feat: temporal - huge refactor

This commit is contained in:
Nevo David 2026-01-05 15:49:19 +07:00
parent d8a6215155
commit da0045428a
103 changed files with 1108 additions and 6936 deletions

View File

@ -16,13 +16,10 @@ import { MediaController } from '@gitroom/backend/api/routes/media.controller';
import { UploadModule } from '@gitroom/nestjs-libraries/upload/upload.module';
import { BillingController } from '@gitroom/backend/api/routes/billing.controller';
import { NotificationsController } from '@gitroom/backend/api/routes/notifications.controller';
import { MarketplaceController } from '@gitroom/backend/api/routes/marketplace.controller';
import { MessagesController } from '@gitroom/backend/api/routes/messages.controller';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service';
import { CodesService } from '@gitroom/nestjs-libraries/services/codes.service';
import { CopilotController } from '@gitroom/backend/api/routes/copilot.controller';
import { AgenciesController } from '@gitroom/backend/api/routes/agencies.controller';
import { PublicController } from '@gitroom/backend/api/routes/public.controller';
import { RootController } from '@gitroom/backend/api/routes/root.controller';
import { TrackService } from '@gitroom/nestjs-libraries/track/track.service';
@ -44,10 +41,7 @@ const authenticatedController = [
MediaController,
BillingController,
NotificationsController,
MarketplaceController,
MessagesController,
CopilotController,
AgenciesController,
WebhookController,
SignatureController,
AutopostController,

View File

@ -1,37 +0,0 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { User } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { AgenciesService } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.service';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { CreateAgencyDto } from '@gitroom/nestjs-libraries/dtos/agencies/create.agency.dto';
@ApiTags('Agencies')
@Controller('/agencies')
export class AgenciesController {
constructor(private _agenciesService: AgenciesService) {}
@Get('/')
async getAgencyByUser(@GetUserFromRequest() user: User) {
return (await this._agenciesService.getAgencyByUser(user)) || {};
}
@Post('/')
async createAgency(
@GetUserFromRequest() user: User,
@Body() body: CreateAgencyDto
) {
return this._agenciesService.createAgency(user, body);
}
@Post('/action/:action/:id')
async updateAgency(
@GetUserFromRequest() user: User,
@Param('action') action: string,
@Param('id') id: string
) {
if (!user.isSuperAdmin) {
return 400;
}
return this._agenciesService.approveOrDecline(user.email, action, id);
}
}

View File

@ -1,56 +1,13 @@
import {
Body,
Controller,
Get,
Inject,
Param,
Post,
Query,
} from '@nestjs/common';
import { Controller, Get, Param, Query } from '@nestjs/common';
import { Organization } from '@prisma/client';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import dayjs from 'dayjs';
import { StarsListDto } from '@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto';
import { ApiTags } from '@nestjs/swagger';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
@ApiTags('Analytics')
@Controller('/analytics')
export class AnalyticsController {
constructor(
private _starsService: StarsService,
private _integrationService: IntegrationService
) {}
@Get('/')
async getStars(@GetOrgFromRequest() org: Organization) {
return this._starsService.getStars(org.id);
}
@Get('/trending')
async getTrending() {
const todayTrending = dayjs(dayjs().format('YYYY-MM-DDT12:00:00'));
const last = todayTrending.isAfter(dayjs())
? todayTrending.subtract(1, 'day')
: todayTrending;
const nextTrending = last.add(1, 'day');
return {
last: last.format('YYYY-MM-DD HH:mm:ss'),
predictions: nextTrending.format('YYYY-MM-DD HH:mm:ss'),
};
}
@Post('/stars')
async getStarsFilter(
@GetOrgFromRequest() org: Organization,
@Body() starsFilter: StarsListDto
) {
return {
stars: await this._starsService.getStarsFilter(org.id, starsFilter),
};
}
constructor(private _integrationService: IntegrationService) {}
@Get('/:integration')
async getIntegration(

View File

@ -1,242 +0,0 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { Organization, User } from '@prisma/client';
import { ApiTags } from '@nestjs/swagger';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { ItemUserService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/item.user.service';
import { AddRemoveItemDto } from '@gitroom/nestjs-libraries/dtos/marketplace/add.remove.item.dto';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { ChangeActiveDto } from '@gitroom/nestjs-libraries/dtos/marketplace/change.active.dto';
import { ItemsDto } from '@gitroom/nestjs-libraries/dtos/marketplace/items.dto';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { AudienceDto } from '@gitroom/nestjs-libraries/dtos/marketplace/audience.dto';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { CreateOfferDto } from '@gitroom/nestjs-libraries/dtos/marketplace/create.offer.dto';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
@ApiTags('Marketplace')
@Controller('/marketplace')
export class MarketplaceController {
constructor(
private _itemUserService: ItemUserService,
private _stripeService: StripeService,
private _userService: UsersService,
private _messagesService: MessagesService,
private _postsService: PostsService
) {}
@Post('/')
getInfluencers(
@GetOrgFromRequest() organization: Organization,
@GetUserFromRequest() user: User,
@Body() body: ItemsDto
) {
return this._userService.getMarketplacePeople(
organization.id,
user.id,
body
);
}
@Post('/conversation')
createConversation(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Body() body: NewConversationDto
) {
return this._messagesService.createConversation(
user.id,
organization.id,
body
);
}
@Get('/bank')
connectBankAccount(
@GetUserFromRequest() user: User,
@Query('country') country: string
) {
return this._stripeService.createAccountProcess(
user.id,
user.email,
country
);
}
@Post('/item')
async addItems(
@GetUserFromRequest() user: User,
@Body() body: AddRemoveItemDto
) {
return this._itemUserService.addOrRemoveItem(body.state, user.id, body.key);
}
@Post('/active')
async changeActive(
@GetUserFromRequest() user: User,
@Body() body: ChangeActiveDto
) {
await this._userService.changeMarketplaceActive(user.id, body.active);
}
@Post('/audience')
async changeAudience(
@GetUserFromRequest() user: User,
@Body() body: AudienceDto
) {
await this._userService.changeAudienceSize(user.id, body.audience);
}
@Get('/item')
async getItems(@GetUserFromRequest() user: User) {
return this._itemUserService.getItems(user.id);
}
@Get('/orders')
async getOrders(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Query('type') type: 'seller' | 'buyer'
) {
return this._messagesService.getOrders(user.id, organization.id, type);
}
@Get('/account')
async getAccount(@GetUserFromRequest() user: User) {
const { account, marketplace, connectedAccount, name, picture, audience } =
await this._userService.getUserByEmail(user.email);
return {
account,
marketplace,
connectedAccount,
fullname: name,
audience,
picture,
};
}
@Post('/offer')
async createOffer(
@GetUserFromRequest() user: User,
@Body() body: CreateOfferDto
) {
return this._messagesService.createOffer(user.id, body);
}
@Get('/posts/:id')
async post(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string
) {
const getPost = await this._messagesService.getPost(
user.id,
organization.id,
id
);
if (!getPost) {
return;
}
return {
...(await this._postsService.getPost(getPost.organizationId, id)),
providerId: getPost.integration.providerIdentifier,
};
}
@Post('/posts/:id/revision')
async revision(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string,
@Body('message') message: string
) {
return this._messagesService.requestRevision(
user.id,
organization.id,
id,
message
);
}
@Post('/posts/:id/approve')
async approve(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string,
@Body('message') message: string
) {
return this._messagesService.requestApproved(
user.id,
organization.id,
id,
message
);
}
@Post('/posts/:id/cancel')
async cancel(
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string
) {
return this._messagesService.requestCancel(organization.id, id);
}
@Post('/offer/:id/complete')
async completeOrder(
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string
) {
const order = await this._messagesService.completeOrderAndPay(
organization.id,
id
);
if (!order) {
return;
}
try {
await this._stripeService.payout(
id,
order.charge,
order.account,
order.price
);
} catch (e) {
await this._messagesService.payoutProblem(
id,
order.sellerId,
order.price
);
}
await this._messagesService.completeOrder(id);
}
@Post('/orders/:id/payment')
async payOrder(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('id') id: string
) {
const orderDetails = await this._messagesService.getOrderDetails(
user.id,
organization.id,
id
);
const payment = await this._stripeService.payAccountStepOne(
user.id,
organization,
orderDetails.seller,
orderDetails.order.id,
orderDetails.order.ordersItems.map((p) => ({
quantity: p.quantity,
integrationType: p.integration.providerIdentifier,
price: p.price,
})),
orderDetails.order.messageGroupId
);
return payment;
}
}

View File

@ -1,50 +0,0 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { GetUserFromRequest } from '@gitroom/nestjs-libraries/user/user.from.request';
import { Organization, User } from '@prisma/client';
import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
@ApiTags('Messages')
@Controller('/messages')
export class MessagesController {
constructor(private _messagesService: MessagesService) {}
@Get('/')
getMessagesGroup(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization
) {
return this._messagesService.getMessagesGroup(user.id, organization.id);
}
@Get('/:groupId/:page')
getMessages(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('groupId') groupId: string,
@Param('page') page: string
) {
return this._messagesService.getMessages(
user.id,
organization.id,
groupId,
+page
);
}
@Post('/:groupId')
createMessage(
@GetUserFromRequest() user: User,
@GetOrgFromRequest() organization: Organization,
@Param('groupId') groupId: string,
@Body() message: AddMessageDto
) {
return this._messagesService.createMessage(
user.id,
organization.id,
groupId,
message
);
}
}

View File

@ -1,30 +1,14 @@
import { Controller, Get, HttpException, Param } from '@nestjs/common';
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
@ApiTags('Monitor')
@Controller('/monitor')
export class MonitorController {
constructor(private _workerServiceProducer: BullMqClient) {}
@Get('/queue/:name')
async getMessagesGroup(@Param('name') name: string) {
const { valid } =
await this._workerServiceProducer.checkForStuckWaitingJobs(name);
if (valid) {
return {
status: 'success',
message: `Queue ${name} is healthy.`,
};
}
throw new HttpException(
{
status: 'error',
message: `Queue ${name} has stuck waiting jobs.`,
},
503
);
return {
status: 'success',
message: `Queue ${name} is healthy.`,
};
}
}

View File

@ -13,10 +13,8 @@ import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/po
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization, User } from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import { ApiTags } from '@nestjs/swagger';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { GeneratorDto } from '@gitroom/nestjs-libraries/dtos/generator/generator.dto';
import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto';
import { AgentGraphService } from '@gitroom/nestjs-libraries/agent/agent.graph.service';
@ -31,8 +29,6 @@ import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/p
export class PostsController {
constructor(
private _postsService: PostsService,
private _starsService: StarsService,
private _messagesService: MessagesService,
private _agentGraphService: AgentGraphService,
private _shortLinkService: ShortLinkService
) {}
@ -50,14 +46,6 @@ export class PostsController {
return { ask: this._shortLinkService.askShortLinkedin(body.messages) };
}
@Get('/marketplace/:id')
async getMarketplacePosts(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return this._messagesService.getMarketplaceAvailableOffers(org.id, id);
}
@Post('/:id/comments')
async createComment(
@GetOrgFromRequest() org: Organization,
@ -115,11 +103,6 @@ export class PostsController {
return { date: await this._postsService.findFreeDateTime(org.id, id) };
}
@Get('/predict-trending')
predictTrending() {
return this._starsService.predictTrending();
}
@Get('/old')
oldPosts(
@GetOrgFromRequest() org: Organization,

View File

@ -1,7 +1,6 @@
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import { GetOrgFromRequest } from '@gitroom/nestjs-libraries/user/org.from.request';
import { Organization } from '@prisma/client';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import { CheckPolicies } from '@gitroom/backend/services/auth/permissions/permissions.ability';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { AddTeamMemberDto } from '@gitroom/nestjs-libraries/dtos/settings/add.team.member.dto';
@ -12,95 +11,9 @@ import { AuthorizationActions, Sections } from '@gitroom/backend/services/auth/p
@Controller('/settings')
export class SettingsController {
constructor(
private _starsService: StarsService,
private _organizationService: OrganizationService
) {}
@Get('/github')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getConnectedGithubAccounts(@GetOrgFromRequest() org: Organization) {
return {
github: (
await this._starsService.getGitHubRepositoriesByOrgId(org.id)
).map((repo) => ({
id: repo.id,
login: repo.login,
})),
};
}
@Post('/github')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async addGitHub(
@GetOrgFromRequest() org: Organization,
@Body('code') code: string
) {
if (!code) {
throw new Error('No code provided');
}
await this._starsService.addGitHub(org.id, code);
}
@Get('/github/url')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
authUrl() {
return {
url: `https://github.com/login/oauth/authorize?client_id=${
process.env.GITHUB_CLIENT_ID
}&scope=${encodeURIComponent(
'user:email'
)}&redirect_uri=${encodeURIComponent(
`${process.env.FRONTEND_URL}/settings`
)}`,
};
}
@Get('/organizations/:id')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getOrganizations(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return {
organizations: await this._starsService.getOrganizations(org.id, id),
};
}
@Get('/organizations/:id/:github')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async getRepositories(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Param('github') github: string
) {
return {
repositories: await this._starsService.getRepositoriesOfOrganization(
org.id,
id,
github
),
};
}
@Post('/organizations/:id')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async updateGitHubLogin(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string,
@Body('login') login: string
) {
return this._starsService.updateGitHubLogin(org.id, id, login);
}
@Delete('/repository/:id')
@CheckPolicies([AuthorizationActions.Create, Sections.ADMIN])
async deleteRepository(
@GetOrgFromRequest() org: Organization,
@Param('id') id: string
) {
return this._starsService.deleteRepository(org.id, id);
}
@Get('/team')
@CheckPolicies(
[AuthorizationActions.Create, Sections.TEAM_MEMBERS],

View File

@ -1,47 +1,19 @@
import {
Controller,
Get,
Header,
HttpException,
Param,
Post,
RawBodyRequest,
Req,
} from '@nestjs/common';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { ApiTags } from '@nestjs/swagger';
import { CodesService } from '@gitroom/nestjs-libraries/services/codes.service';
@ApiTags('Stripe')
@Controller('/stripe')
export class StripeController {
constructor(
private readonly _stripeService: StripeService,
private readonly _codesService: CodesService
) {}
@Post('/connect')
stripeConnect(@Req() req: RawBodyRequest<Request>) {
const event = this._stripeService.validateRequest(
req.rawBody,
// @ts-ignore
req.headers['stripe-signature'],
process.env.STRIPE_SIGNING_KEY_CONNECT
);
// Maybe it comes from another stripe webhook
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (event?.data?.object?.metadata?.service !== 'gitroom') {
return { ok: true };
}
switch (event.type) {
case 'account.updated':
return this._stripeService.updateAccount(event);
default:
return { ok: true };
}
}
@Post('/')
stripe(@Req() req: RawBodyRequest<Request>) {
@ -66,8 +38,6 @@ export class StripeController {
switch (event.type) {
case 'invoice.payment_succeeded':
return this._stripeService.paymentSucceeded(event);
case 'account.updated':
return this._stripeService.updateAccount(event);
case 'customer.subscription.created':
return this._stripeService.createSubscription(event);
case 'customer.subscription.updated':
@ -81,11 +51,4 @@ export class StripeController {
throw new HttpException(e, 500);
}
}
@Get('/lifetime-deal-codes/:provider')
@Header('Content-disposition', 'attachment; filename=codes.csv')
@Header('Content-type', 'text/csv')
async getStripeCodes(@Param('provider') providerToken: string) {
return this._codesService.generateCodes(providerToken);
}
}

View File

@ -3,7 +3,6 @@ import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/databa
import { ApiModule } from '@gitroom/backend/api/api.module';
import { APP_GUARD } from '@nestjs/core';
import { PoliciesGuard } from '@gitroom/backend/services/auth/permissions/permissions.guard';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { PublicApiModule } from '@gitroom/backend/public-api/public.api.module';
import { ThrottlerBehindProxyGuard } from '@gitroom/nestjs-libraries/throttler/throttler.provider';
import { ThrottlerModule } from '@nestjs/throttler';
@ -20,7 +19,6 @@ import { TemporalRegisterMissingSearchAttributesModule } from '@gitroom/nestjs-l
@Module({
imports: [
SentryModule.forRoot(),
BullMqModule,
DatabaseModule,
ApiModule,
PublicApiModule,
@ -50,7 +48,6 @@ import { TemporalRegisterMissingSearchAttributesModule } from '@gitroom/nestjs-l
},
],
exports: [
BullMqModule,
DatabaseModule,
ApiModule,
PublicApiModule,

View File

@ -3,6 +3,8 @@ initializeSentry('backend', true);
import { loadSwagger } from '@gitroom/helpers/swagger/load.swagger';
import { json } from 'express';
import { Runtime } from '@temporalio/worker';
Runtime.install({ shutdownSignals: [] });
process.env.TZ = 'UTC';
@ -21,7 +23,11 @@ async function start() {
rawBody: true,
cors: {
...(!process.env.NOT_SECURED ? { credentials: true } : {}),
allowedHeaders: ['Content-Type', 'Authorization', 'x-copilotkit-runtime-client-gql-version'],
allowedHeaders: [
'Content-Type',
'Authorization',
'x-copilotkit-runtime-client-gql-version',
],
exposedHeaders: [
'reload',
'onboarding',

View File

@ -1,17 +1,15 @@
import { Module } from '@nestjs/common';
import { CommandModule as ExternalCommandModule } from 'nestjs-command';
import { CheckStars } from './tasks/check.stars';
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { RefreshTokens } from './tasks/refresh.tokens';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { ConfigurationTask } from './tasks/configuration';
import { AgentRun } from './tasks/agent.run';
import { AgentModule } from '@gitroom/nestjs-libraries/agent/agent.module';
@Module({
imports: [ExternalCommandModule, DatabaseModule, BullMqModule, AgentModule],
imports: [ExternalCommandModule, DatabaseModule, AgentModule],
controllers: [],
providers: [CheckStars, RefreshTokens, ConfigurationTask, AgentRun],
providers: [RefreshTokens, ConfigurationTask, AgentRun],
get exports() {
return [...this.imports, ...this.providers];
},

View File

@ -1,52 +0,0 @@
import { Command, Positional } from 'nestjs-command';
import { Injectable } from '@nestjs/common';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
@Injectable()
export class CheckStars {
constructor(private _workerServiceProducer: BullMqClient) {}
@Command({
command: 'sync:stars <login>',
describe: 'Sync stars for a login',
})
async create(
@Positional({
name: 'login',
describe: 'login {owner}/{repo}',
type: 'string',
})
login: string
) {
this._workerServiceProducer
.emit('check_stars', { payload: { login } })
.subscribe();
return true;
}
@Command({
command: 'sync:all_stars <login>',
describe: 'Sync all stars for a login',
})
async syncAllStars(
@Positional({
name: 'login',
describe: 'login {owner}/{repo}',
type: 'string',
})
login: string
) {
this._workerServiceProducer
.emit('sync_all_stars', { payload: { login } })
.subscribe();
return true;
}
@Command({
command: 'sync:trending',
describe: 'Sync trending',
})
async syncTrending() {
this._workerServiceProducer.emit('sync_trending', {}).subscribe();
return true;
}
}

View File

@ -1,8 +0,0 @@
dist/
node_modules/
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]

View File

@ -1,20 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"monorepo": false,
"sourceRoot": "src",
"entryFile": "../../dist/cron/apps/cron/src/main",
"language": "ts",
"generateOptions": {
"spec": false
},
"compilerOptions": {
"manualRestart": true,
"tsConfigPath": "./tsconfig.build.json",
"webpack": false,
"deleteOutDir": true,
"assets": [],
"watchAssets": false,
"plugins": []
}
}

View File

@ -1,14 +0,0 @@
{
"name": "postiz-cron",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/cron/src/main",
"build": "cross-env NODE_ENV=production nest build",
"start": "dotenv -e ../../.env -- node --experimental-require-module ./dist/apps/cron/src/main.js",
"pm2": "pm2 start pnpm --name cron -- start"
},
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@ -1,20 +0,0 @@
import { Module } from '@nestjs/common';
import { ScheduleModule } from '@nestjs/schedule';
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { SentryModule } from '@sentry/nestjs/setup';
import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
import { CheckMissingQueues } from '@gitroom/cron/tasks/check.missing.queues';
import { PostNowPendingQueues } from '@gitroom/cron/tasks/post.now.pending.queues';
@Module({
imports: [
SentryModule.forRoot(),
DatabaseModule,
ScheduleModule.forRoot(),
BullMqModule,
],
controllers: [],
providers: [FILTER, CheckMissingQueues, PostNowPendingQueues],
})
export class CronModule {}

View File

@ -1,12 +0,0 @@
import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry';
initializeSentry('cron');
import { NestFactory } from '@nestjs/core';
import { CronModule } from './cron.module';
async function bootstrap() {
// some comment again
await NestFactory.createApplicationContext(CronModule);
}
bootstrap();

View File

@ -1,45 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import dayjs from 'dayjs';
@Injectable()
export class CheckMissingQueues {
constructor(
private _postService: PostsService,
private _workerServiceProducer: BullMqClient
) {}
@Cron('0 * * * *')
async handleCron() {
const list = await this._postService.searchForMissingThreeHoursPosts();
const notExists = (
await Promise.all(
list.map(async (p) => ({
id: p.id,
publishDate: p.publishDate,
isJob:
['delayed', 'waiting'].indexOf(
await this._workerServiceProducer
.getQueue('post')
.getJobState(p.id)
) > -1,
}))
)
).filter((p) => !p.isJob);
for (const job of notExists) {
this._workerServiceProducer.emit('post', {
id: job.id,
options: {
delay: dayjs(job.publishDate).diff(dayjs(), 'millisecond'),
},
payload: {
id: job.id,
delay: dayjs(job.publishDate).diff(dayjs(), 'millisecond'),
},
});
}
}
}

View File

@ -1,43 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
@Injectable()
export class PostNowPendingQueues {
constructor(
private _postService: PostsService,
private _workerServiceProducer: BullMqClient
) {}
@Cron('*/16 * * * *')
async handleCron() {
const list = await this._postService.checkPending15minutesBack();
const notExists = (
await Promise.all(
list.map(async (p) => ({
id: p.id,
publishDate: p.publishDate,
isJob:
['delayed', 'waiting'].indexOf(
await this._workerServiceProducer
.getQueue('post')
.getJobState(p.id)
) > -1,
}))
)
).filter((p) => !p.isJob);
for (const job of notExists) {
this._workerServiceProducer.emit('post', {
id: job.id,
options: {
delay: 0,
},
payload: {
id: job.id,
delay: 0,
},
});
}
}
}

View File

@ -1,23 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"],
"compilerOptions": {
"module": "CommonJS",
"resolveJsonModule": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"outDir": "./dist"
}
}

View File

@ -1,13 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"allowSyntheticDefaultImports": true,
"noLib": false,
"target": "ES2021",
"sourceMap": true,
"esModuleInterop": true,
}
}

View File

@ -77,7 +77,6 @@ export const SettingsPopup: FC<{
return;
}
toast.show(t('profile_updated', 'Profile updated'));
swr.mutate('/marketplace/account');
close();
}, []);

View File

@ -1,40 +0,0 @@
'use client';
import { FC } from 'react';
import { usePathname } from 'next/navigation';
import clsx from 'clsx';
import Link from 'next/link';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
export const BuyerSeller: FC = () => {
const path = usePathname();
const t = useT();
const pathComputed = path === '/marketplace' ? '/marketplace/seller' : path;
return (
<div className="relative">
<div className="w-[286px] h-[50px] bg-third p-[9px] flex select-none absolute -translate-y-[63px] end-0">
<div className="bg-input flex flex-1">
<Link
href="/marketplace/seller"
className={clsx(
'flex justify-center items-center flex-1',
pathComputed.indexOf('/marketplace/seller') > -1 &&
'bg-forth text-white'
)}
>
{t('seller', 'Seller')}
</Link>
<Link
href="/marketplace/buyer"
className={clsx(
'flex justify-center items-center flex-1',
pathComputed.indexOf('/marketplace/buyer') > -1 &&
'bg-forth text-white'
)}
>
{t('buyer', 'Buyer')}
</Link>
</div>
</div>
</div>
);
};

View File

@ -1,568 +0,0 @@
'use client';
import React, {
FC,
Fragment,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Checkbox } from '@gitroom/react/form/checkbox';
import { useRouter, useSearchParams } from 'next/navigation';
import clsx from 'clsx';
import { Button } from '@gitroom/react/form/button';
import {
allTagsOptions,
tagsList,
} from '@gitroom/nestjs-libraries/database/prisma/marketplace/tags.list';
import { capitalize, chunk, fill } from 'lodash';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { Textarea } from '@gitroom/react/form/textarea';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
import { OrderList } from '@gitroom/frontend/components/marketplace/order.list';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
export interface Root {
list: List[];
count: number;
}
export interface List {
id: string;
name: any;
bio: string;
audience: number;
picture: {
id: string;
path: string;
};
organizations: Organization[];
items: Item[];
}
export interface Organization {
organization: Organization2;
}
export interface Organization2 {
Integration: Integration[];
}
export interface Integration {
providerIdentifier: string;
}
export interface Item {
key: string;
}
export const LabelCheckbox: FC<{
label: string;
name: string;
value: string;
checked: boolean;
onChange: (value: string, status: boolean) => void;
}> = (props) => {
const { label, name, value, checked, onChange } = props;
const ref = useRef<any>(null);
const [innerCheck, setInnerCheck] = useState(checked);
const change = useCallback(() => {
setInnerCheck(!innerCheck);
onChange(value, !innerCheck);
}, [innerCheck]);
return (
<div className="flex items-center gap-[10px] select-none">
<Checkbox
ref={ref}
variant="hollow"
name={name}
checked={checked}
onChange={change}
disableForm={true}
/>
<label
onClick={() => ref.current.click()}
className="text-[20px]"
htmlFor={name}
>
{label}
</label>
</div>
);
};
const Pagination: FC<{
results: number;
}> = (props) => {
const { results } = props;
const router = useRouter();
const search = useSearchParams();
const page = +(parseInt(search.get('page')!) || 1) - 1;
const t = useT();
const from = page * 8;
const to = (page + 1) * 8;
const pagesArray = useMemo(() => {
return Array.from(
{
length: Math.ceil(results / 8),
},
(_, i) => i + 1
);
}, [results]);
const changePage = useCallback(
(newPage: number) => () => {
const params = new URLSearchParams(window.location.search);
params.set('page', String(newPage));
router.replace('?' + params.toString(), {
scroll: true,
});
},
[page]
);
if (results < 8) {
return null;
}
return (
<div className="flex items-center relative">
<div className="absolute start-0">
{t('showing', 'Showing')}
{from + 1}
{t('to', 'to')}
{to > results ? results : to}
{t('from', 'from')}
{results}
{t('results', 'Results')}
</div>
<div className="flex mx-auto">
{page > 0 && (
<div>
<svg
width="41"
height="40"
viewBox="0 0 41 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
onClick={changePage(page)}
>
<g clipPath="url(#clip0_703_22324)">
<path
d="M22.5 25L17.5 20L22.5 15"
stroke="#64748B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_703_22324">
<rect x="0.5" width="40" height="40" rx="8" fill="white" />
</clipPath>
</defs>
</svg>
</div>
)}
{pagesArray.map((p) => (
<div
key={p}
onClick={changePage(p)}
className={clsx(
'w-[40px] h-[40px] flex justify-center items-center rounded-[8px] cursor-pointer',
p === page + 1 ? 'bg-customColor4' : 'text-inputText'
)}
>
{p}
</div>
))}
{page + 1 < pagesArray[pagesArray.length - 1] && (
<svg
onClick={changePage(page + 2)}
width="41"
height="40"
viewBox="0 0 41 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M18.5 15L23.5 20L18.5 25"
stroke="#64748B"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
</div>
);
};
export const Options: FC<{
title: string;
options: Array<{
key: string;
value: string;
}>;
onChange?: (key: string, value: boolean) => void;
preSelected?: string[];
rows?: number;
search: boolean;
}> = (props) => {
const { title, onChange, search, preSelected } = props;
const query = 'services';
const [selected, setPreSelected] = useState<string[]>(
preSelected?.slice(0) || []
);
const rows = props.rows || 1;
const optionsGroupList = chunk(
props.options,
Math.ceil(props.options.length / rows)
);
const optionsGroup =
optionsGroupList.length < rows
? [
...optionsGroupList,
...fill(Array(rows - optionsGroupList.length), []),
]
: optionsGroupList;
const router = useRouter();
const searchParams = (useSearchParams().get(query) || '')?.split(',') || [];
const change = (value: string, state: boolean) => {
if (onChange) {
onChange(value, state);
}
if (!search) {
return;
}
const getAll = new URLSearchParams(window.location.search).get(query);
const splitAll = (getAll?.split(',') || []).filter((f) => f);
if (state) {
splitAll?.push(value);
} else {
splitAll?.splice(splitAll.indexOf(value), 1);
}
const params = new URLSearchParams(window.location.search);
if (!splitAll?.length) {
params.delete(query);
} else {
params.set(query, splitAll?.join(',') || '');
}
router.replace('?' + params.toString());
return params.toString();
};
return (
<>
<div className="h-[56px] text-[20px] font-[600] flex items-center px-[24px] bg-customColor8">
{title}
</div>
<div className="bg-customColor3 flex px-[32px] py-[24px]">
{optionsGroup.map((options, key) => (
<div
key={`options_` + key}
className="flex gap-[16px] flex-col flex-1 justify-start"
>
{options.map((option) => (
<div key={option.key} className="flex gap-[10px]">
<LabelCheckbox
value={option.key}
label={option.value}
checked={
selected?.indexOf(option.key) > -1 ||
searchParams.indexOf(option.key) > -1
}
name={query}
onChange={change}
/>
</div>
))}
</div>
))}
</div>
</>
);
};
export const RequestService: FC<{
toId: string;
name: string;
}> = (props) => {
const { toId, name } = props;
const router = useRouter();
const fetch = useFetch();
const modal = useModals();
const resolver = useMemo(() => {
return classValidatorResolver(NewConversationDto);
}, []);
const form = useForm({
resolver,
values: {
to: toId,
message: '',
},
});
const close = useCallback(() => {
return modal.closeAll();
}, []);
const t = useT();
const createConversation: SubmitHandler<NewConversationDto> = useCallback(
async (data) => {
const { id } = await (
await fetch('/marketplace/conversation', {
method: 'POST',
body: JSON.stringify(data),
})
).json();
close();
router.push(`/messages/${id}`);
},
[]
);
return (
<form onSubmit={form.handleSubmit(createConversation)}>
<FormProvider {...form}>
<div className="w-full max-w-[920px] mx-auto bg-sixth px-[16px] rounded-[4px] border border-customColor6 gap-[24px] flex flex-col relative">
<button
onClick={close}
className="outline-none absolute end-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root 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"
>
<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 className="text-[18px] font-[500] flex flex-col">
<TopTitle title={`Send a message to ${name}`} />
<Textarea
placeholder="Add a message like: I'm intrested in 3 posts for Linkedin... (min 50 chars)"
className="mt-[14px] resize-none h-[400px]"
name="message"
label=""
/>
<div className="flex justify-end">
<Button
disabled={!form.formState.isValid}
type="submit"
className="w-[144px] mb-[16px] rounded-[4px] text-[14px]"
>
{t('send_message', 'Send Message')}
</Button>
</div>
</div>
</div>
</FormProvider>
</form>
);
};
export const Card: FC<{
data: List;
}> = (props) => {
const { data } = props;
const modal = useModals();
const tags = useMemo(() => {
return data.items
.filter((f) => !['content-writer', 'influencers'].includes(f.key))
.map((p) => {
return allTagsOptions?.find((t) => t.key === p.key)?.value;
});
}, [data]);
const requestService = useCallback(() => {
modal.openModal({
children: <RequestService toId={data.id} name={data.name || 'Noname'} />,
classNames: {
modal: 'bg-transparent text-textColor',
},
withCloseButton: false,
size: '100%',
});
}, []);
const t = useT();
const identifier = useMemo(() => {
return [
...new Set(
data.organizations.flatMap((p) =>
p.organization.Integration.flatMap((d) => d.providerIdentifier)
)
),
];
}, []);
return (
<div className="min-h-[155px] bg-sixth p-[24px] flex">
<div className="flex gap-[16px] flex-1">
<div>
<div className="h-[103px] w-[103px] bg-red-500/10 rounded-full relative">
{data?.picture?.path && (
<img
src={data?.picture?.path}
className="rounded-full w-full h-full"
/>
)}
<div className="w-[80px] h-[28px] bg-customColor4 absolute bottom-0 start-[50%] -translate-x-[50%] rounded-[30px] flex gap-[4px] justify-center items-center">
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
>
<path
d="M7.82348 9.80876C8.45164 9.28076 8.90221 8.57235 9.11409 7.77958C9.32597 6.98682 9.28891 6.14807 9.00792 5.37709C8.72694 4.60611 8.21563 3.9402 7.54334 3.46967C6.87106 2.99914 6.07032 2.74677 5.24973 2.74677C4.42914 2.74677 3.62841 2.99914 2.95612 3.46967C2.28383 3.9402 1.77253 4.60611 1.49154 5.37709C1.21056 6.14807 1.17349 6.98682 1.38537 7.77958C1.59725 8.57235 2.04782 9.28076 2.67598 9.80876C1.69509 10.2523 0.845054 10.9411 0.207856 11.8088C0.0901665 11.9691 0.0410049 12.1697 0.0711866 12.3663C0.101368 12.5629 0.208421 12.7395 0.368794 12.8572C0.529167 12.9749 0.729724 13.024 0.926344 12.9939C1.12296 12.9637 1.29954 12.8566 1.41723 12.6963C1.85832 12.0938 2.43521 11.6039 3.10109 11.2662C3.76697 10.9284 4.50309 10.7524 5.24973 10.7524C5.99637 10.7524 6.73249 10.9284 7.39837 11.2662C8.06426 11.6039 8.64114 12.0938 9.08223 12.6963C9.19992 12.8567 9.37653 12.9638 9.57321 12.9941C9.76989 13.0243 9.97053 12.9752 10.131 12.8575C10.2914 12.7398 10.3986 12.5632 10.4288 12.3665C10.459 12.1699 10.4099 11.9692 10.2922 11.8088C9.65465 10.9412 8.80445 10.2524 7.82348 9.80876ZM2.74973 6.75001C2.74973 6.25556 2.89635 5.77221 3.17106 5.36108C3.44576 4.94996 3.83621 4.62953 4.29302 4.44031C4.74984 4.25109 5.2525 4.20158 5.73746 4.29805C6.22241 4.39451 6.66787 4.63261 7.0175 4.98224C7.36713 5.33187 7.60523 5.77733 7.70169 6.26228C7.79816 6.74724 7.74865 7.2499 7.55943 7.70672C7.37021 8.16353 7.04978 8.55398 6.63866 8.82868C6.22753 9.10339 5.74418 9.25001 5.24973 9.25001C4.58669 9.25001 3.95081 8.98662 3.48196 8.51778C3.01312 8.04894 2.74973 7.41305 2.74973 6.75001ZM15.631 12.8544C15.5516 12.9127 15.4615 12.9549 15.3658 12.9784C15.2701 13.0019 15.1707 13.0063 15.0733 12.9914C14.9759 12.9765 14.8824 12.9425 14.7982 12.8914C14.7139 12.8404 14.6405 12.7732 14.5822 12.6938C14.1401 12.0925 13.5629 11.6034 12.8973 11.2658C12.2317 10.9282 11.4961 10.7515 10.7497 10.75C10.5508 10.75 10.3601 10.671 10.2194 10.5303C10.0787 10.3897 9.99973 10.1989 9.99973 10C9.99973 9.8011 10.0787 9.61033 10.2194 9.46968C10.3601 9.32903 10.5508 9.25001 10.7497 9.25001C11.1178 9.24958 11.4813 9.16786 11.8142 9.01071C12.147 8.85355 12.4411 8.62482 12.6753 8.34086C12.9096 8.05691 13.0782 7.72473 13.1692 7.36805C13.2602 7.01138 13.2713 6.63901 13.2017 6.27754C13.1322 5.91607 12.9837 5.57443 12.7668 5.277C12.5499 4.97958 12.27 4.73373 11.9471 4.557C11.6242 4.38027 11.2662 4.27702 10.8988 4.25464C10.5314 4.23225 10.1636 4.29128 9.82161 4.42751C9.73 4.46502 9.63188 4.48402 9.5329 4.48343C9.43392 4.48283 9.33603 4.46265 9.24488 4.42404C9.15374 4.38543 9.07114 4.32916 9.00184 4.25848C8.93255 4.18779 8.87793 4.10409 8.84114 4.0122C8.80435 3.9203 8.78612 3.82204 8.78749 3.72306C8.78885 3.62409 8.8098 3.52636 8.84912 3.43552C8.88844 3.34468 8.94535 3.26252 9.01658 3.19378C9.0878 3.12504 9.17193 3.07108 9.26411 3.03501C10.1468 2.6816 11.1265 2.65418 12.0276 2.95767C12.9287 3.26115 13.6922 3.87569 14.1812 4.6911C14.6703 5.50652 14.8528 6.46947 14.6962 7.4073C14.5396 8.34512 14.0541 9.1965 13.3266 9.80876C14.3075 10.2523 15.1575 10.9411 15.7947 11.8088C15.9113 11.9693 15.9594 12.1694 15.9288 12.3654C15.8981 12.5613 15.791 12.7372 15.631 12.8544Z"
fill="white"
/>
</svg>
</div>
<div className="text-[14px]">{data?.audience}</div>
</div>
</div>
</div>
<div className="flex flex-col gap-[8px]">
<div className="flex gap-[14px] items-center">
<div className="text-[24px]">{data.name || 'Noname'}</div>
<div className="flex gap-[3px]">
{data.items.some((i) => i.key === 'content-writer') && (
<div
className={clsx(
'bg-customColor6 rounded-[34px] py-[8px] px-[12px] text-[12px]',
)}
>
{t('content_writer', 'Content Writer')}
</div>
)}
{data.items.some((i) => i.key === 'influencers') && (
<div
className={clsx(
'bg-customColor6 rounded-[34px] py-[8px] px-[12px] text-[12px]',
)}
>
{t('influencer', 'Influencer')}
</div>
)}
</div>
<div className="flex gap-[10px]">
{identifier.map((i) => (
<img
key={i}
src={`/icons/platforms/${i}.png`}
className="w-[24px] h-[24px] rounded-full"
/>
))}
</div>
</div>
<div className="text-[18px] text-customColor18 font-[400]">
{data.bio || 'No bio'}
</div>
<div
className={clsx(
'gap-[8px] flex items-center text-[10px] font-[300] text-customColor41 tracking-[1.2px] uppercase',
)}
>
{tags.map((tag, index) => (
<Fragment key={tag}>
<div>{tag}</div>
{index !== tags.length - 1 && (
<div>
<div className="w-[4px] h-[4px] bg-customColor1 rounded-full" />
</div>
)}
</Fragment>
))}
</div>
</div>
</div>
<div className="ms-[100px] items-center flex">
<Button onClick={requestService}>
{t('request_service', 'Request Service')}
</Button>
</div>
</div>
);
};
export const Buyer = () => {
const search = useSearchParams();
const services = search.get('services');
const page = +(search.get('page') || 1);
const router = useRouter();
const fetch = useFetch();
const marketplace = useCallback(async () => {
return await (
await fetch('/marketplace', {
method: 'POST',
body: JSON.stringify({
items: services?.split(',').filter((f) => f) || [],
page: page === 0 ? 1 : page,
}),
})
).json();
}, [services, page]);
const t = useT();
useEffect(() => {
if (!services) {
return;
}
const params = new URLSearchParams(window.location.search);
params.set('page', '1');
router.replace('?' + params.toString());
}, [services]);
const { data: list } = useSWR<Root>('search' + services + page, marketplace);
return (
<div className="flex flex-col items-center mt-[100px] gap-[27px] text-center">
<div>
<img src="/peoplemarketplace.svg" />
</div>
<div className="text-[48px]">
{t(
'the_marketplace_is_not_opened_yet',
'The marketplace is not opened yet'
)}
<br />
{t('check_again_soon', 'Check again soon!')}
</div>
</div>
);
return (
<>
<div>
<OrderList type="buyer" />
</div>
<div className="flex mt-[29px] w-full gap-[43px]">
<div className="w-[330px]">
<div className="flex flex-col gap-[16px]">
<h2 className="text-[20px]">{t('filter', 'Filter')}</h2>
<div className="flex flex-col">
{tagsList.map((tag) => (
<Options
search={true}
key={tag.key}
options={tag.options}
title={tag.name}
/>
))}
</div>
</div>
</div>
<div className="flex-1 gap-[16px] flex-col flex">
<div className="text-[20px] text-right">
{list?.count || 0}
{t('result', 'Result')}
</div>
{list?.list?.map((item, index) => (
<Card key={String(index)} data={item} />
))}
{/*<Pagination results={list?.count || 0} />*/}
</div>
</div>
</>
);
};

View File

@ -1,36 +0,0 @@
'use client';
import { createContext } from 'react';
import { Orders } from '@prisma/client';
export interface Root2 {
id: string;
buyerId: string;
sellerId: string;
createdAt: string;
updatedAt: string;
buyer: SellerBuyer;
seller: SellerBuyer;
messages: Message[];
orders: Orders[];
}
export interface SellerBuyer {
id: string;
name: any;
picture: Picture;
}
export interface Picture {
id: string;
path: string;
}
export interface Message {
id: string;
from: string;
content: string;
groupId: string;
createdAt: string;
updatedAt: string;
deletedAt: any;
}
export const MarketplaceProvider = createContext<{
message?: Root2;
}>({});

View File

@ -1,3 +0,0 @@
export const Marketplace = () => {
return <div />;
};

View File

@ -1,79 +0,0 @@
import React, { FC, useCallback, useMemo } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
export const OrderList: FC<{
type: 'seller' | 'buyer';
}> = (props) => {
const fetch = useFetch();
const { type } = props;
const getOrderDetails = useCallback(async () => {
return (await fetch(`/marketplace/orders?type=${type}`)).json();
}, [type]);
const { data, isLoading } = useSWR(
`/marketplace/orders/${type}`,
getOrderDetails
);
const t = useT();
const biggerRow = useMemo(() => {
return data?.orders?.reduce((all: any, current: any) => {
if (current.details.length > all) return current.details.length;
return all;
}, 0);
}, [data]);
if (isLoading || !data?.orders?.length) return <></>;
return (
<div className="bg-sixth p-[24px] flex flex-col gap-[24px] border border-customColor6 rounded-[4px]">
<h3 className="text-[24px]">{t('orders', 'Orders')}</h3>
<div className="pt-[20px] px-[24px] border border-customColor6 flex">
<table className="w-full">
<tr>
<td colSpan={biggerRow + 1} className="pb-[20px]">
{type === 'seller' ? 'Buyer' : 'Seller'}
</td>
<td className="pb-[20px]">{t('price', 'Price')}</td>
<td className="pb-[20px]">{t('state', 'State')}</td>
</tr>
{data.orders.map((order: any) => (
<tr key={order.id}>
<td className="pb-[20px]">{order.name}</td>
{order.details.map((details: any, index: number) => (
<td
className="pb-[20px]"
key={details.id}
{...(index === order.details.length - 1
? {
colSpan: biggerRow - order.details.length + 1,
}
: {})}
>
<div className="flex gap-[20px] items-center">
<div className="relative">
<img
src={details.integration.picture}
alt="platform"
className="w-[24px] h-[24px] rounded-full"
/>
<img
className="absolute start-[15px] top-[15px] w-[15px] h-[15px] rounded-full"
src={`/icons/platforms/${details.integration.providerIdentifier}.png`}
alt={details.integration.name}
/>
</div>
<div>
{details.integration.name} ({details.total}/
{details.submitted})
</div>
</div>
</td>
))}
<td className="pb-[20px]">{order.price}</td>
<td className="pb-[20px]">{order.status}</td>
</tr>
))}
</table>
</div>
</div>
);
};

View File

@ -1,387 +0,0 @@
import React, { FC, useCallback, useContext, useMemo, useState } from 'react';
import { MarketplaceProvider } from '@gitroom/frontend/components/marketplace/marketplace.provider';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { TopTitle } from '@gitroom/frontend/components/launches/helpers/top.title.component';
import { Input } from '@gitroom/react/form/input';
import { CustomSelect } from '@gitroom/react/form/custom.select';
import { FormProvider, useFieldArray, useForm } from 'react-hook-form';
import { Total } from '@gitroom/react/form/total';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { Button } from '@gitroom/react/form/button';
import { array, number, object, string } from 'yup';
import { yupResolver } from '@hookform/resolvers/yup';
import { useToaster } from '@gitroom/react/toaster/toaster';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
const schema = object({
socialMedia: array()
.min(1)
.of(
object({
total: number().required(),
value: object({
value: string().required('Platform is required'),
}).required(),
price: string().matches(/^\d+$/, 'Price must be a number').required(),
})
)
.required(),
}).required();
export const NewOrder: FC<{
group: string;
}> = (props) => {
const { group } = props;
const t = useT();
const modal = useModals();
const fetch = useFetch();
const [update, setUpdate] = useState(0);
const toast = useToaster();
const loadIntegrations = useCallback(async () => {
return (
await (await fetch('/integrations/list')).json()
).integrations.filter((f: any) => !f.disabled);
}, []);
const { data } = useSWR('integrations', loadIntegrations);
const options: Array<{
label: string;
value: string;
icon: string;
}> = useMemo(() => {
if (!data) {
return [];
}
return data?.map((p: any) => ({
label: p.name,
value: p.identifier,
id: p.id,
icon: (
<div className="relative">
<img
className="w-[20px] h-[20px] rounded-full"
src={p.picture}
alt={p.name}
/>
<img
className="absolute start-[10px] top-[10px] w-[15px] h-[15px] rounded-full"
src={`/icons/platforms/${p.identifier}.png`}
alt={p.name}
/>
</div>
),
}));
}, [data]);
const change = useCallback(() => {
setUpdate((prev) => prev + 1);
}, [update]);
const form = useForm<{
price: string;
socialMedia: Array<{
value?: string;
total: number;
price: any;
}>;
}>({
values: {
price: '',
socialMedia: [
{
value: undefined,
total: 1,
price: '',
},
],
},
criteriaMode: 'all',
// @ts-ignore
resolver: yupResolver(schema),
mode: 'onChange',
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: 'socialMedia',
});
const possibleOptions = useMemo(() => {
return fields.map((z, index) => {
const field = form.getValues(`socialMedia.${index}.value`) as {
value?: {
value?: string;
total?: number;
};
};
return options.filter((f) => {
const getAllValues = fields.reduce((all, p, innerIndex) => {
if (index === innerIndex) {
return all;
}
const newField = form.getValues(
`socialMedia.${innerIndex}.value`
) as {
value?: {
value?: string;
};
};
all.push(newField);
return all;
}, [] as any[]);
return (
field?.value?.value === f.value ||
!getAllValues.some((v) => v?.value === f.value)
);
});
});
}, [update, fields, options]);
const canAddMoreOptions = useMemo(() => {
return fields.length < options.length;
}, [update, fields, options]);
const close = useCallback(() => {
return modal.closeAll();
}, []);
const submit = useCallback(async (data: any) => {
await (
await fetch('/marketplace/offer', {
method: 'POST',
body: JSON.stringify({
group,
socialMedia: data.socialMedia.map((z: any) => ({
total: z.total,
price: +z.price,
value: z.value.id,
})),
}),
})
).json();
toast.show('Offer sent successfully');
modal.closeAll();
}, []);
const totalPrice = useMemo(() => {
return fields.reduce((total, field, index) => {
return (
total +
(+(form.getValues(`socialMedia.${index}.price`) || 0) *
form.getValues(`socialMedia.${index}.total`) || 0)
);
}, 0);
}, [update, fields, options]);
return (
<form onSubmit={form.handleSubmit(submit)}>
<FormProvider {...form}>
<div className="w-full max-w-[647px] mx-auto bg-sixth px-[16px] rounded-[4px] border border-customColor6 gap-[24px] flex flex-col relative">
<button
onClick={close}
className="outline-none absolute end-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root 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"
>
<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 className="text-[18px] font-[500] flex flex-col">
<TopTitle title={`Send a new offer`} />
<div className="p-[16px] -mx-[16px]">
{fields.map((field, index) => (
<div className="relative flex gap-[10px]" key={field.id}>
{index !== 0 && (
<div
onClick={() => remove(index)}
className="cursor-pointer top-[3px] z-[99] w-[15px] h-[15px] bg-red-500 rounded-full text-textColor absolute start-[60px] text-[12px] flex justify-center items-center pb-[2px] select-none"
>
x
</div>
)}
<div className="flex-1">
<CustomSelect
{...form.register(`socialMedia.${index}.value`)}
onChange={change}
options={possibleOptions[index]}
placeholder="Select social media"
label="Platform"
translationKey="label_platform"
disableForm={true}
/>
</div>
<div>
<Total
customOnChange={change}
{...form.register(`socialMedia.${index}.total`)}
/>
</div>
<div>
<Input
icon={<div className="text-[14px]">$</div>}
className="text-[14px]"
label="Price per post"
translationKey="label_price_per_post"
error={
form.formState.errors?.socialMedia?.[index]?.price
?.message
}
customUpdate={change}
name={`socialMedia.${index}.price`}
/>
</div>
</div>
))}
{canAddMoreOptions && (
<div>
<div
onClick={() =>
append({
value: undefined,
total: 1,
price: '',
})
}
className="select-none rounded-[4px] border-2 border-customColor21 flex py-[9.5px] px-[24px] items-center gap-[4px] text-[14px] float-left cursor-pointer"
>
<div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
>
<path
d="M15.75 9C15.75 9.14918 15.6907 9.29226 15.5852 9.39775C15.4798 9.50324 15.3367 9.5625 15.1875 9.5625H9.5625V15.1875C9.5625 15.3367 9.50324 15.4798 9.39775 15.5852C9.29226 15.6907 9.14918 15.75 9 15.75C8.85082 15.75 8.70774 15.6907 8.60225 15.5852C8.49676 15.4798 8.4375 15.3367 8.4375 15.1875V9.5625H2.8125C2.66332 9.5625 2.52024 9.50324 2.41475 9.39775C2.30926 9.29226 2.25 9.14918 2.25 9C2.25 8.85082 2.30926 8.70774 2.41475 8.60225C2.52024 8.49676 2.66332 8.4375 2.8125 8.4375H8.4375V2.8125C8.4375 2.66332 8.49676 2.52024 8.60225 2.41475C8.70774 2.30926 8.85082 2.25 9 2.25C9.14918 2.25 9.29226 2.30926 9.39775 2.41475C9.50324 2.52024 9.5625 2.66332 9.5625 2.8125V8.4375H15.1875C15.3367 8.4375 15.4798 8.49676 15.5852 8.60225C15.6907 8.70774 15.75 8.85082 15.75 9Z"
fill="white"
/>
</svg>
</div>
<div>
{t('add_another_platform', 'Add another platform')}
</div>
</div>
</div>
)}
</div>
<div className="py-[16px] flex justify-end">
<Button type="submit" className="rounded-[4px]">
{t('send_an_offer_for', 'Send an offer for $')}
{totalPrice}
</Button>
</div>
</div>
</div>
</FormProvider>
</form>
);
};
export const OrderInProgress: FC<{
group: string;
buyer: boolean;
order: string;
}> = (props) => {
const { group, buyer, order } = props;
const t = useT();
const fetch = useFetch();
const completeOrder = useCallback(async () => {
if (
await deleteDialog(
'Are you sure you want to pay the seller and end the order? this is irreversible action'
)
) {
await (
await fetch(`/marketplace/offer/${order}/complete`, {
method: 'POST',
})
).json();
}
}, [order]);
return (
<div className="flex gap-[10px]">
{buyer && (
<div
onClick={completeOrder}
className="rounded-[34px] border-[1px] border-customColor21 !bg-sixth h-[28px] justify-center items-center text-[12px] px-[12px] flex font-[600] cursor-pointer"
>
{t('complete_order_and_pay_early', 'Complete order and pay early')}
</div>
)}
<div className="h-[28px] justify-center items-center bg-customColor42 text-[12px] px-[12px] flex rounded-[34px] font-[600]">
{t('order_in_progress', 'Order in progress')}
</div>
</div>
);
};
export const CreateNewOrder: FC<{
group: string;
}> = (props) => {
const { group } = props;
const modals = useModals();
const t = useT();
const createOrder = useCallback(() => {
modals.openModal({
classNames: {
modal: 'bg-transparent text-textColor',
},
withCloseButton: false,
size: '100%',
children: <NewOrder group={group} />,
});
}, [group]);
return (
<div
className="h-[28px] justify-center items-center bg-customColor42 text-[12px] px-[12px] flex rounded-[34px] font-[600] cursor-pointer"
onClick={createOrder}
>
{t('create_a_new_offer', 'Create a new offer')}
</div>
);
};
enum OrderOptions {
CREATE_A_NEW_ORDER = 'CREATE_A_NEW_ORDER',
IN_PROGRESS = 'IN_PROGRESS',
WAITING_PUBLICATION = 'WAITING_PUBLICATION',
}
export const OrderTopActions = () => {
const { message } = useContext(MarketplaceProvider);
const user = useUser();
const isBuyer = useMemo(() => {
return user?.id === message?.buyerId;
}, [user, message]);
const myOptions: OrderOptions | undefined = useMemo(() => {
if (
!isBuyer &&
(!message?.orders.length ||
message.orders[0].status === 'COMPLETED' ||
message.orders[0].status === 'CANCELED')
) {
return OrderOptions.CREATE_A_NEW_ORDER;
}
if (message?.orders?.[0]?.status === 'PENDING') {
return OrderOptions.IN_PROGRESS;
}
if (message?.orders?.[0]?.status === 'ACCEPTED') {
return OrderOptions.WAITING_PUBLICATION;
}
}, [isBuyer, user, message]);
if (!myOptions) {
return null;
}
switch (myOptions) {
case OrderOptions.CREATE_A_NEW_ORDER:
return <CreateNewOrder group={message?.id!} />;
case OrderOptions.WAITING_PUBLICATION:
return (
<OrderInProgress
group={message?.id!}
buyer={isBuyer}
order={message?.orders[0]?.id!}
/>
);
}
return <div />;
};

View File

@ -1,19 +0,0 @@
import 'reflect-metadata';
import { FC } from 'react';
import { Post as PrismaPost } from '.prisma/client';
import { Providers } from '@gitroom/frontend/components/new-launch/providers/show.all.providers';
export const PreviewPopupDynamic: FC<{
postId: string;
providerId: string;
post: {
integration: string;
group: string;
posts: PrismaPost[];
settings: any;
};
}> = (props) => {
const { component: ProviderComponent } = Providers.find(
(p) => p.identifier === props.providerId
)!;
return null;
};

View File

@ -1,241 +0,0 @@
'use client';
import { Slider } from '@gitroom/react/form/slider';
import { Button } from '@gitroom/react/form/button';
import { tagsList } from '@gitroom/nestjs-libraries/database/prisma/marketplace/tags.list';
import { Options } from '@gitroom/frontend/components/marketplace/buyer';
import { ChangeEvent, FC, useCallback, useEffect, useState } from 'react';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { Input } from '@gitroom/react/form/input';
import { useDebouncedCallback } from 'use-debounce';
import { OrderList } from '@gitroom/frontend/components/marketplace/order.list';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { Select } from '@gitroom/react/form/select';
import { countries } from '@gitroom/nestjs-libraries/services/stripe.country.list';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
export const AddAccount: FC<{
openBankAccount: (country: string) => void;
}> = (props) => {
const { openBankAccount } = props;
const t = useT();
const [country, setCountry] = useState('');
const [loading, setLoading] = useState(false);
return (
<div className="bg-sixth p-[32px] text-[20px] w-full max-w-[600px] mx-auto flex flex-col gap-[24px] rounded-[4px] border border-customColor6 relative">
{t(
'please_select_your_country_where_your_business_is',
'Please select your country where your business is.'
)}
<br />
<Select
label="Country"
name="country"
disableForm={true}
value={country}
onChange={(e) => setCountry(e.target.value)}
>
<option value="">{t('select_country', '--SELECT COUNTRY--')}</option>
{countries.map((country) => (
<option key={country.value} value={country.value}>
{country.label}
</option>
))}
</Select>
<Button
className="w-full"
disabled={!country}
loading={loading}
type="button"
onClick={() => {
openBankAccount(country);
setLoading(true);
}}
>
{t('connect_bank_account', 'Connect Bank Account')}
</Button>
</div>
);
};
export const Seller = () => {
const fetch = useFetch();
const [loading, setLoading] = useState<boolean>(true);
const [keys, setKeys] = useState<
Array<{
key: string;
id: string;
user: string;
}>
>([]);
const [connectedLoading, setConnectedLoading] = useState(false);
const [state, setState] = useState(true);
const [audience, setAudience] = useState<number>(0);
const modals = useModals();
const accountInformation = useCallback(async () => {
const account = await (
await fetch('/marketplace/account', {
method: 'GET',
})
).json();
setState(account.marketplace);
setAudience(account.audience);
return account;
}, []);
const onChange = useCallback((key: string, state: boolean) => {
fetch('/marketplace/item', {
method: 'POST',
body: JSON.stringify({
key,
state,
}),
});
}, []);
const connectBankAccountLink = useCallback(async (country: string) => {
setConnectedLoading(true);
const { url } = await (
await fetch(`/marketplace/bank?country=${country}`, {
method: 'GET',
})
).json();
window.location.href = url;
}, []);
const loadItems = useCallback(async () => {
const data = await (
await fetch('/marketplace/item', {
method: 'GET',
})
).json();
setKeys(data);
setLoading(false);
}, []);
const changeAudienceBackend = useDebouncedCallback(
useCallback(async (aud: number) => {
fetch('/marketplace/audience', {
method: 'POST',
body: JSON.stringify({
audience: aud,
}),
});
}, []),
500
);
const changeAudience = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const num = String(+e.target.value.replace(/\D/g, '') || 0).slice(0, 8);
setAudience(+num);
changeAudienceBackend(+num);
}, []);
const changeMarketplace = useCallback(
async (value: string) => {
await fetch('/marketplace/active', {
method: 'POST',
body: JSON.stringify({
active: value === 'on',
}),
});
setState(!state);
},
[state]
);
const t = useT();
const { data } = useSWR('/marketplace/account', accountInformation);
const connectBankAccount = useCallback(async () => {
if (!data?.connectedAccount) {
modals.openModal({
size: '100%',
classNames: {
modal: 'bg-transparent text-textColor',
},
withCloseButton: false,
children: <AddAccount openBankAccount={connectBankAccountLink} />,
});
return;
}
connectBankAccountLink('');
}, [data, connectBankAccountLink]);
useEffect(() => {
loadItems();
}, []);
if (loading) {
return <></>;
}
return (
<>
<OrderList type="seller" />
<div className="flex mt-[29px] w-full gap-[26px]">
<div className="w-[328px] flex flex-col gap-[16px]">
<h2 className="text-[20px]">{t('seller_mode', 'Seller Mode')}</h2>
<div className="flex p-[24px] bg-sixth rounded-[4px] border border-customColor6 flex-col items-center gap-[16px]">
<div className="w-[64px] h-[64px] bg-customColor38 rounded-full">
{!!data?.picture?.path && (
<img
className="w-full h-full rounded-full"
src={data?.picture?.path || ''}
alt="avatar"
/>
)}
</div>
<div className="text-[24px]">{data?.fullname || ''}</div>
{data?.connectedAccount && (
<div className="flex gap-[16px] items-center pb-[8px]">
<Slider
fill={true}
value={state ? 'on' : 'off'}
onChange={changeMarketplace}
/>
<div className="text-[18px]">{t('active', 'Active')}</div>
</div>
)}
<div className="border-t border-t-customColor43 w-full" />
<div className="w-full">
<Button
className="w-full"
onClick={connectBankAccount}
loading={connectedLoading}
>
{!data?.connectedAccount
? 'Connect Bank Account'
: 'Update Bank Account'}
</Button>
</div>
</div>
</div>
<div className="flex-1 flex gap-[16px] flex-col">
<h2 className="text-[20px]">{t('details', 'Details')}</h2>
<div className="bg-sixth rounded-[4px] border border-customColor6">
{tagsList.map((tag) => (
<Options
rows={3}
key={tag.key}
onChange={onChange}
preSelected={keys.map((key) => key.key)}
search={false}
options={tag.options}
title={tag.name}
/>
))}
<div className="h-[56px] text-[20px] font-[600] flex items-center px-[24px] bg-customColor8">
{t('audience_size', 'Audience Size')}
</div>
<div className="bg-customColor3 flex px-[32px] py-[24px]">
<div className="flex-1">
<Input
label="Audience size on all platforms"
name="audience"
type="text"
pattern="\d*"
max={8}
disableForm={true}
value={audience}
onChange={changeAudience}
/>
</div>
</div>
</div>
</div>
</div>
</>
);
};

View File

@ -1,430 +0,0 @@
'use client';
import React, { FC, useCallback, useContext, useMemo } from 'react';
import { MarketplaceProvider } from '@gitroom/frontend/components/marketplace/marketplace.provider';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { Button } from '@gitroom/react/form/button';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import useSWR from 'swr';
import { capitalize } from 'lodash';
import removeMd from 'remove-markdown';
import { deleteDialog } from '@gitroom/react/helpers/delete.dialog';
import { useModals } from '@gitroom/frontend/components/layout/new-modal';
import { Post as PrismaPost } from '@prisma/client';
import dynamic from 'next/dynamic';
import { IntegrationContext } from '@gitroom/frontend/components/launches/helpers/use.integration';
import dayjs from 'dayjs';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
const PreviewPopupDynamic = dynamic(() =>
import('@gitroom/frontend/components/marketplace/preview.popup.dynamic').then(
(mod) => mod.PreviewPopupDynamic
)
);
interface SpecialMessageInterface {
type: string;
data: {
id: string;
[key: string]: any;
};
}
export const OrderCompleted: FC = () => {
const t = useT();
return (
<div className="border border-customColor44 flex flex-col rounded-[6px] overflow-hidden">
<div className="flex items-center bg-customColor8 px-[24px] py-[16px] text-[20px]">
<div className="flex-1">{t('order_completed', 'Order completed')}</div>
</div>
<div className="py-[16px] px-[24px] flex flex-col gap-[20px] text-[18px]">
{t('the_order_has_been_completed', 'The order has been completed')}
</div>
</div>
);
};
export const Published: FC<{
isCurrentOrder: boolean;
isSellerOrBuyer: 'BUYER' | 'SELLER';
orderStatus: string;
data: SpecialMessageInterface;
}> = (props) => {
const t = useT();
const { data, isSellerOrBuyer } = props;
return (
<div className="border border-customColor44 flex flex-col rounded-[6px] overflow-hidden">
<div className="flex items-center bg-customColor8 px-[24px] py-[16px] text-[20px]">
<div className="flex-1">
{isSellerOrBuyer === 'BUYER' ? 'Your' : 'The'}
{t('post_has_been_published', 'post has been published')}
</div>
</div>
<div className="py-[16px] px-[24px] flex flex-col gap-[20px]">
<div className="flex gap-[20px]">
<div className="relative">
<img
src={data.data.picture}
alt="platform"
className="w-[24px] h-[24px] rounded-full"
/>
<img
className="absolute start-[15px] top-[15px] w-[15px] h-[15px] rounded-full"
src={`/icons/platforms/${data.data.integration}.png`}
alt={data.data.name}
/>
</div>
<div className="flex-1 text-[18px]">{data.data.name}</div>
</div>
<div className="text-[14px]">
{t('url_1', 'URL:')}
<a className="underline hover:font-bold" href={data.data.url}>
{data.data.url}
</a>
</div>
</div>
</div>
);
};
export const PreviewPopup: FC<{
postId: string;
providerId: string;
post: {
integration: string;
group: string;
posts: PrismaPost[];
settings: any;
};
}> = (props) => {
const modal = useModals();
const close = useCallback(() => {
return modal.closeAll();
}, []);
return (
<div className="bg-primary p-[20px] w-full relative">
<button
onClick={close}
className="outline-none absolute end-[20px] top-[20px] mantine-UnstyledButton-root mantine-ActionIcon-root 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"
>
<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>
<PreviewPopupDynamic {...props} />
</div>
);
};
export const Offer: FC<{
isCurrentOrder: boolean;
isSellerOrBuyer: 'BUYER' | 'SELLER';
orderStatus: string;
data: SpecialMessageInterface;
}> = (props) => {
const { data, isSellerOrBuyer, isCurrentOrder, orderStatus } = props;
const fetch = useFetch();
const acceptOrder = useCallback(async () => {
const { url } = await (
await fetch(`/marketplace/orders/${data.data.id}/payment`, {
method: 'POST',
})
).json();
window.location.href = url;
}, [data.data.id]);
const totalPrice = useMemo(() => {
return data?.data?.ordersItems?.reduce((all: any, current: any) => {
return all + current.price * current.quantity;
}, 0);
}, [data?.data?.ordersItems]);
const t = useT();
return (
<div className="border border-customColor44 flex flex-col rounded-[6px] overflow-hidden">
<div className="flex items-center bg-customColor8 px-[24px] py-[16px] text-[20px]">
<div className="flex-1">{t('new_offer', 'New Offer')}</div>
<div className="text-customColor42">${totalPrice}</div>
</div>
<div className="py-[16px] px-[24px] flex flex-col gap-[20px]">
<div className="text-inputText text-[12px]">
{t('platform', 'Platform')}
</div>
{data.data.ordersItems.map((item: any) => (
<div
key={item.integration.id}
className="flex gap-[10px] items-center"
>
<div className="relative">
<img
src={item.integration.picture}
alt="platform"
className="w-[24px] h-[24px] rounded-full"
/>
<img
className="absolute start-[15px] top-[15px] w-[15px] h-[15px] rounded-full"
src={`/icons/platforms/${item.integration.providerIdentifier}.png`}
alt={item.integration.name}
/>
</div>
<div className="flex-1 text-[18px]">{item.integration.name}</div>
<div className="text-[18px]">
{item.quantity}
{t('posts', 'Posts')}
</div>
</div>
))}
{orderStatus === 'PENDING' &&
isCurrentOrder &&
isSellerOrBuyer === 'BUYER' && (
<div className="flex justify-end">
<Button
className="rounded-[4px] text-[14px]"
onClick={acceptOrder}
>
{t('pay_accept_offer', 'Pay & Accept Offer')}
</Button>
</div>
)}
{orderStatus === 'ACCEPTED' && (
<div className="flex justify-end">
<Button className="rounded-[4px] text-[14px] border border-tableBorder !bg-sixth text-tableBorder">
{t('accepted', 'Accepted')}
</Button>
</div>
)}
</div>
</div>
);
};
export const Post: FC<{
isCurrentOrder: boolean;
isSellerOrBuyer: 'BUYER' | 'SELLER';
orderStatus: string;
message: string;
data: SpecialMessageInterface;
}> = (props) => {
const { data, isSellerOrBuyer, message, isCurrentOrder, orderStatus } = props;
const fetch = useFetch();
const modal = useModals();
const getIntegration = useCallback(async () => {
return (
await fetch(
`/integrations/${data.data.integration}?order=${data.data.id}`,
{
method: 'GET',
}
)
).json();
}, []);
const requestRevision = useCallback(async () => {
if (
!(await deleteDialog(
'Are you sure you want to request a revision?',
'Yes'
))
) {
return;
}
await fetch(`/marketplace/posts/${data.data.postId}/revision`, {
method: 'POST',
body: JSON.stringify({
message,
}),
headers: {
'Content-Type': 'application/json',
},
});
}, [data]);
const requestApproved = useCallback(async () => {
if (
!(await deleteDialog(
'Are you sure you want to approve this post?',
'Yes'
))
) {
return;
}
await fetch(`/marketplace/posts/${data.data.postId}/approve`, {
method: 'POST',
body: JSON.stringify({
message,
}),
headers: {
'Content-Type': 'application/json',
},
});
}, [data]);
const preview = useCallback(async () => {
const post = await (
await fetch(`/marketplace/posts/${data.data.postId}`)
).json();
const integration = await getIntegration();
modal.openModal({
classNames: {
modal: 'bg-transparent text-textColor',
},
size: 'auto',
withCloseButton: false,
children: (
<IntegrationContext.Provider
value={{
allIntegrations: [],
date: newDayjs(),
integration,
value: [],
}}
>
<PreviewPopup
providerId={post?.providerId!}
post={post}
postId={data?.data?.postId!}
/>
</IntegrationContext.Provider>
),
});
}, [data?.data]);
const { data: integrationData } = useSWR<{
id: string;
name: string;
picture: string;
providerIdentifier: string;
}>(`/integrations/${data.data.integration}`, getIntegration);
const t = useT();
return (
<div className="border border-customColor44 flex flex-col rounded-[6px] overflow-hidden">
<div className="flex items-center bg-customColor8 px-[24px] py-[16px] text-[20px]">
<div className="flex-1">
{t('post_draft', 'Post Draft')}
{capitalize(integrationData?.providerIdentifier || '')}
</div>
</div>
<div className="py-[16px] px-[24px] flex gap-[20px]">
<div>
<div className="relative">
<img
src={integrationData?.picture}
alt="platform"
className="w-[24px] h-[24px] rounded-full"
/>
<img
className="absolute start-[15px] top-[15px] w-[15px] h-[15px] rounded-full"
src={`/icons/platforms/${integrationData?.providerIdentifier}.png`}
alt={integrationData?.name}
/>
</div>
</div>
<div className="flex flex-1 flex-col text-[16px] gap-[2px]">
<div className="text-[18px]">{integrationData?.name}</div>
<div>{removeMd(data.data.description)}</div>
{isSellerOrBuyer === 'BUYER' &&
isCurrentOrder &&
data.data.status === 'PENDING' &&
orderStatus === 'ACCEPTED' && (
<div className="mt-[18px] flex gap-[10px] justify-end">
<Button
onClick={requestRevision}
className="rounded-[4px] text-[14px] border-[2px] border-customColor21 !bg-sixth"
>
{t('revision_needed', 'Revision Needed')}
</Button>
<Button
onClick={requestApproved}
className="rounded-[4px] text-[14px] border-[2px] border-customColor21 !bg-sixth"
>
{t('approve', 'Approve')}
</Button>
<Button className="rounded-[4px]" onClick={preview}>
{t('preview', 'Preview')}
</Button>
</div>
)}
{data.data.status === 'REVISION' && (
<div className="flex justify-end">
<Button className="rounded-[4px] text-[14px] border border-tableBorder !bg-sixth text-tableBorder">
{t('revision_requested', 'Revision Requested')}
</Button>
</div>
)}
{data.data.status === 'APPROVED' && (
<div className="flex justify-end gap-[10px]">
<Button className="rounded-[4px] text-[14px] border border-tableBorder !bg-sixth text-tableBorder">
{t('accepted_1', 'ACCEPTED')}
</Button>
</div>
)}
{data.data.status === 'CANCELED' && (
<div className="flex justify-end gap-[10px]">
<Button className="rounded-[4px] text-[14px] border border-tableBorder !bg-sixth text-tableBorder">
{t('cancelled_by_the_seller', 'Cancelled by the seller')}
</Button>
</div>
)}
</div>
</div>
</div>
);
};
export const SpecialMessage: FC<{
data: SpecialMessageInterface;
id: string;
}> = (props) => {
const { data, id } = props;
const { message } = useContext(MarketplaceProvider);
const user = useUser();
const isCurrentOrder = useMemo(() => {
return message?.orders?.[0]?.id === data?.data?.id;
}, [message, data]);
const isSellerOrBuyer = useMemo(() => {
return user?.id === message?.buyerId ? 'BUYER' : 'SELLER';
}, [user, message]);
if (data.type === 'offer') {
return (
<Offer
data={data}
orderStatus={message?.orders?.[0]?.status!}
isCurrentOrder={isCurrentOrder}
isSellerOrBuyer={isSellerOrBuyer}
/>
);
}
if (data.type === 'post') {
return (
<Post
data={data}
orderStatus={message?.orders?.[0]?.status!}
isCurrentOrder={isCurrentOrder}
isSellerOrBuyer={isSellerOrBuyer}
message={id}
/>
);
}
if (data.type === 'published') {
return (
<Published
data={data}
orderStatus={message?.orders?.[0]?.status!}
isCurrentOrder={isCurrentOrder}
isSellerOrBuyer={isSellerOrBuyer}
/>
);
}
if (data.type === 'order-completed') {
return <OrderCompleted />;
}
return null;
};

View File

@ -1,123 +0,0 @@
'use client';
import { FC, ReactNode, useCallback, useMemo } from 'react';
import clsx from 'clsx';
import useSWR from 'swr';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { useParams, useRouter } from 'next/navigation';
import {
MarketplaceProvider,
Root2,
} from '@gitroom/frontend/components/marketplace/marketplace.provider';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { Button } from '@gitroom/react/form/button';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
const Card: FC<{
message: Root2;
}> = (props) => {
const { message } = props;
const path = useParams();
const router = useRouter();
const user = useUser();
const changeConversation = useCallback(() => {
router.push(`/messages/${message.id}`);
}, []);
const t = useT();
const showFrom = useMemo(() => {
return user?.id === message?.buyerId ? message?.seller : message?.buyer;
}, [message, user]);
return (
<div
onClick={changeConversation}
className={clsx(
'h-[89px] p-[24px] flex gap-[16px] rounded-[4px] cursor-pointer',
path?.id === message.id && 'bg-sixth border border-customColor6'
)}
>
<div className="w-[40px] h-[40px] rounded-full bg-amber-200">
{showFrom?.picture?.path && (
<img
src={showFrom.picture.path}
alt={showFrom.name || 'Noname'}
className="w-full h-full rounded-full"
/>
)}
</div>
<div className="flex-1 relative">
<div className="absolute start-0 top-0 w-full h-full flex flex-col whitespace-nowrap">
<div>{showFrom?.name || 'Noname'}</div>
<div className="text-[12px] w-full overflow-ellipsis overflow-hidden">
{message.messages[0]?.content}
</div>
</div>
</div>
<div className="text-[12px]">{t('mar_28', 'Mar 28')}</div>
</div>
);
};
export const Layout: FC<{
renderChildren: ReactNode;
}> = (props) => {
const { renderChildren } = props;
const fetch = useFetch();
const params = useParams();
const router = useRouter();
const loadMessagesGroup = useCallback(async () => {
return await (await fetch('/messages')).json();
}, []);
const messagesGroup = useSWR<Root2[]>('messagesGroup', loadMessagesGroup, {
refreshInterval: 5000,
});
const t = useT();
const marketplace = useCallback(() => {
router.push('/marketplace');
}, [router]);
const currentMessage = useMemo(() => {
return messagesGroup?.data?.find((message) => message.id === params.id);
}, [params.id, messagesGroup.data]);
if (messagesGroup.isLoading) {
return null;
}
if (!messagesGroup.isLoading && !messagesGroup?.data?.length) {
return (
<div className="flex flex-col justify-center items-center mt-[100px] gap-[27px] text-center">
<div>
<img src="/peoplemarketplace.svg" />
</div>
<div className="text-[48px]">
{t('there_are_no_messages_yet', 'There are no messages yet.')}
<br />
{t('checkout_the_marketplace', 'Checkout the Marketplace')}
</div>
<div>
<Button onClick={marketplace}>
{t('go_to_marketplace', 'Go to marketplace')}
</Button>
</div>
</div>
);
}
return (
<div className="flex gap-[20px]">
<div className="pt-[7px] w-[330px] flex flex-col">
<div className="text-[20px] mb-[18px]">
{t('all_messages', 'All Messages')}
</div>
<div className="flex flex-col">
{messagesGroup.data?.map((message) => (
<Card key={message.id} message={message} />
))}
</div>
</div>
<MarketplaceProvider.Provider
value={{
message: currentMessage,
}}
>
<div className="flex-1 flex flex-col">{renderChildren}</div>
</MarketplaceProvider.Provider>
</div>
);
};

View File

@ -1,285 +0,0 @@
'use client';
export interface Root {
id: string;
buyerId: string;
sellerId: string;
createdAt: string;
updatedAt: string;
messages: Message[];
}
export interface SellerBuyer {
id: string;
name?: string;
picture: Picture;
}
export interface Picture {
id: string;
path: string;
}
export interface Message {
id: string;
from: string;
content: string;
special?: string;
groupId: string;
createdAt: string;
updatedAt: string;
deletedAt: any;
}
import { Textarea } from '@gitroom/react/form/textarea';
import clsx from 'clsx';
import useSWR from 'swr';
import {
FC,
UIEventHandler,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useParams } from 'next/navigation';
import { useFetch } from '@gitroom/helpers/utils/custom.fetch';
import { reverse } from 'lodash';
import { FormProvider, SubmitHandler, useForm } from 'react-hook-form';
import { classValidatorResolver } from '@hookform/resolvers/class-validator';
import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { useUser } from '@gitroom/frontend/components/layout/user.context';
import { OrderTopActions } from '@gitroom/frontend/components/marketplace/order.top.actions';
import { MarketplaceProvider } from '@gitroom/frontend/components/marketplace/marketplace.provider';
import { SpecialMessage } from '@gitroom/frontend/components/marketplace/special.message';
import { usePageVisibility } from '@gitroom/react/helpers/use.is.visible';
import { useT } from '@gitroom/react/translation/get.transation.service.client';
import { newDayjs } from '@gitroom/frontend/components/layout/set.timezone';
export const Message: FC<{
message: Message;
seller: SellerBuyer;
buyer: SellerBuyer;
scrollDown: () => void;
}> = (props) => {
const { message, seller, buyer, scrollDown } = props;
const user = useUser();
const t = useT();
const amITheBuyerOrSeller = useMemo(() => {
return user?.id === buyer?.id ? 'BUYER' : 'SELLER';
}, [buyer, user]);
useEffect(() => {
scrollDown();
}, []);
const person = useMemo(() => {
if (message.from === 'BUYER') {
return buyer;
}
if (message.from === 'SELLER') {
return seller;
}
}, [amITheBuyerOrSeller, buyer, seller, message]);
const data = useMemo(() => {
if (!message.special) {
return false;
}
return JSON.parse(message.special);
}, [message]);
const isMe = useMemo(() => {
return (
(amITheBuyerOrSeller === 'BUYER' && message.from === 'BUYER') ||
(amITheBuyerOrSeller === 'SELLER' && message.from === 'SELLER')
);
}, [amITheBuyerOrSeller, message]);
const time = useMemo(() => {
return newDayjs(message.createdAt).format('h:mm A');
}, [message]);
return (
<div className="flex gap-[10px]">
<div>
<div className="w-[24px] h-[24px] rounded-full bg-amber-200">
{!!person?.picture?.path && (
<img
src={person.picture.path}
alt="person"
className="w-[24px] h-[24px] rounded-full"
/>
)}
</div>
</div>
<div className="flex-1 flex flex-col max-w-[534px] gap-[10px]">
<div className="flex gap-[10px] items-center">
<div>{isMe ? t('me', 'Me') : person?.name}</div>
<div className="w-[6px] h-[6px] bg-customColor34 rounded-full" />
<div className="text-[14px] text-inputText">{time}</div>
</div>
<pre
className={clsx(
'whitespace-pre-line font-[400] text-[12px]',
)}
>
{message.content}
{data && <SpecialMessage data={data} id={message.id} />}
</pre>
</div>
</div>
);
};
const Page: FC<{
page: number;
group: string;
refChange: any;
}> = (props) => {
const { page, group, refChange } = props;
const fetch = useFetch();
const { message } = useContext(MarketplaceProvider);
const visible = usePageVisibility(page);
const loadMessages = useCallback(async () => {
return await (await fetch(`/messages/${group}/${page}`)).json();
}, []);
const { data, mutate } = useSWR<Root>(`load-${page}-${group}`, loadMessages, {
...(page === 1
? {
refreshInterval: visible ? 5000 : 0,
refreshWhenHidden: false,
refreshWhenOffline: false,
revalidateOnFocus: false,
revalidateIfStale: false,
}
: {}),
});
const scrollDown = useCallback(() => {
if (page > 1) {
return;
}
// @ts-ignore
refChange.current?.scrollTo(0, refChange.current.scrollHeight);
}, [refChange]);
const messages = useMemo(() => {
return reverse([...(data?.messages || [])]);
}, [data]);
return (
<>
{messages.map((m) => (
<Message
key={m.id}
message={m}
seller={message?.seller!}
buyer={message?.buyer!}
scrollDown={scrollDown}
/>
))}
</>
);
};
export const Messages = () => {
const [pages, setPages] = useState([makeId(3)]);
const user = useUser();
const params = useParams();
const fetch = useFetch();
const t = useT();
const ref = useRef(null);
const { message } = useContext(MarketplaceProvider);
const showFrom = useMemo(() => {
return user?.id === message?.buyerId ? message?.seller : message?.buyer;
}, [message, user]);
const resolver = useMemo(() => {
return classValidatorResolver(AddMessageDto);
}, []);
const form = useForm({
resolver,
values: {
message: '',
},
});
useEffect(() => {
setPages([makeId(3)]);
}, [params.id]);
const loadMessages = useCallback(async () => {
return await (await fetch(`/messages/${params.id}/1`)).json();
}, []);
const { data, mutate, isLoading } = useSWR<Root>(
`load-1-${params.id}`,
loadMessages
);
const submit: SubmitHandler<AddMessageDto> = useCallback(async (values) => {
await fetch(`/messages/${params.id}`, {
method: 'POST',
body: JSON.stringify(values),
});
mutate();
form.reset();
}, []);
const changeScroll: UIEventHandler<HTMLDivElement> = useCallback(
(e) => {
// @ts-ignore
if (e.target.scrollTop === 0) {
// @ts-ignore
e.target.scrollTop = 1;
setPages((prev) => [...prev, makeId(3)]);
}
},
[pages, setPages]
);
return (
<form onSubmit={form.handleSubmit(submit)}>
<FormProvider {...form}>
<div className="flex-1 flex flex-col rounded-[4px] border border-customColor6 bg-customColor3 pb-[16px]">
<div className="bg-customColor8 h-[64px] px-[24px] py-[16px] flex gap-[10px] items-center">
<div className="w-[32px] h-[32px] rounded-full bg-amber-200">
{!!showFrom?.picture?.path && (
<img
src={showFrom?.picture?.path}
alt="seller"
className="w-[32px] h-[32px] rounded-full"
/>
)}
</div>
<div className="text-[20px] flex-1">
{showFrom?.name || t('noname', 'Noname')}
</div>
<div>
<OrderTopActions />
</div>
</div>
<div className="flex-1 min-h-[658px] max-h-[658px] relative">
<div
className="pt-[18px] pb-[18px] absolute top-0 start-0 w-full h-full px-[24px] flex flex-col gap-[24px] overflow-x-hidden overflow-y-auto"
onScroll={changeScroll}
ref={ref}
>
{pages.map((p, index) => (
<Page
key={'page_' + (pages.length - index)}
refChange={ref}
page={pages.length - index}
group={params.id as string}
/>
))}
</div>
</div>
<div className="border-t border-t-customColor46 p-[16px] flex flex-col">
<div>
<Textarea
className="!min-h-[100px] resize-none"
label=""
name="message"
/>
</div>
<div className="flex justify-end">
<button
className={clsx(
'rounded-[4px] border border-customColor21 h-[48px] px-[24px]',
!form.formState.isValid && 'opacity-40'
)}
disabled={!form.formState.isValid}
>
{t('send_message', 'Send Message')}
</button>
</div>
</div>
</div>
</FormProvider>
</form>
);
};

View File

@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { Activity, ActivityMethod } from 'nestjs-temporal-core';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import {
NotificationService,
NotificationType,
} from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { Integration, Post, State } from '@prisma/client';
import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { AuthTokenDetails } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
import { timer } from '@gitroom/helpers/utils/timer';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { WebhooksService } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.service';
import { AutopostService } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.service';
@Injectable()
@Activity()
export class AutopostActivity {
constructor(private _autoPostService: AutopostService) {}
@ActivityMethod()
async autoPost(id: string) {
return this._autoPostService.startAutopost(id)
}
}

View File

@ -0,0 +1,23 @@
import { Injectable } from '@nestjs/common';
import { Activity, ActivityMethod } from 'nestjs-temporal-core';
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
@Injectable()
@Activity()
export class EmailActivity {
constructor(
private _emailService: EmailService,
private _organizationService: OrganizationService
) {}
@ActivityMethod()
async sendEmail(to: string, subject: string, html: string, replyTo?: string) {
return this._emailService.sendEmail(to, subject, html, replyTo);
}
@ActivityMethod()
async getUserOrgs(id: string) {
return this._organizationService.getTeam(id);
}
}

View File

@ -2,14 +2,12 @@ import { Module } from '@nestjs/common';
import { PostActivity } from '@gitroom/orchestrator/activities/post.activity';
import { getTemporalModule } from '@gitroom/nestjs-libraries/temporal/temporal.module';
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { AutopostService } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.service';
import { EmailActivity } from '@gitroom/orchestrator/activities/email.activity';
const activities = [
PostActivity,
];
const activities = [PostActivity, AutopostService, EmailActivity];
@Module({
imports: [
BullMqModule,
DatabaseModule,
getTemporalModule(true, require.resolve('./workflows'), activities),
],

View File

@ -0,0 +1,9 @@
import { defineSignal } from '@temporalio/workflow';
export type Email = {
message: string;
title?: string;
type: 'success' | 'fail' | 'info';
};
export const emailSignal = defineSignal<[Email[]]>('email');

View File

@ -0,0 +1,30 @@
import { proxyActivities, sleep } from '@temporalio/workflow';
import { AutopostActivity } from '@gitroom/orchestrator/activities/autopost.activity';
const { autoPost } = proxyActivities<AutopostActivity>({
startToCloseTimeout: '10 minute',
taskQueue: 'main',
retry: {
maximumAttempts: 3,
backoffCoefficient: 1,
initialInterval: '2 minutes',
},
});
export async function autoPostWorkflow({
id,
immediately,
}: {
id: string;
immediately: boolean;
}) {
while (true) {
try {
if (immediately) {
await autoPost(id);
}
} catch (err) {}
immediately = true;
await sleep(3600000);
}
}

View File

@ -0,0 +1,69 @@
import {
condition,
continueAsNew,
proxyActivities,
setHandler,
sleep,
} from '@temporalio/workflow';
import { Email, emailSignal } from '@gitroom/orchestrator/signals/email.signal';
import { EmailActivity } from '@gitroom/orchestrator/activities/email.activity';
const { sendEmail, getUserOrgs } = proxyActivities<EmailActivity>({
startToCloseTimeout: '10 minute',
taskQueue: 'main',
retry: {
maximumAttempts: 3,
backoffCoefficient: 1,
initialInterval: '2 minutes',
},
});
export async function digestEmailWorkflow({
organizationId,
queue = [],
}: {
organizationId: string;
queue?: Email[];
}) {
setHandler(emailSignal, (data) => {
queue.push(...data);
});
while (true) {
await condition(() => queue.length > 0);
await sleep(60000);
// Take a snapshot batch and immediately clear queue.
const batch = queue.splice(0, queue.length);
queue = [];
const org = await getUserOrgs(organizationId);
for (const user of org.users) {
const allowFailure = user.user.sendFailureEmails ? 'fail' : null;
const allowSuccess = user.user.sendSuccessEmails ? 'success' : null;
const toSend = batch.filter(
(email) =>
email.type === allowFailure ||
email.type === allowSuccess ||
email.type === 'info'
);
if (toSend.length === 0) continue;
await sendEmail(
user.user.email,
toSend.length === 1
? toSend[0].title
: `[Postiz] Your latest notifications`,
toSend.map((p) => p.message).join('<br/>')
);
}
return continueAsNew({
organizationId,
queue,
});
}
}

View File

@ -1 +1,3 @@
export * from './post.workflow';
export * from './autopost.workflow';
export * from './digest.email.workflow';

View File

@ -14,18 +14,23 @@ 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,
postSocial,
postComment,
refreshToken,
internalPlugs,
changeState,
globalPlugs,
updatePost,
processInternalPlug,
processPlug,
sendWebhooks,
isCommentable,
} = proxyActivities<PostActivity>({
@ -38,14 +43,28 @@ const {
});
export async function postWorkflow({
taskQueue,
postId,
organizationId,
postNow = false,
}: {
taskQueue: string;
postId: string;
organizationId: string;
postNow?: boolean;
}) {
// Dynamic task queue, for concurrency
const {
postSocial,
postComment,
refreshToken,
internalPlugs,
globalPlugs,
processInternalPlug,
processPlug,
} = proxyTaskQueue(taskQueue);
const startTime = new Date();
// get all the posts and comments to post
const postsList = await getPostsList(organizationId, postId);
@ -115,7 +134,9 @@ export async function postWorkflow({
postsResults.push(
...(await postComment(
postsResults[0].postId,
postsResults.length === 1 ? undefined : postsResults[i - 1].id,
postsResults.length === 1
? undefined
: postsResults[i - 1].postId,
post.integration,
[postsList[i]]
))
@ -313,6 +334,7 @@ export async function postWorkflow({
parentClosePolicy: 'ABANDON',
args: [
{
taskQueue,
postId,
organizationId,
postNow: true,

View File

@ -1,8 +0,0 @@
dist/
node_modules/
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]

View File

@ -1,38 +0,0 @@
{
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": false,
"decorators": true,
"dynamicImport": true
},
"target": "es2020",
"baseUrl": "/Users/nevodavid/Projects/gitroom",
"paths": {
"@gitroom/backend/*": ["apps/backend/src/*"],
"@gitroom/cron/*": ["apps/cron/src/*"],
"@gitroom/frontend/*": ["apps/frontend/src/*"],
"@gitroom/helpers/*": ["libraries/helpers/src/*"],
"@gitroom/nestjs-libraries/*": ["libraries/nestjs-libraries/src/*"],
"@gitroom/react/*": ["libraries/react-shared-libraries/src/*"],
"@gitroom/plugins/*": ["libraries/plugins/src/*"],
"@gitroom/workers/*": ["apps/workers/src/*"],
"@gitroom/extension/*": ["apps/extension/src/*"]
},
"keepClassNames": true,
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
},
"loose": true
},
"module": {
"type": "commonjs",
"strict": false,
"strictMode": true,
"lazy": false,
"noInterop": false
},
"sourceMaps": true,
"minify": false
}

View File

@ -1,20 +0,0 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"monorepo": false,
"sourceRoot": "src",
"entryFile": "../../dist/workers/apps/workers/src/main",
"language": "ts",
"generateOptions": {
"spec": false
},
"compilerOptions": {
"manualRestart": true,
"tsConfigPath": "./tsconfig.build.json",
"webpack": false,
"deleteOutDir": true,
"assets": [],
"watchAssets": false,
"plugins": []
}
}

View File

@ -1,14 +0,0 @@
{
"name": "postiz-workers",
"version": "1.0.0",
"description": "",
"scripts": {
"dev": "dotenv -e ../../.env -- nest start --watch --entryFile=./apps/workers/src/main",
"build": "cross-env NODE_ENV=production nest build",
"start": "dotenv -e ../../.env -- node --experimental-require-module ./dist/apps/workers/src/main.js",
"pm2": "pm2 start pnpm --name workers -- start"
},
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@ -1,15 +0,0 @@
import { Module } from '@nestjs/common';
import { DatabaseModule } from '@gitroom/nestjs-libraries/database/prisma/database.module';
import { PostsController } from '@gitroom/workers/app/posts.controller';
import { BullMqModule } from '@gitroom/nestjs-libraries/bull-mq-transport-new/bull.mq.module';
import { PlugsController } from '@gitroom/workers/app/plugs.controller';
import { SentryModule } from '@sentry/nestjs/setup';
import { FILTER } from '@gitroom/nestjs-libraries/sentry/sentry.exception';
@Module({
imports: [SentryModule.forRoot(), DatabaseModule, BullMqModule],
controllers: [PostsController, PlugsController],
providers: [FILTER],
})
export class AppModule {}

View File

@ -1,54 +0,0 @@
import { Controller } from '@nestjs/common';
import { EventPattern, Transport } from '@nestjs/microservices';
import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service';
import { WebhooksService } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.service';
import { AutopostService } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.service';
@Controller()
export class PostsController {
constructor(
private _postsService: PostsService,
private _webhooksService: WebhooksService,
private _autopostsService: AutopostService
) {}
@EventPattern('sendDigestEmail', Transport.REDIS)
async sendDigestEmail(data: { subject: string; org: string; since: string }) {
try {
return await this._postsService.sendDigestEmail(
data.subject,
data.org,
data.since
);
} catch (err) {
console.log(
"Unhandled error, let's avoid crashing the digest worker",
err
);
}
}
@EventPattern('webhooks', Transport.REDIS)
async webhooks(data: { org: string; since: string }) {
try {
return await this._webhooksService.fireWebhooks(data.org, data.since);
} catch (err) {
console.log(
"Unhandled error, let's avoid crashing the webhooks worker",
err
);
}
}
@EventPattern('cron', Transport.REDIS)
async cron(data: { id: string }) {
try {
return await this._autopostsService.startAutopost(data.id);
} catch (err) {
console.log(
"Unhandled error, let's avoid crashing the autopost worker",
err
);
}
}
}

View File

@ -1,25 +0,0 @@
import { initializeSentry } from '@gitroom/nestjs-libraries/sentry/initialize.sentry';
initializeSentry('workers');
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions } from '@nestjs/microservices';
import { BullMqServer } from '@gitroom/nestjs-libraries/bull-mq-transport-new/strategy';
import { AppModule } from './app/app.module';
async function bootstrap() {
process.env.IS_WORKER = 'true';
// some comment again
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AppModule,
{
strategy: new BullMqServer(),
}
);
await app.listen();
}
bootstrap();

View File

@ -1,23 +0,0 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"],
"compilerOptions": {
"module": "CommonJS",
"resolveJsonModule": true,
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false,
"outDir": "./dist"
}
}

View File

@ -1,11 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"allowSyntheticDefaultImports": true,
"target": "es2017",
"sourceMap": true
}
}

View File

@ -1,52 +0,0 @@
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import Bottleneck from 'bottleneck';
import { BadBody } from '@gitroom/nestjs-libraries/integrations/social.abstract';
const connection = new Bottleneck.IORedisConnection({
client: ioRedis,
});
const mapper = {} as Record<string, Bottleneck>;
export const concurrency = async <T>(
identifier: string,
maxConcurrent = 1,
func: (...args: any[]) => Promise<T>,
ignoreConcurrency = false
) => {
const strippedIdentifier = identifier.toLowerCase().split('-')[0];
mapper[strippedIdentifier] ??= new Bottleneck({
id: strippedIdentifier + '-concurrency-new',
maxConcurrent,
datastore: 'ioredis',
connection,
minTime: 1000,
});
let load: T;
if (ignoreConcurrency) {
return await func();
}
try {
load = await mapper[strippedIdentifier].schedule<T>(
{ expiration: 60000 },
async () => {
try {
return await func();
} catch (err) {
console.log(err);
}
}
);
} catch (err) {
throw new BadBody(
identifier,
JSON.stringify({}),
{} as any,
`Something is wrong with ${identifier}`
);
}
return load;
};

View File

@ -1,9 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
@Global()
@Module({
providers: [BullMqClient],
exports: [BullMqClient],
})
export class BullMqModule {}

View File

@ -1,121 +0,0 @@
import { ClientProxy, ReadPacket, WritePacket } from '@nestjs/microservices';
import { Queue, QueueEvents } from 'bullmq';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { v4 } from 'uuid';
import { Injectable } from '@nestjs/common';
@Injectable()
export class BullMqClient extends ClientProxy {
queues = new Map<string, Queue>();
queueEvents = new Map<string, QueueEvents>();
async connect(): Promise<any> {
return;
}
async close() {
return;
}
publish(
packet: ReadPacket<any>,
callback: (packet: WritePacket<any>) => void
) {
// console.log('hello');
// this.publishAsync(packet, callback);
return () => console.log('sent');
}
delete(pattern: string, jobId: string) {
const queue = this.getQueue(pattern);
return queue.remove(jobId);
}
deleteScheduler(pattern: string, jobId: string) {
const queue = this.getQueue(pattern);
return queue.removeJobScheduler(jobId);
}
async publishAsync(
packet: ReadPacket<any>,
callback: (packet: WritePacket<any>) => void
) {
const queue = this.getQueue(packet.pattern);
const queueEvents = this.getQueueEvents(packet.pattern);
const job = await queue.add(packet.pattern, packet.data, {
jobId: packet.data.id ?? v4(),
...packet.data.options,
removeOnComplete: !packet.data.options.attempts,
removeOnFail: !packet.data.options.attempts,
});
try {
await job.waitUntilFinished(queueEvents);
console.log('success');
callback({ response: job.returnvalue, isDisposed: true });
} catch (err) {
console.log('err');
callback({ err, isDisposed: true });
}
}
getQueueEvents(pattern: string) {
return (
this.queueEvents.get(pattern) ||
new QueueEvents(pattern, {
connection: ioRedis,
})
);
}
getQueue(pattern: string) {
return (
this.queues.get(pattern) ||
new Queue(pattern, {
connection: ioRedis,
})
);
}
async checkForStuckWaitingJobs(queueName: string) {
const queue = this.getQueue(queueName);
const getJobs = await queue.getJobs('waiting' as const);
const now = Date.now();
const thresholdMs = 60 * 60 * 1000;
return {
valid: !getJobs.some((job) => {
const age = now - job.timestamp;
return age > thresholdMs;
}),
};
}
async dispatchEvent(packet: ReadPacket<any>): Promise<any> {
console.log('event to dispatch: ', packet);
const queue = this.getQueue(packet.pattern);
if (packet?.data?.options?.every) {
const { every, immediately } = packet.data.options;
const id = packet.data.id ?? v4();
await queue.upsertJobScheduler(
id,
{ every, ...(immediately ? { immediately } : {}) },
{
name: id,
data: packet.data,
opts: {
removeOnComplete: true,
removeOnFail: true,
},
}
);
return;
}
await queue.add(packet.pattern, packet.data, {
jobId: packet.data.id ?? v4(),
...packet.data.options,
removeOnComplete: true,
removeOnFail: true,
});
}
}

View File

@ -1,61 +0,0 @@
import { CustomTransportStrategy, Server } from '@nestjs/microservices';
import { Queue, Worker } from 'bullmq';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
export class BullMqServer extends Server implements CustomTransportStrategy {
queues: Map<string, Queue>;
workers: Worker[] = [];
/**
* This method is triggered when you run "app.listen()".
*/
listen(callback: () => void) {
this.queues = [...this.messageHandlers.keys()].reduce((all, pattern) => {
all.set(pattern, new Queue(pattern, { connection: ioRedis }));
return all;
}, new Map());
this.workers = Array.from(this.messageHandlers).map(
([pattern, handler]) => {
return new Worker(
pattern,
async (job) => {
const stream$ = this.transformToObservable(
await handler(job.data.payload, job)
);
this.send(stream$, (packet) => {
if (packet.err) {
return job.discard();
}
return true;
});
},
{
maxStalledCount: 10,
concurrency: 300,
connection: ioRedis,
removeOnComplete: {
count: 0,
},
removeOnFail: {
count: 0,
},
}
);
}
);
callback();
}
/**
* This method is triggered on application shutdown.
*/
close() {
this.workers.map((worker) => worker.close());
this.queues.forEach((queue) => queue.close());
return true;
}
}

View File

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AutopostRepository } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.repository';
import { AutopostDto } from '@gitroom/nestjs-libraries/dtos/autopost/autopost.dto';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import dayjs from 'dayjs';
import { END, START, StateGraph } from '@langchain/langgraph';
import { AutoPost, Integration } from '@prisma/client';
@ -15,6 +14,12 @@ import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/po
import Parser from 'rss-parser';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { TemporalService } from 'nestjs-temporal-core';
import { TypedSearchAttributes } from '@temporalio/common';
import {
organizationId,
postId as postIdSearchParam,
} from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
const parser = new Parser();
interface WorkflowChannelsState {
@ -58,7 +63,7 @@ const dallePrompt = z.object({
export class AutopostService {
constructor(
private _autopostsRepository: AutopostRepository,
private _workerServiceProducer: BullMqClient,
private _temporalService: TemporalService,
private _integrationService: IntegrationService,
private _postsService: PostsService
) {}
@ -81,7 +86,7 @@ export class AutopostService {
id
);
await this.processCron(body.active, data.id);
await this.processCron(body.active, orgId, data.id);
return data;
}
@ -92,30 +97,39 @@ export class AutopostService {
id,
active
);
await this.processCron(active, id);
await this.processCron(active, orgId, id);
return data;
}
async processCron(active: boolean, id: string) {
async processCron(active: boolean, orgId: string, id: string) {
if (active) {
return this._workerServiceProducer.emit('cron', {
id,
options: {
every: 3600000,
immediately: true,
},
payload: {
id,
},
});
try {
return this._temporalService.client
.getRawClient()
?.workflow.start('postWorkflow', {
workflowId: `autopost-${id}`,
taskQueue: 'main',
args: [{ id, immediately: true }],
typedSearchAttributes: new TypedSearchAttributes([
{
key: organizationId,
value: orgId,
},
]),
});
} catch (err) {}
}
return this._workerServiceProducer.deleteScheduler('cron', id);
try {
return await this._temporalService.terminateWorkflow(`autopost-${id}`);
} catch (err) {
return false;
}
}
async deleteAutopost(orgId: string, id: string) {
const data = await this._autopostsRepository.deleteAutopost(orgId, id);
await this.processCron(false, id);
await this.processCron(false, orgId, id);
return data;
}

View File

@ -4,8 +4,6 @@ import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prism
import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service';
import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service';
import { UsersRepository } from '@gitroom/nestjs-libraries/database/prisma/users/users.repository';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import { StarsRepository } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.repository';
import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service';
import { SubscriptionRepository } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.repository';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
@ -18,10 +16,6 @@ import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/me
import { MediaRepository } from '@gitroom/nestjs-libraries/database/prisma/media/media.repository';
import { NotificationsRepository } from '@gitroom/nestjs-libraries/database/prisma/notifications/notifications.repository';
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
import { ItemUserRepository } from '@gitroom/nestjs-libraries/database/prisma/marketplace/item.user.repository';
import { ItemUserService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/item.user.service';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { MessagesRepository } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.repository';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { ExtractContentService } from '@gitroom/nestjs-libraries/openai/extract.content.service';
import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service';
@ -55,8 +49,6 @@ import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integration
UsersRepository,
OrganizationService,
OrganizationRepository,
StarsService,
StarsRepository,
SubscriptionService,
SubscriptionRepository,
NotificationService,
@ -68,18 +60,14 @@ import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integration
PostsService,
PostsRepository,
StripeService,
MessagesRepository,
SignatureRepository,
AutopostRepository,
AutopostService,
SignatureService,
MediaService,
MediaRepository,
ItemUserRepository,
AgenciesService,
AgenciesRepository,
ItemUserService,
MessagesService,
IntegrationManager,
RefreshIntegrationService,
ExtractContentService,

View File

@ -9,7 +9,6 @@ import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import {
AnalyticsData,
AuthTokenDetails,
SocialProvider,
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { Integration, Organization } from '@prisma/client';
@ -21,11 +20,11 @@ import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abst
import { IntegrationTimeDto } from '@gitroom/nestjs-libraries/dtos/integrations/integration.time.dto';
import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory';
import { PlugDto } from '@gitroom/nestjs-libraries/dtos/plugs/plug.dto';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import { difference, uniq } from 'lodash';
import utc from 'dayjs/plugin/utc';
import { AutopostRepository } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.repository';
import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service';
import { TemporalService } from 'nestjs-temporal-core';
dayjs.extend(utc);
@ -37,16 +36,18 @@ export class IntegrationService {
private _autopostsRepository: AutopostRepository,
private _integrationManager: IntegrationManager,
private _notificationService: NotificationService,
private _workerServiceProducer: BullMqClient,
@Inject(forwardRef(() => RefreshIntegrationService))
private _refreshIntegrationService: RefreshIntegrationService
private _refreshIntegrationService: RefreshIntegrationService,
private _temporalService: TemporalService
) {}
async changeActiveCron(orgId: string) {
const data = await this._autopostsRepository.getAutoposts(orgId);
for (const item of data.filter((f) => f.active)) {
await this._workerServiceProducer.deleteScheduler('cron', item.id);
try {
await this._temporalService.terminateWorkflow(`autopost-${item.id}`);
} catch (err) {}
}
return true;

View File

@ -1,41 +0,0 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ItemUserRepository {
constructor(private _itemUser: PrismaRepository<'itemUser'>) {}
addOrRemoveItem(add: boolean, userId: string, item: string) {
if (!add) {
return this._itemUser.model.itemUser.deleteMany({
where: {
user: {
id: userId,
},
key: item,
},
});
}
return this._itemUser.model.itemUser.create({
data: {
key: item,
user: {
connect: {
id: userId,
},
},
},
});
}
getItems(userId: string) {
return this._itemUser.model.itemUser.findMany({
where: {
user: {
id: userId,
},
},
});
}
}

View File

@ -1,15 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ItemUserRepository } from '@gitroom/nestjs-libraries/database/prisma/marketplace/item.user.repository';
@Injectable()
export class ItemUserService {
constructor(private _itemUserRepository: ItemUserRepository) {}
addOrRemoveItem(add: boolean, userId: string, item: string) {
return this._itemUserRepository.addOrRemoveItem(add, userId, item);
}
getItems(userId: string) {
return this._itemUserRepository.getItems(userId);
}
}

View File

@ -1,915 +0,0 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
import { From, OrderStatus } from '@prisma/client';
import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message';
import { CreateOfferDto } from '@gitroom/nestjs-libraries/dtos/marketplace/create.offer.dto';
@Injectable()
export class MessagesRepository {
constructor(
private _messagesGroup: PrismaRepository<'messagesGroup'>,
private _messages: PrismaRepository<'messages'>,
private _orders: PrismaRepository<'orders'>,
private _organizations: PrismaRepository<'organization'>,
private _post: PrismaRepository<'post'>,
private _payoutProblems: PrismaRepository<'payoutProblems'>,
private _users: PrismaRepository<'user'>
) {}
async createConversation(
userId: string,
organizationId: string,
body: NewConversationDto
) {
const { id } =
(await this._messagesGroup.model.messagesGroup.findFirst({
where: {
buyerOrganizationId: organizationId,
buyerId: userId,
sellerId: body.to,
},
})) ||
(await this._messagesGroup.model.messagesGroup.create({
data: {
buyerOrganizationId: organizationId,
buyerId: userId,
sellerId: body.to,
},
}));
await this._messagesGroup.model.messagesGroup.update({
where: {
id,
},
data: {
updatedAt: new Date(),
},
});
await this._messages.model.messages.create({
data: {
groupId: id,
from: From.BUYER,
content: body.message,
},
});
return { id };
}
getOrgByOrder(orderId: string) {
return this._orders.model.orders.findFirst({
where: {
id: orderId,
},
select: {
messageGroup: {
select: {
buyerOrganizationId: true,
},
},
},
});
}
async getMessagesGroup(userId: string, organizationId: string) {
return this._messagesGroup.model.messagesGroup.findMany({
where: {
OR: [
{
buyerOrganizationId: organizationId,
buyerId: userId,
},
{
sellerId: userId,
},
],
},
orderBy: {
updatedAt: 'desc',
},
include: {
seller: {
select: {
id: true,
name: true,
picture: {
select: {
id: true,
path: true,
},
},
},
},
buyer: {
select: {
id: true,
name: true,
picture: {
select: {
id: true,
path: true,
},
},
},
},
orders: {
orderBy: {
createdAt: 'desc',
},
take: 1,
},
messages: {
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
}
async createMessage(
userId: string,
orgId: string,
groupId: string,
body: AddMessageDto
) {
const group = await this._messagesGroup.model.messagesGroup.findFirst({
where: {
id: groupId,
OR: [
{
buyerOrganizationId: orgId,
buyerId: userId,
},
{
sellerId: userId,
},
],
},
});
if (!group) {
throw new Error('Group not found');
}
const create = await this.createNewMessage(
groupId,
group.buyerId === userId ? From.BUYER : From.SELLER,
body.message
);
await this._messagesGroup.model.messagesGroup.update({
where: {
id: groupId,
},
data: {
updatedAt: new Date(),
},
});
if (userId === group.buyerId) {
return create.group.seller;
}
return create.group.buyer;
}
async updateOrderOnline(userId: string) {
await this._users.model.user.update({
where: {
id: userId,
},
data: {
lastOnline: new Date(),
},
});
}
async getMessages(
userId: string,
organizationId: string,
groupId: string,
page: number
) {
return this._messagesGroup.model.messagesGroup.findFirst({
where: {
id: groupId,
OR: [
{
buyerOrganizationId: organizationId,
buyerId: userId,
},
{
sellerId: userId,
},
],
},
include: {
messages: {
orderBy: {
createdAt: 'desc',
},
take: 10,
skip: (page - 1) * 10,
},
},
});
}
async createOffer(userId: string, body: CreateOfferDto) {
const messageGroup =
await this._messagesGroup.model.messagesGroup.findFirst({
where: {
id: body.group,
sellerId: userId,
},
select: {
id: true,
buyer: {
select: {
id: true,
},
},
orders: {
orderBy: {
createdAt: 'desc',
},
take: 1,
},
},
});
if (!messageGroup?.id) {
throw new Error('Group not found');
}
if (
messageGroup.orders.length &&
messageGroup.orders[0].status !== 'COMPLETED' &&
messageGroup.orders[0].status !== 'CANCELED'
) {
throw new Error('Order already exists');
}
const data = await this._orders.model.orders.create({
data: {
sellerId: userId,
buyerId: messageGroup.buyer.id,
messageGroupId: messageGroup.id,
ordersItems: {
createMany: {
data: body.socialMedia.map((item) => ({
quantity: item.total,
integrationId: item.value,
price: item.price,
})),
},
},
status: 'PENDING',
},
select: {
id: true,
ordersItems: {
select: {
quantity: true,
price: true,
integration: {
select: {
name: true,
providerIdentifier: true,
picture: true,
id: true,
},
},
},
},
},
});
await this._messages.model.messages.create({
data: {
groupId: body.group,
from: From.SELLER,
content: '',
special: JSON.stringify({ type: 'offer', data: data }),
},
});
return { success: true };
}
async createNewMessage(
group: string,
from: From,
content: string,
special?: object
) {
return this._messages.model.messages.create({
data: {
groupId: group,
from,
content,
special: JSON.stringify(special),
},
select: {
id: true,
group: {
select: {
buyer: {
select: {
lastOnline: true,
id: true,
organizations: true,
},
},
seller: {
select: {
lastOnline: true,
id: true,
organizations: true,
},
},
},
},
},
});
}
async getOrderDetails(
userId: string,
organizationId: string,
orderId: string
) {
const order = await this._messagesGroup.model.messagesGroup.findFirst({
where: {
buyerId: userId,
buyerOrganizationId: organizationId,
},
select: {
buyer: true,
seller: true,
orders: {
include: {
ordersItems: {
select: {
quantity: true,
integration: true,
price: true,
},
},
},
where: {
id: orderId,
status: 'PENDING',
},
},
},
});
if (!order?.orders[0]?.id) {
throw new Error('Order not found');
}
return {
buyer: order.buyer,
seller: order.seller,
order: order.orders[0]!,
};
}
async canAddPost(id: string, order: string, integrationId: string) {
const findOrder = await this._orders.model.orders.findFirst({
where: {
id: order,
status: 'ACCEPTED',
},
select: {
posts: true,
ordersItems: true,
},
});
if (!findOrder) {
return false;
}
if (
findOrder.posts.find(
(p) => p.id === id && p.approvedSubmitForOrder === 'YES'
)
) {
return false;
}
if (
findOrder.posts.find(
(p) =>
p.id === id && p.approvedSubmitForOrder === 'WAITING_CONFIRMATION'
)
) {
return true;
}
const postsForIntegration = findOrder.ordersItems.filter(
(p) => p.integrationId === integrationId
);
const totalPostsRequired = postsForIntegration.reduce(
(acc, item) => acc + item.quantity,
0
);
const usedPosts = findOrder.posts.filter(
(p) =>
p.integrationId === integrationId &&
['WAITING_CONFIRMATION', 'YES'].indexOf(p.approvedSubmitForOrder) > -1
).length;
return totalPostsRequired > usedPosts;
}
changeOrderStatus(
orderId: string,
status: OrderStatus,
paymentIntent?: string
) {
return this._orders.model.orders.update({
where: {
id: orderId,
},
data: {
status,
captureId: paymentIntent,
},
});
}
async getMarketplaceAvailableOffers(orgId: string, id: string) {
const offers = await this._organizations.model.organization.findFirst({
where: {
id: orgId,
},
select: {
users: {
select: {
user: {
select: {
orderSeller: {
where: {
status: 'ACCEPTED',
},
select: {
id: true,
posts: {
where: {
deletedAt: null,
},
select: {
id: true,
integrationId: true,
approvedSubmitForOrder: true,
},
},
messageGroup: {
select: {
buyerOrganizationId: true,
},
},
buyer: {
select: {
id: true,
name: true,
picture: {
select: {
id: true,
path: true,
},
},
},
},
ordersItems: {
select: {
quantity: true,
integration: {
select: {
id: true,
name: true,
providerIdentifier: true,
},
},
},
},
},
},
},
},
},
},
},
});
const allOrders =
offers?.users.flatMap((user) => user.user.orderSeller) || [];
const onlyValidItems = allOrders.filter(
(order) =>
(order.posts.find((p) => p.id === id)
? 0
: order.posts.filter((f) => f.approvedSubmitForOrder !== 'NO')
.length) <
order.ordersItems.reduce((acc, item) => acc + item.quantity, 0)
);
return onlyValidItems
.map((order) => {
const postsNumbers = order.posts
.filter(
(p) =>
['WAITING_CONFIRMATION', 'YES'].indexOf(
p.approvedSubmitForOrder
) > -1
)
.reduce((acc, post) => {
acc[post.integrationId] = acc[post.integrationId] + 1 || 1;
return acc;
}, {} as { [key: string]: number });
const missing = order.ordersItems.map((item) => {
return {
integration: item,
missing: item.quantity - (postsNumbers[item.integration.id] || 0),
};
});
return {
id: order.id,
usedIds: order.posts.map((p) => ({
id: p.id,
status: p.approvedSubmitForOrder,
})),
buyer: order.buyer,
missing,
};
})
.filter((f) => f.missing.length);
}
async requestRevision(
userId: string,
orgId: string,
postId: string,
message: string
) {
const loadMessage = await this._messages.model.messages.findFirst({
where: {
id: message,
group: {
buyerOrganizationId: orgId,
},
},
select: {
id: true,
special: true,
},
});
const post = await this._post.model.post.findFirst({
where: {
id: postId,
approvedSubmitForOrder: 'WAITING_CONFIRMATION',
deletedAt: null,
},
});
if (post && loadMessage) {
const special = JSON.parse(loadMessage.special!);
special.data.status = 'REVISION';
await this._messages.model.messages.update({
where: {
id: message,
},
data: {
special: JSON.stringify(special),
},
});
await this._post.model.post.update({
where: {
id: postId,
deletedAt: null,
},
data: {
approvedSubmitForOrder: 'NO',
},
});
}
}
async requestCancel(orgId: string, postId: string) {
const getPost = await this._post.model.post.findFirst({
where: {
id: postId,
organizationId: orgId,
approvedSubmitForOrder: {
in: ['WAITING_CONFIRMATION', 'YES'],
},
},
select: {
lastMessage: true,
},
});
if (!getPost) {
throw new Error('Post not found');
}
await this._post.model.post.update({
where: {
id: postId,
},
data: {
approvedSubmitForOrder: 'NO',
submittedForOrganizationId: null,
},
});
const special = JSON.parse(getPost.lastMessage!.special!);
special.data.status = 'CANCELED';
await this._messages.model.messages.update({
where: {
id: getPost.lastMessage!.id,
},
data: {
special: JSON.stringify(special),
},
});
}
async requestApproved(
userId: string,
orgId: string,
postId: string,
message: string
) {
const loadMessage = await this._messages.model.messages.findFirst({
where: {
id: message,
group: {
buyerOrganizationId: orgId,
},
},
select: {
id: true,
special: true,
},
});
const post = await this._post.model.post.findFirst({
where: {
id: postId,
approvedSubmitForOrder: 'WAITING_CONFIRMATION',
deletedAt: null,
},
});
if (post && loadMessage) {
const special = JSON.parse(loadMessage.special!);
special.data.status = 'APPROVED';
await this._messages.model.messages.update({
where: {
id: message,
},
data: {
special: JSON.stringify(special),
},
});
await this._post.model.post.update({
where: {
id: postId,
deletedAt: null,
},
data: {
approvedSubmitForOrder: 'YES',
},
});
return post;
}
return false;
}
completeOrder(orderId: string) {
return this._orders.model.orders.update({
where: {
id: orderId,
},
data: {
status: 'COMPLETED',
},
});
}
async completeOrderAndPay(orgId: string, order: string) {
const findOrder = await this._orders.model.orders.findFirst({
where: {
id: order,
messageGroup: {
buyerOrganizationId: orgId,
},
},
select: {
captureId: true,
seller: {
select: {
account: true,
id: true,
},
},
ordersItems: true,
posts: true,
},
});
if (!findOrder) {
return false;
}
const releasedPosts = findOrder.posts.filter((p) => p.releaseURL);
const nonReleasedPosts = findOrder.posts.filter((p) => !p.releaseURL);
const totalPosts = releasedPosts.reduce((acc, item) => {
acc[item.integrationId] = (acc[item.integrationId] || 0) + 1;
return acc;
}, {} as { [key: string]: number });
const totalOrderItems = findOrder.ordersItems.reduce((acc, item) => {
acc[item.integrationId] = (acc[item.integrationId] || 0) + item.quantity;
return acc;
}, {} as { [key: string]: number });
const calculate = Object.keys(totalOrderItems).reduce((acc, key) => {
acc.push({
price: findOrder.ordersItems.find((p) => p.integrationId === key)!
.price,
quantity: totalOrderItems[key] - (totalPosts[key] || 0),
});
return acc;
}, [] as { price: number; quantity: number }[]);
const price = calculate.reduce((acc, item) => {
acc += item.price * item.quantity;
return acc;
}, 0);
return {
price,
account: findOrder.seller.account,
charge: findOrder.captureId,
posts: nonReleasedPosts,
sellerId: findOrder.seller.id,
};
}
payoutProblem(
orderId: string,
sellerId: string,
amount: number,
postId?: string
) {
return this._payoutProblems.model.payoutProblems.create({
data: {
amount,
orderId,
...(postId ? { postId } : {}),
userId: sellerId,
status: 'PAYMENT_ERROR',
},
});
}
async getOrders(userId: string, orgId: string, type: 'seller' | 'buyer') {
const orders = await this._orders.model.orders.findMany({
where: {
status: {
in: ['ACCEPTED', 'PENDING', 'COMPLETED'],
},
...(type === 'seller'
? {
sellerId: userId,
}
: {
messageGroup: {
buyerOrganizationId: orgId,
},
}),
},
orderBy: {
updatedAt: 'desc',
},
select: {
id: true,
status: true,
...(type === 'seller'
? {
buyer: {
select: {
name: true,
},
},
}
: {
seller: {
select: {
name: true,
},
},
}),
ordersItems: {
select: {
id: true,
quantity: true,
price: true,
integration: {
select: {
id: true,
picture: true,
name: true,
providerIdentifier: true,
},
},
},
},
posts: {
select: {
id: true,
integrationId: true,
releaseURL: true,
approvedSubmitForOrder: true,
state: true,
},
},
},
});
return {
orders: await Promise.all(
orders.map(async (order) => {
return {
id: order.id,
status: order.status,
// @ts-ignore
name: type === 'seller' ? order?.buyer?.name : order?.seller?.name,
price: order.ordersItems.reduce(
(acc, item) => acc + item.price * item.quantity,
0
),
details: await Promise.all(
order.ordersItems.map((item) => {
return {
posted: order.posts.filter(
(p) =>
p.releaseURL && p.integrationId === item.integration.id
).length,
submitted: order.posts.filter(
(p) =>
!p.releaseURL &&
(p.approvedSubmitForOrder === 'WAITING_CONFIRMATION' ||
p.approvedSubmitForOrder === 'YES') &&
p.integrationId === item.integration.id
).length,
integration: item.integration,
total: item.quantity,
price: item.price,
};
})
),
};
})
),
};
}
getPost(userId: string, orgId: string, postId: string) {
return this._post.model.post.findFirst({
where: {
id: postId,
submittedForOrder: {
messageGroup: {
OR: [{ sellerId: userId }, { buyerOrganizationId: orgId }],
},
},
},
select: {
organizationId: true,
integration: {
select: {
providerIdentifier: true,
},
},
},
});
}
}

View File

@ -1,252 +0,0 @@
import { Injectable } from '@nestjs/common';
import { MessagesRepository } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.repository';
import { NewConversationDto } from '@gitroom/nestjs-libraries/dtos/marketplace/new.conversation.dto';
import { AddMessageDto } from '@gitroom/nestjs-libraries/dtos/messages/add.message';
import { CreateOfferDto } from '@gitroom/nestjs-libraries/dtos/marketplace/create.offer.dto';
import { From, OrderStatus, User } from '@prisma/client';
import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import dayjs from 'dayjs';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
@Injectable()
export class MessagesService {
constructor(
private _workerServiceProducer: BullMqClient,
private _messagesRepository: MessagesRepository,
private _organizationRepository: OrganizationRepository,
private _inAppNotificationService: NotificationService
) {}
async createConversation(
userId: string,
organizationId: string,
body: NewConversationDto
) {
const conversation = await this._messagesRepository.createConversation(
userId,
organizationId,
body
);
const orgs = await this._organizationRepository.getOrgsByUserId(body.to);
await Promise.all(
orgs.map(async (org) => {
return this._inAppNotificationService.inAppNotification(
org.id,
'Request for service',
'A user has requested a service from you',
true
);
})
);
return conversation;
}
getMessagesGroup(userId: string, organizationId: string) {
return this._messagesRepository.getMessagesGroup(userId, organizationId);
}
async getMessages(
userId: string,
organizationId: string,
groupId: string,
page: number
) {
if (page === 1) {
this._messagesRepository.updateOrderOnline(userId);
}
return this._messagesRepository.getMessages(
userId,
organizationId,
groupId,
page
);
}
async createNewMessage(
group: string,
from: From,
content: string,
special?: object
) {
const message = await this._messagesRepository.createNewMessage(
group,
from,
content,
special
);
const user = from === 'BUYER' ? message.group.seller : message.group.buyer;
await Promise.all(
user.organizations.map((p) => {
return this.sendMessageNotification({
id: p.organizationId,
lastOnline: user.lastOnline,
});
})
);
return message;
}
async sendMessageNotification(user: { id: string; lastOnline: Date }) {
if (dayjs(user.lastOnline).add(5, 'minute').isBefore(dayjs())) {
await this._inAppNotificationService.inAppNotification(
user.id,
'New message',
'You have a new message',
true
);
}
}
async createMessage(
userId: string,
orgId: string,
groupId: string,
body: AddMessageDto
) {
const message = await this._messagesRepository.createMessage(
userId,
orgId,
groupId,
body
);
await Promise.all(
message.organizations.map((p) => {
return this.sendMessageNotification({
id: p.organizationId,
lastOnline: message.lastOnline,
});
})
);
return message;
}
createOffer(userId: string, body: CreateOfferDto) {
return this._messagesRepository.createOffer(userId, body);
}
getOrderDetails(userId: string, organizationId: string, orderId: string) {
return this._messagesRepository.getOrderDetails(
userId,
organizationId,
orderId
);
}
canAddPost(id: string, order: string, integrationId: string) {
return this._messagesRepository.canAddPost(id, order, integrationId);
}
changeOrderStatus(
orderId: string,
status: OrderStatus,
paymentIntent?: string
) {
return this._messagesRepository.changeOrderStatus(
orderId,
status,
paymentIntent
);
}
getOrgByOrder(orderId: string) {
return this._messagesRepository.getOrgByOrder(orderId);
}
getMarketplaceAvailableOffers(orgId: string, id: string) {
return this._messagesRepository.getMarketplaceAvailableOffers(orgId, id);
}
getPost(userId: string, orgId: string, postId: string) {
return this._messagesRepository.getPost(userId, orgId, postId);
}
requestRevision(
userId: string,
orgId: string,
postId: string,
message: string
) {
return this._messagesRepository.requestRevision(
userId,
orgId,
postId,
message
);
}
async requestApproved(
userId: string,
orgId: string,
postId: string,
message: string
) {
const post = await this._messagesRepository.requestApproved(
userId,
orgId,
postId,
message
);
if (post) {
this._workerServiceProducer.emit('post', {
id: post.id,
options: {
delay: 0, //dayjs(post.publishDate).diff(dayjs(), 'millisecond'),
},
payload: {
id: post.id,
},
});
}
}
async requestCancel(orgId: string, postId: string) {
const cancel = await this._messagesRepository.requestCancel(orgId, postId);
await this._workerServiceProducer.delete('post', postId);
return cancel;
}
async completeOrderAndPay(orgId: string, order: string) {
const orderList = await this._messagesRepository.completeOrderAndPay(
orgId,
order
);
if (!orderList) {
return false;
}
orderList.posts.forEach((post) => {
this._workerServiceProducer.delete('post', post.id);
});
return orderList;
}
completeOrder(orderId: string) {
return this._messagesRepository.completeOrder(orderId);
}
payoutProblem(
orderId: string,
sellerId: string,
amount: number,
postId?: string
) {
return this._messagesRepository.payoutProblem(
orderId,
sellerId,
amount,
postId
);
}
getOrders(userId: string, orgId: string, type: 'seller' | 'buyer') {
return this._messagesRepository.getOrders(userId, orgId, type);
}
}

View File

@ -1,138 +0,0 @@
export const tagsList =
process.env.isGeneral === 'true'
? [
{
key: 'services',
name: 'Niches',
options: [
{ key: 'real-estate', value: 'Real Estate' },
{ key: 'fashion', value: 'Fashion' },
{ key: 'health-and-fitness', value: 'Health and Fitness' },
{ key: 'beauty', value: 'Beauty' },
{ key: 'travel', value: 'Travel' },
{ key: 'food', value: 'Food' },
{ key: 'tech', value: 'Tech' },
{ key: 'gaming', value: 'Gaming' },
{ key: 'parenting', value: 'Parenting' },
{ key: 'education', value: 'Education' },
{ key: 'business', value: 'Business' },
{ key: 'finance', value: 'Finance' },
{ key: 'diy', value: 'DIY' },
{ key: 'pets', value: 'Pets' },
{ key: 'lifestyle', value: 'Lifestyle' },
{ key: 'sports', value: 'Sports' },
{ key: 'entertainment', value: 'Entertainment' },
{ key: 'art', value: 'Art' },
{ key: 'photography', value: 'Photography' },
{ key: 'sustainability', value: 'Sustainability' },
],
},
]
: [
{
key: 'services',
name: 'Services',
options: [
{
key: 'content-writer',
value: 'Content Writer',
},
{
key: 'influencers',
value: 'Influencers',
},
],
},
{
key: 'niches',
name: 'Niches',
options: [
{
key: 'kubernetes',
value: 'Kubernetes',
},
{
key: 'fullstack',
value: 'Fullstack',
},
{
key: 'security',
value: 'Security',
},
{
key: 'infrastructure',
value: 'Infrastructure',
},
{
key: 'productivity',
value: 'Productivity',
},
{
key: 'web3',
value: 'Web3',
},
{
key: 'cloud-native',
value: 'Cloud Native',
},
{
key: 'ml',
value: 'ML',
},
],
},
{
key: 'technologies',
name: 'Technologies',
options: [
{
key: 'html',
value: 'HTML',
},
{
key: 'css',
value: 'CSS',
},
{
key: 'javascript',
value: 'JavaScript',
},
{
key: 'typescript',
value: 'TypeScript',
},
{
key: 'rust',
value: 'Rust',
},
{
key: 'go',
value: 'Go',
},
{
key: 'python',
value: 'Python',
},
{
key: 'java',
value: 'Java',
},
{
key: 'php',
value: 'PHP',
},
{
key: 'ruby',
value: 'Ruby',
},
{
key: 'c',
value: 'C/C++',
},
],
},
];
export const allTagsOptions = tagsList.reduce((acc, tag) => {
return [...acc, ...tag.options];
}, [] as Array<{ key: string; value: string }>);

View File

@ -2,9 +2,10 @@ import { Injectable } from '@nestjs/common';
import { NotificationsRepository } from '@gitroom/nestjs-libraries/database/prisma/notifications/notifications.repository';
import { EmailService } from '@gitroom/nestjs-libraries/services/email.service';
import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import dayjs from 'dayjs';
import { TemporalService } from 'nestjs-temporal-core';
import { TypedSearchAttributes } from '@temporalio/common';
import { organizationId } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
export type NotificationType = 'success' | 'fail' | 'info';
@ -14,7 +15,7 @@ export class NotificationService {
private _notificationRepository: NotificationsRepository,
private _emailService: EmailService,
private _organizationRepository: OrganizationRepository,
private _workerServiceProducer: BullMqClient
private _temporalService: TemporalService
) {}
getMainPageCount(organizationId: string, userId: string) {
@ -46,42 +47,41 @@ export class NotificationService {
digest = false,
type: NotificationType = 'success'
) {
const date = new Date().toISOString();
await this._notificationRepository.createNotification(orgId, message);
if (!sendEmail) {
return;
}
if (digest) {
await ioRedis.watch('digest_' + orgId);
const value = await ioRedis.get('digest_' + orgId);
try {
await this._temporalService.client
.getRawClient()
?.workflow.start('digestEmailWorkflow', {
workflowId: 'digest_email_workflow_' + orgId,
taskQueue: 'main',
args: [{ organizationId: orgId }],
typedSearchAttributes: new TypedSearchAttributes([
{
key: organizationId,
value: orgId,
},
]),
});
} catch (err) {}
// Track notification types in the digest
const typesKey = 'digest_types_' + orgId;
await ioRedis.sadd(typesKey, type);
await ioRedis.expire(typesKey, 120); // Slightly longer than digest window
if (value) {
return;
}
await ioRedis
.multi()
.set('digest_' + orgId, date)
.expire('digest_' + orgId, 60)
.exec();
this._workerServiceProducer.emit('sendDigestEmail', {
id: 'digest_' + orgId,
options: {
delay: 60000,
},
payload: {
subject,
org: orgId,
since: date,
},
});
await this._temporalService.signalWorkflow(
'digest_email_workflow_' + orgId,
'email',
[
[
{
title: subject,
message,
type,
},
],
]
);
return;
}

View File

@ -273,6 +273,8 @@ export class OrganizationRepository {
select: {
email: true,
id: true,
sendSuccessEmails: true,
sendFailureEmails: true,
},
},
},

View File

@ -1,6 +1,5 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
ValidationPipe,
} from '@nestjs/common';
@ -10,10 +9,7 @@ import dayjs from 'dayjs';
import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager';
import { Integration, Post, Media, From, State } from '@prisma/client';
import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { shuffle } from 'lodash';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { StripeService } from '@gitroom/nestjs-libraries/services/stripe.service';
import { CreateGeneratedPostsDto } from '@gitroom/nestjs-libraries/dtos/generator/create.generated.posts.dto';
import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
@ -30,7 +26,10 @@ dayjs.extend(utc);
import * as Sentry from '@sentry/nestjs';
import { TemporalService } from 'nestjs-temporal-core';
import { TypedSearchAttributes } from '@temporalio/common';
import { postId as postIdSearchParam } from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
import {
organizationId,
postId as postIdSearchParam,
} from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute';
type PostWithConditionals = Post & {
integration?: Integration;
@ -43,23 +42,13 @@ export class PostsService {
constructor(
private _postRepository: PostsRepository,
private _integrationManager: IntegrationManager,
private _notificationService: NotificationService,
private _messagesService: MessagesService,
private _stripeService: StripeService,
private _integrationService: IntegrationService,
private _mediaService: MediaService,
private _shortLinkService: ShortLinkService,
private openaiService: OpenaiService,
private _openaiService: OpenaiService,
private _temporalService: TemporalService
) {}
checkPending15minutesBack() {
return this._postRepository.checkPending15minutesBack();
}
searchForMissingThreeHoursPosts() {
return this._postRepository.searchForMissingThreeHoursPosts();
}
updatePost(id: string, postId: string, releaseURL: string) {
return this._postRepository.updatePost(id, postId, releaseURL);
}
@ -434,17 +423,14 @@ export class PostsService {
content: updateContent[i],
}));
const { previousPost, posts } =
await this._postRepository.createOrUpdatePost(
body.type,
orgId,
body.type === 'now'
? dayjs().format('YYYY-MM-DDTHH:mm:00')
: body.date,
post,
body.tags,
body.inter
);
const { posts } = await this._postRepository.createOrUpdatePost(
body.type,
orgId,
body.type === 'now' ? dayjs().format('YYYY-MM-DDTHH:mm:00') : body.date,
post,
body.tags,
body.inter
);
if (!posts?.length) {
return [] as any[];
@ -478,12 +464,22 @@ export class PostsService {
?.workflow.start('postWorkflow', {
workflowId: `post_${posts[0].id}`,
taskQueue: 'main',
args: [{ postId: posts[0].id, organizationId: orgId }],
args: [
{
taskQueue: post.settings.__type.split('-')[0].toLowerCase(),
postId: posts[0].id,
organizationId: orgId,
},
],
typedSearchAttributes: new TypedSearchAttributes([
{
key: postIdSearchParam,
value: posts[0].id,
},
{
key: organizationId,
value: orgId,
},
]),
});
@ -498,7 +494,7 @@ export class PostsService {
}
async separatePosts(content: string, len: number) {
return this.openaiService.separatePosts(content, len);
return this._openaiService.separatePosts(content, len);
}
async changeState(id: string, state: State, err?: any, body?: any) {
@ -536,94 +532,30 @@ export class PostsService {
?.workflow.start('postWorkflow', {
workflowId: `post_${getPostById.id}`,
taskQueue: 'main',
args: [{ postId: getPostById.id, organizationId: orgId }],
args: [
{
taskQueue: getPostById.integration.providerIdentifier
.split('-')[0]
.toLowerCase(),
postId: getPostById.id,
organizationId: orgId,
},
],
typedSearchAttributes: new TypedSearchAttributes([
{
key: postIdSearchParam,
value: getPostById.id,
},
{
key: organizationId,
value: orgId,
},
]),
});
return newDate;
}
async payout(id: string, url: string) {
const getPost = await this._postRepository.getPostById(id);
if (!getPost || !getPost.submittedForOrder) {
return;
}
const findPrice = getPost.submittedForOrder.ordersItems.find(
(orderItem) => orderItem.integrationId === getPost.integrationId
)!;
await this._messagesService.createNewMessage(
getPost.submittedForOrder.messageGroupId,
From.SELLER,
'',
{
type: 'published',
data: {
id: getPost.submittedForOrder.id,
postId: id,
status: 'PUBLISHED',
integrationId: getPost.integrationId,
integration: getPost.integration.providerIdentifier,
picture: getPost.integration.picture,
name: getPost.integration.name,
url,
},
}
);
const totalItems = getPost.submittedForOrder.ordersItems.reduce(
(all, p) => all + p.quantity,
0
);
const totalPosts = getPost.submittedForOrder.posts.length;
if (totalItems === totalPosts) {
await this._messagesService.completeOrder(getPost.submittedForOrder.id);
await this._messagesService.createNewMessage(
getPost.submittedForOrder.messageGroupId,
From.SELLER,
'',
{
type: 'order-completed',
data: {
id: getPost.submittedForOrder.id,
postId: id,
status: 'PUBLISHED',
},
}
);
}
try {
await this._stripeService.payout(
getPost.submittedForOrder.id,
getPost.submittedForOrder.captureId!,
getPost.submittedForOrder.seller.account!,
findPrice.price
);
return this._notificationService.inAppNotification(
getPost.integration.organizationId,
'Payout completed',
`You have received a payout of $${findPrice.price}`,
true
);
} catch (err) {
await this._messagesService.payoutProblem(
getPost.submittedForOrder.id,
getPost.submittedForOrder.seller.id,
findPrice.price,
id
);
}
}
async generatePostsDraft(orgId: string, body: CreateGeneratedPostsDto) {
const getAllIntegrations = (
await this._integrationService.getIntegrationsList(orgId)
@ -785,28 +717,4 @@ export class PostsService {
) {
return this._postRepository.createComment(orgId, userId, postId, comment);
}
async sendDigestEmail(subject: string, orgId: string, since: string) {
const getNotificationsForOrgSince =
await this._notificationService.getNotificationsSince(orgId, since);
if (getNotificationsForOrgSince.length === 0) {
return;
}
// Get the types of notifications in this digest
const types = await this._notificationService.getDigestTypes(orgId);
const message = getNotificationsForOrgSince
.map((p) => p.content)
.join('<br />');
await this._notificationService.sendDigestEmailsToOrg(
orgId,
getNotificationsForOrgSince.length === 1
? subject
: '[Postiz] Your latest notifications',
message,
types.length > 0 ? types : ['success'] // Default to success if no types tracked
);
}
}

View File

@ -86,7 +86,6 @@ model User {
lastReadNotifications DateTime @default(now())
inviteId String?
activated Boolean @default(true)
marketplace Boolean @default(true)
account String?
connectedAccount Boolean @default(false)
lastOnline DateTime @default(now())

View File

@ -1,198 +0,0 @@
import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { StarsListDto } from '@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto';
@Injectable()
export class StarsRepository {
constructor(
private _github: PrismaRepository<'gitHub'>,
private _stars: PrismaRepository<'star'>,
private _trending: PrismaRepository<'trending'>
) {}
getGitHubRepositoriesByOrgId(org: string) {
return this._github.model.gitHub.findMany({
where: {
organizationId: org,
},
});
}
replaceOrAddTrending(
language: string,
hashedNames: string,
arr: { name: string; position: number }[]
) {
return this._trending.model.trending.upsert({
create: {
language,
hash: hashedNames,
trendingList: JSON.stringify(arr),
date: new Date(),
},
update: {
language,
hash: hashedNames,
trendingList: JSON.stringify(arr),
date: new Date(),
},
where: {
language,
},
});
}
getAllGitHubRepositories() {
return this._github.model.gitHub.findMany({
distinct: ['login'],
});
}
async getLastStarsByLogin(login: string) {
return (
await this._stars.model.star.findMany({
where: {
login,
},
orderBy: {
date: 'desc',
},
take: 1,
})
)?.[0];
}
async getStarsByLogin(login: string) {
return this._stars.model.star.findMany({
where: {
login,
},
orderBy: {
date: 'asc',
},
});
}
async getGitHubsByNames(names: string[]) {
return this._github.model.gitHub.findMany({
where: {
login: {
in: names,
},
},
});
}
findValidToken(login: string) {
return this._github.model.gitHub.findFirst({
where: {
login,
},
});
}
createStars(
login: string,
totalNewsStars: number,
totalStars: number,
totalNewForks: number,
totalForks: number,
date: Date
) {
return this._stars.model.star.upsert({
create: {
login,
stars: totalNewsStars,
forks: totalNewForks,
totalForks,
totalStars,
date,
},
update: {
stars: totalNewsStars,
totalStars,
forks: totalNewForks,
totalForks,
},
where: {
login_date: {
date,
login,
},
},
});
}
getTrendingByLanguage(language: string) {
return this._trending.model.trending.findUnique({
where: {
language,
},
});
}
getStarsFilter(githubs: string[], starsFilter: StarsListDto) {
return this._stars.model.star.findMany({
orderBy: {
[starsFilter.key || 'date']: starsFilter.state || 'desc',
},
where: {
login: {
in: githubs.filter((f) => f),
},
},
take: 20,
skip: (starsFilter.page - 1) * 10,
});
}
addGitHub(orgId: string, accessToken: string) {
return this._github.model.gitHub.create({
data: {
token: accessToken,
organizationId: orgId,
jobId: '',
},
});
}
getGitHubById(orgId: string, id: string) {
return this._github.model.gitHub.findUnique({
where: {
organizationId: orgId,
id,
},
});
}
updateGitHubLogin(orgId: string, id: string, login: string) {
return this._github.model.gitHub.update({
where: {
organizationId: orgId,
id,
},
data: {
login,
},
});
}
deleteRepository(orgId: string, id: string) {
return this._github.model.gitHub.delete({
where: {
organizationId: orgId,
id,
},
});
}
getOrganizationsByGitHubLogin(login: string) {
return this._github.model.gitHub.findMany({
select: {
organizationId: true,
},
where: {
login,
},
distinct: ['organizationId'],
});
}
}

View File

@ -1,485 +0,0 @@
import { HttpException, Injectable } from '@nestjs/common';
import { StarsRepository } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.repository';
import { chunk, groupBy } from 'lodash';
import dayjs from 'dayjs';
import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service';
import { StarsListDto } from '@gitroom/nestjs-libraries/dtos/analytics/stars.list.dto';
import { mean } from 'simple-statistics';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
enum Inform {
Removed,
New,
Changed,
}
@Injectable()
export class StarsService {
constructor(
private _starsRepository: StarsRepository,
private _notificationsService: NotificationService,
private _workerServiceProducer: BullMqClient
) {}
getGitHubRepositoriesByOrgId(org: string) {
return this._starsRepository.getGitHubRepositoriesByOrgId(org);
}
getAllGitHubRepositories() {
return this._starsRepository.getAllGitHubRepositories();
}
getStarsByLogin(login: string) {
return this._starsRepository.getStarsByLogin(login);
}
getLastStarsByLogin(login: string) {
return this._starsRepository.getLastStarsByLogin(login);
}
createStars(
login: string,
totalNewsStars: number,
totalStars: number,
totalNewForks: number,
totalForks: number,
date: Date
) {
return this._starsRepository.createStars(
login,
totalNewsStars,
totalStars,
totalNewForks,
totalForks,
date
);
}
async sync(login: string, token?: string) {
const loadAllStars = await this.syncProcess(login, token);
const loadAllForks = await this.syncForksProcess(login, token);
const allDates = [
...new Set([...Object.keys(loadAllStars), ...Object.keys(loadAllForks)]),
];
const sortedArray = allDates.sort(
(a, b) => dayjs(a).unix() - dayjs(b).unix()
);
let addPreviousStars = 0;
let addPreviousForks = 0;
for (const date of sortedArray) {
const dateObject = dayjs(date).toDate();
addPreviousStars += loadAllStars[date] || 0;
addPreviousForks += loadAllForks[date] || 0;
await this._starsRepository.createStars(
login,
loadAllStars[date] || 0,
addPreviousStars,
loadAllForks[date] || 0,
addPreviousForks,
dateObject
);
}
}
async findValidToken(login: string) {
return this._starsRepository.findValidToken(login);
}
async fetchWillFallback(url: string, userToken?: string): Promise<Response> {
if (userToken) {
const response = await fetch(url, {
headers: {
Accept: 'application/vnd.github.v3.star+json',
Authorization: `Bearer ${userToken}`,
},
});
if (response.status === 200) {
return response;
}
}
const response2 = await fetch(url, {
headers: {
Accept: 'application/vnd.github.v3.star+json',
...(process.env.GITHUB_AUTH
? { Authorization: `token ${process.env.GITHUB_AUTH}` }
: {}),
},
});
const totalRemaining = +(
response2.headers.get('x-ratelimit-remaining') ||
response2.headers.get('X-RateLimit-Remaining') ||
0
);
const resetTime = +(
response2.headers.get('x-ratelimit-reset') ||
response2.headers.get('X-RateLimit-Reset') ||
0
);
if (totalRemaining < 10) {
console.log('waiting for the rate limit');
const delay = resetTime * 1000 - Date.now() + 1000;
await new Promise((resolve) => setTimeout(resolve, delay));
return this.fetchWillFallback(url, userToken);
}
return response2;
}
async syncForksProcess(login: string, userToken?: string, page = 1) {
console.log('processing forks');
const starsRequest = await this.fetchWillFallback(
`https://api.github.com/repos/${login}/forks?page=${page}&per_page=100`,
userToken
);
const data: Array<{ created_at: string }> = await starsRequest.json();
const mapDataToDate = groupBy(data, (p) =>
dayjs(p.created_at).format('YYYY-MM-DD')
);
// take all the forks from the page
const aggForks: { [key: string]: number } = Object.values(
mapDataToDate
).reduce(
(acc, value) => ({
...acc,
[dayjs(value[0].created_at).format('YYYY-MM-DD')]: value.length,
}),
{}
);
// if we have 100 stars, we need to fetch the next page and merge the results (recursively)
const nextOne: { [key: string]: number } =
data.length === 100
? await this.syncForksProcess(login, userToken, page + 1)
: {};
// merge the results
const allKeys = [
...new Set([...Object.keys(aggForks), ...Object.keys(nextOne)]),
];
return {
...allKeys.reduce(
(acc, key) => ({
...acc,
[key]: (aggForks[key] || 0) + (nextOne[key] || 0),
}),
{} as { [key: string]: number }
),
};
}
async syncProcess(login: string, userToken?: string, page = 1) {
console.log('processing stars');
const starsRequest = await this.fetchWillFallback(
`https://api.github.com/repos/${login}/stargazers?page=${page}&per_page=100`,
userToken
);
const data: Array<{ starred_at: string }> = await starsRequest.json();
const mapDataToDate = groupBy(data, (p) =>
dayjs(p.starred_at).format('YYYY-MM-DD')
);
// take all the stars from the page
const aggStars: { [key: string]: number } = Object.values(
mapDataToDate
).reduce(
(acc, value) => ({
...acc,
[dayjs(value[0].starred_at).format('YYYY-MM-DD')]: value.length,
}),
{}
);
// if we have 100 stars, we need to fetch the next page and merge the results (recursively)
const nextOne: { [key: string]: number } =
data.length === 100
? await this.syncProcess(login, userToken, page + 1)
: {};
// merge the results
const allKeys = [
...new Set([...Object.keys(aggStars), ...Object.keys(nextOne)]),
];
return {
...allKeys.reduce(
(acc, key) => ({
...acc,
[key]: (aggStars[key] || 0) + (nextOne[key] || 0),
}),
{} as { [key: string]: number }
),
};
}
async updateTrending(
language: string,
hash: string,
arr: Array<{ name: string; position: number }>
) {
const currentTrending = await this._starsRepository.getTrendingByLanguage(
language
);
if (currentTrending?.hash === hash) {
return;
}
if (currentTrending) {
const list: Array<{ name: string; position: number }> = JSON.parse(
currentTrending.trendingList
);
const removedFromTrending = list.filter(
(p) => !arr.find((a) => a.name === p.name)
);
const changedPosition = arr.filter((p) => {
const current = list.find((a) => a.name === p.name);
return current && current.position !== p.position;
});
if (removedFromTrending.length) {
// let people know they are not trending anymore
await this.inform(Inform.Removed, removedFromTrending, language);
}
if (changedPosition.length) {
// let people know they changed position
await this.inform(Inform.Changed, changedPosition, language);
}
}
const informNewPeople = arr.filter(
(p) =>
!currentTrending?.trendingList ||
currentTrending?.trendingList?.indexOf(p.name) === -1
);
// let people know they are trending
await this.inform(Inform.New, informNewPeople, language);
await this.replaceOrAddTrending(language, hash, arr);
}
async inform(
type: Inform,
removedFromTrending: Array<{ name: string; position: number }>,
language: string
) {
const names = await this._starsRepository.getGitHubsByNames(
removedFromTrending.map((p) => p.name)
);
const mapDbNamesToList = names.map(
(n) => removedFromTrending.find((p) => p.name === n.login)!
);
for (const person of mapDbNamesToList) {
const getOrganizationsByGitHubLogin =
await this._starsRepository.getOrganizationsByGitHubLogin(person.name);
for (const org of getOrganizationsByGitHubLogin) {
switch (type) {
case Inform.Removed:
return this._notificationsService.inAppNotification(
org.organizationId,
`${person.name} is not trending on GitHub anymore`,
`${person.name} is not trending anymore in ${language}`,
true
);
case Inform.New:
return this._notificationsService.inAppNotification(
org.organizationId,
`${person.name} is trending on GitHub`,
`${person.name} is trending in ${
language || 'On the main feed'
} position #${person.position}`,
true
);
case Inform.Changed:
return this._notificationsService.inAppNotification(
org.organizationId,
`${person.name} changed trending position on GitHub`,
`${person.name} changed position in ${
language || 'on the main feed to position'
} position #${person.position}`,
true
);
}
}
}
}
async replaceOrAddTrending(
language: string,
hash: string,
arr: Array<{ name: string; position: number }>
) {
return this._starsRepository.replaceOrAddTrending(language, hash, arr);
}
async getStars(org: string) {
const getGitHubs = await this.getGitHubRepositoriesByOrgId(org);
const list = [];
for (const gitHub of getGitHubs) {
if (!gitHub.login) {
continue;
}
const getAllByLogin = await this.getStarsByLogin(gitHub.login!);
const stars = getAllByLogin.filter((f) => f.stars);
const graphSize = stars.length < 10 ? stars.length : stars.length / 10;
const forks = getAllByLogin.filter((f) => f.forks);
const graphForkSize =
forks.length < 10 ? forks.length : forks.length / 10;
list.push({
login: gitHub.login,
stars: chunk(stars, graphSize).reduce((acc, chunkedStars) => {
return [
...acc,
{
totalStars: chunkedStars[chunkedStars.length - 1].totalStars,
date: chunkedStars[chunkedStars.length - 1].date,
},
];
}, [] as Array<{ totalStars: number; date: Date }>),
forks: chunk(forks, graphForkSize).reduce((acc, chunkedForks) => {
return [
...acc,
{
totalForks: chunkedForks[chunkedForks.length - 1].totalForks,
date: chunkedForks[chunkedForks.length - 1].date,
},
];
}, [] as Array<{ totalForks: number; date: Date }>),
});
}
return list;
}
async getStarsFilter(orgId: string, starsFilter: StarsListDto) {
const getGitHubs = await this.getGitHubRepositoriesByOrgId(orgId);
if (getGitHubs.filter((f) => f.login).length === 0) {
return [];
}
return this._starsRepository.getStarsFilter(
getGitHubs.map((p) => p.login) as string[],
starsFilter
);
}
async addGitHub(orgId: string, code: string) {
const { access_token } = await (
await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: `${process.env.FRONTEND_URL}/settings`,
}),
})
).json();
return this._starsRepository.addGitHub(orgId, access_token);
}
async getOrganizations(orgId: string, id: string) {
const getGitHub = await this._starsRepository.getGitHubById(orgId, id);
return (
await fetch(`https://api.github.com/user/orgs`, {
headers: {
Authorization: `token ${getGitHub?.token!}`,
},
})
).json();
}
async getRepositoriesOfOrganization(
orgId: string,
id: string,
github: string
) {
const getGitHub = await this._starsRepository.getGitHubById(orgId, id);
return (
await fetch(`https://api.github.com/orgs/${github}/repos`, {
headers: {
Authorization: `token ${getGitHub?.token!}`,
},
})
).json();
}
async updateGitHubLogin(orgId: string, id: string, login: string) {
const check = await fetch(`https://github.com/${login}`);
if (check.status === 404) {
throw new HttpException('GitHub repository not found!', 404);
}
this._workerServiceProducer
.emit('sync_all_stars', { payload: { login } })
.subscribe();
return this._starsRepository.updateGitHubLogin(orgId, id, login);
}
async deleteRepository(orgId: string, id: string) {
return this._starsRepository.deleteRepository(orgId, id);
}
async predictTrending(max = 500) {
const firstDate = dayjs().subtract(1, 'day');
return [
firstDate.format('YYYY-MM-DDT12:00:00'),
...[...new Array(max)].map((p, index) => {
return firstDate.add(index, 'day').format('YYYY-MM-DDT12:00:00');
}),
];
}
async predictTrendingLoop(
trendings: Array<{ date: Date }>,
current = 0,
max = 500
): Promise<Date[]> {
const dates = trendings.map((result) => dayjs(result.date).toDate());
const intervals = dates
.slice(1)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
.map((date, i) => (date - dates[i]) / (1000 * 60 * 60 * 24));
const nextInterval = intervals.length === 0 ? null : mean(intervals);
const lastTrendingDate = dates[dates.length - 1];
const nextTrendingDate = !nextInterval
? false
: dayjs(
new Date(
lastTrendingDate.getTime() + nextInterval * 24 * 60 * 60 * 1000
)
).toDate();
if (!nextTrendingDate) {
return [];
}
return [
nextTrendingDate,
...(current < max
? await this.predictTrendingLoop(
[...trendings, { date: nextTrendingDate }],
current + 1,
max
)
: []),
];
}
}

View File

@ -29,14 +29,6 @@ export class SubscriptionService {
return this._subscriptionRepository.getCode(code);
}
updateAccount(userId: string, account: string) {
return this._subscriptionRepository.updateAccount(userId, account);
}
getUserAccount(userId: string) {
return this._subscriptionRepository.getUserAccount(userId);
}
async deleteSubscription(customerId: string) {
await this.modifySubscription(
customerId,
@ -61,14 +53,6 @@ export class SubscriptionService {
subscriptionId
);
}
updateConnectedStatus(account: string, accountCharges: boolean) {
return this._subscriptionRepository.updateConnectedStatus(
account,
accountCharges
);
}
async modifySubscription(
customerId: string,
totalChannels: number,
@ -130,23 +114,6 @@ export class SubscriptionService {
}
return true;
// if (to.faq < from.faq) {
// await this._faqRepository.deleteFAQs(getCurrentSubscription?.organizationId, from.faq - to.faq);
// }
// if (to.categories < from.categories) {
// await this._categoriesRepository.deleteCategories(getCurrentSubscription?.organizationId, from.categories - to.categories);
// }
// if (to.integrations < from.integrations) {
// await this._integrationsRepository.deleteIntegrations(getCurrentSubscription?.organizationId, from.integrations - to.integrations);
// }
// if (to.user < from.user) {
// await this._integrationsRepository.deleteUsers(getCurrentSubscription?.organizationId, from.user - to.user);
// }
// if (to.domains < from.domains) {
// await this._settingsService.deleteDomainByOrg(getCurrentSubscription?.organizationId);
// await this._organizationRepository.changePowered(getCurrentSubscription?.organizationId);
// }
}
async createOrUpdateSubscription(

View File

@ -2,8 +2,6 @@ import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/pris
import { Injectable } from '@nestjs/common';
import { Provider } from '@prisma/client';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { ItemsDto } from '@gitroom/nestjs-libraries/dtos/marketplace/items.dto';
import { allTagsOptions } from '@gitroom/nestjs-libraries/database/prisma/marketplace/tags.list';
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
import { EmailNotificationsDto } from '@gitroom/nestjs-libraries/dtos/users/email-notifications.dto';
@ -109,17 +107,6 @@ export class UsersRepository {
});
}
changeMarketplaceActive(userId: string, active: boolean) {
return this._user.model.user.update({
where: {
id: userId,
},
data: {
marketplace: active,
},
});
}
async getPersonal(userId: string) {
const user = await this._user.model.user.findUnique({
where: {
@ -185,83 +172,4 @@ export class UsersRepository {
},
});
}
async getMarketplacePeople(orgId: string, userId: string, items: ItemsDto) {
const info = {
id: {
not: userId,
},
account: {
not: null,
},
connectedAccount: true,
marketplace: true,
items: {
...(items.items.length
? {
some: {
OR: items.items.map((key) => ({ key })),
},
}
: {
some: {
OR: allTagsOptions.map((p) => ({ key: p.key })),
},
}),
},
};
const list = await this._user.model.user.findMany({
where: {
...info,
},
select: {
id: true,
name: true,
bio: true,
audience: true,
picture: {
select: {
id: true,
path: true,
},
},
organizations: {
select: {
organization: {
select: {
Integration: {
where: {
disabled: false,
deletedAt: null,
},
select: {
providerIdentifier: true,
},
},
},
},
},
},
items: {
select: {
key: true,
},
},
},
skip: (items.page - 1) * 8,
take: 8,
});
const count = await this._user.model.user.count({
where: {
...info,
},
});
return {
list,
count,
};
}
}

View File

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { UsersRepository } from '@gitroom/nestjs-libraries/database/prisma/users/users.repository';
import { Provider } from '@prisma/client';
import { ItemsDto } from '@gitroom/nestjs-libraries/dtos/marketplace/items.dto';
import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto';
import { EmailNotificationsDto } from '@gitroom/nestjs-libraries/dtos/users/email-notifications.dto';
import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository';
@ -37,18 +36,6 @@ export class UsersService {
return this._usersRepository.updatePassword(id, password);
}
changeAudienceSize(userId: string, audience: number) {
return this._usersRepository.changeAudienceSize(userId, audience);
}
changeMarketplaceActive(userId: string, active: boolean) {
return this._usersRepository.changeMarketplaceActive(userId, active);
}
getMarketplacePeople(orgId: string, userId: string, body: ItemsDto) {
return this._usersRepository.getMarketplacePeople(orgId, userId, body);
}
getPersonal(userId: string) {
return this._usersRepository.getPersonal(userId);
}

View File

@ -1,17 +1,10 @@
import { Injectable } from '@nestjs/common';
import { WebhooksRepository } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.repository';
import { WebhooksDto } from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto';
import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service';
import { BullMqClient } from '@gitroom/nestjs-libraries/bull-mq-transport-new/client';
import { PostsRepository } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.repository';
@Injectable()
export class WebhooksService {
constructor(
private _webhooksRepository: WebhooksRepository,
private _postsRepository: PostsRepository,
private _workerServiceProducer: BullMqClient
) {}
constructor(private _webhooksRepository: WebhooksRepository) {}
getTotal(orgId: string) {
return this._webhooksRepository.getTotal(orgId);
@ -28,73 +21,4 @@ export class WebhooksService {
deleteWebhook(orgId: string, id: string) {
return this._webhooksRepository.deleteWebhook(orgId, id);
}
async digestWebhooks(orgId: string, since: string) {
const date = new Date().toISOString();
await ioRedis.watch('webhook_' + orgId);
const value = await ioRedis.get('webhook_' + orgId);
if (value) {
return;
}
await ioRedis
.multi()
.set('webhook_' + orgId, date)
.expire('webhook_' + orgId, 60)
.exec();
this._workerServiceProducer.emit('webhooks', {
id: 'digest_' + orgId,
options: {
delay: 60000,
},
payload: {
org: orgId,
since,
},
});
}
async fireWebhooks(orgId: string, since: string) {
const list = await this._postsRepository.getPostsSince(orgId, since);
const webhooks = await this._webhooksRepository.getWebhooks(orgId);
const sendList = [];
for (const webhook of webhooks) {
const toSend = [];
if (webhook.integrations.length === 0) {
toSend.push(...list);
} else {
toSend.push(
...list.filter((post) =>
webhook.integrations.some(
(i) => i.integration.id === post.integration.id
)
)
);
}
if (toSend.length) {
sendList.push({
url: webhook.url,
data: toSend,
});
}
}
return Promise.all(
sendList.map(async (s) => {
try {
await fetch(s.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(s.data),
});
} catch (e) {
/**empty**/
}
})
);
}
}

View File

@ -1,11 +0,0 @@
import { IsBoolean, IsIn, IsString } from 'class-validator';
import { allTagsOptions } from '@gitroom/nestjs-libraries/database/prisma/marketplace/tags.list';
export class AddRemoveItemDto {
@IsString()
@IsIn(allTagsOptions.map((p) => p.key))
key: string;
@IsBoolean()
state: boolean;
}

View File

@ -1,8 +0,0 @@
import { IsNumber, Max, Min } from 'class-validator';
export class AudienceDto {
@IsNumber()
@Max(99999999)
@Min(1)
audience: number;
}

View File

@ -1,6 +0,0 @@
import { IsBoolean } from 'class-validator';
export class ChangeActiveDto {
@IsBoolean()
active: boolean;
}

View File

@ -1,27 +0,0 @@
import {
ArrayMinSize,
IsNumber,
IsString,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class SocialMedia {
@IsNumber()
total: number;
@IsString()
value: string;
@IsNumber()
price: number;
}
export class CreateOfferDto {
@IsString()
group: string;
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => SocialMedia)
socialMedia: SocialMedia[];
}

View File

@ -1,12 +0,0 @@
import { IsArray, IsIn, IsNumber, Min } from 'class-validator';
import { allTagsOptions } from '@gitroom/nestjs-libraries/database/prisma/marketplace/tags.list';
export class ItemsDto {
@IsArray()
@IsIn(allTagsOptions.map((p) => p.key), { each: true })
items: string[];
@IsNumber()
@Min(1)
page: number;
}

View File

@ -1,10 +0,0 @@
import { IsString, MinLength } from 'class-validator';
export class NewConversationDto {
@IsString()
to: string;
@IsString()
@MinLength(50)
message: string;
}

View File

@ -1,7 +0,0 @@
import { IsString, MinLength } from 'class-validator';
export class AddMessageDto {
@IsString()
@MinLength(3)
message: string;
}

View File

@ -29,8 +29,9 @@ import { VkProvider } from '@gitroom/nestjs-libraries/integrations/social/vk.pro
import { WordpressProvider } from '@gitroom/nestjs-libraries/integrations/social/wordpress.provider';
import { ListmonkProvider } from '@gitroom/nestjs-libraries/integrations/social/listmonk.provider';
import { GmbProvider } from '@gitroom/nestjs-libraries/integrations/social/gmb.provider';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
export const socialIntegrationList: SocialProvider[] = [
export const socialIntegrationList: Array<SocialAbstract & SocialProvider> = [
new XProvider(),
new LinkedinProvider(),
new LinkedinPageProvider(),

View File

@ -1,5 +1,4 @@
import { timer } from '@gitroom/helpers/utils/timer';
import { concurrency } from '@gitroom/helpers/utils/concurrency.service';
import { Integration } from '@prisma/client';
import { ApplicationFailure } from '@temporalio/activity';
@ -59,19 +58,13 @@ export abstract class SocialAbstract {
func: (...args: any[]) => Promise<T>,
ignoreConcurrency?: boolean
) {
const value = await concurrency<any>(
this.identifier,
this.maxConcurrentJob,
async () => {
try {
return await func();
} catch (err) {
const handle = this.handleErrors(JSON.stringify(err));
return { err: true, ...(handle || {}) };
}
},
ignoreConcurrency
);
let value: any;
try {
value = await func();
} catch (err) {
const handle = this.handleErrors(JSON.stringify(err));
value = { err: true, ...(handle || {}) };
}
if (value && value?.err && value?.value) {
throw new BadBody('', JSON.stringify({}), {} as any, value.value || '');
@ -87,12 +80,7 @@ export abstract class SocialAbstract {
totalRetries = 0,
ignoreConcurrency = false
): Promise<Response> {
const request = await concurrency(
this.identifier,
this.maxConcurrentJob,
() => fetch(url, options),
ignoreConcurrency
);
const request = await fetch(url, options);
if (request.status === 200 || request.status === 201) {
return request;
@ -142,10 +130,20 @@ export abstract class SocialAbstract {
request.status === 401 &&
(handleError?.type === 'refresh-token' || !handleError)
) {
throw new RefreshToken(identifier, json, options.body!, handleError?.value);
throw new RefreshToken(
identifier,
json,
options.body!,
handleError?.value
);
}
throw new BadBody(identifier, json, options.body!, handleError?.value || '');
throw new BadBody(
identifier,
json,
options.body!,
handleError?.value || ''
);
}
checkScopes(required: string[], got: string | string[]) {

View File

@ -239,12 +239,7 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
}
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
private async getAgent(integration: Integration) {
const body = JSON.parse(
AuthService.fixedDecryption(integration.customInstanceDetails!)
);
@ -261,139 +256,154 @@ export class BlueskyProvider extends SocialAbstract implements SocialProvider {
throw new RefreshToken('bluesky', JSON.stringify(err), {} as BodyInit);
}
let loadCid = '';
let loadUri = '';
let replyCid = '';
let replyUri = '';
const cidUrl = [] as { cid: string; url: string; rev: string }[];
for (const post of postDetails) {
// Separate images and videos
const imageMedia =
post.media?.filter((p) => p.path.indexOf('mp4') === -1) || [];
const videoMedia =
post.media?.filter((p) => p.path.indexOf('mp4') !== -1) || [];
return agent;
}
// Upload images
const images = await Promise.all(
imageMedia.map(async (p) => {
const { buffer, width, height } = await reduceImageBySize(p.path);
return {
width,
height,
buffer: await agent.uploadBlob(new Blob([buffer])),
};
})
);
private async uploadMediaForPost(
agent: BskyAgent,
post: PostDetails
): Promise<{ embed: any; images: any[] }> {
// Separate images and videos
const imageMedia =
post.media?.filter((p) => p.path.indexOf('mp4') === -1) || [];
const videoMedia =
post.media?.filter((p) => p.path.indexOf('mp4') !== -1) || [];
// Upload videos (only one video per post is supported by Bluesky)
let videoEmbed: AppBskyEmbedVideo.Main | null = null;
if (videoMedia.length > 0) {
videoEmbed = await uploadVideo(agent, videoMedia[0].path);
}
const rt = new RichText({
text: post.message,
});
await rt.detectFacets(agent);
// Determine embed based on media types
let embed: any = {};
if (videoEmbed) {
// If there's a video, use video embed (Bluesky supports only one video per post)
embed = videoEmbed;
} else if (images.length > 0) {
// If there are images but no video, use image embed
embed = {
$type: 'app.bsky.embed.images',
images: images.map((p, index) => ({
alt: imageMedia?.[index]?.alt || '',
image: p.buffer.data.blob,
aspectRatio: {
width: p.width,
height: p.height,
},
})),
// Upload images
const images = await Promise.all(
imageMedia.map(async (p) => {
const { buffer, width, height } = await reduceImageBySize(p.path);
return {
width,
height,
buffer: await agent.uploadBlob(new Blob([buffer])),
};
}
})
);
// @ts-ignore
const { cid, uri, commit } = await agent.post({
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
...(Object.keys(embed).length > 0 ? { embed } : {}),
...(loadCid
? {
reply: {
root: {
uri: replyUri,
cid: replyCid,
},
parent: {
uri: loadUri,
cid: loadCid,
},
},
}
: {}),
});
loadCid = loadCid || cid;
loadUri = loadUri || uri;
replyCid = cid;
replyUri = uri;
cidUrl.push({ cid, url: uri, rev: commit.rev });
// Upload videos (only one video per post is supported by Bluesky)
let videoEmbed: AppBskyEmbedVideo.Main | null = null;
if (videoMedia.length > 0) {
videoEmbed = await uploadVideo(agent, videoMedia[0].path);
}
if (postDetails?.[0]?.settings?.active_thread_finisher) {
const rt = new RichText({
text: stripHtmlValidation(
'normal',
postDetails?.[0]?.settings?.thread_finisher,
true
),
});
await rt.detectFacets(agent);
await agent.post({
text: stripHtmlValidation('normal', rt.text, true),
facets: rt.facets,
createdAt: new Date().toISOString(),
embed: {
$type: 'app.bsky.embed.record',
record: {
uri: cidUrl[0].url,
cid: cidUrl[0].cid,
// Determine embed based on media types
let embed: any = {};
if (videoEmbed) {
embed = videoEmbed;
} else if (images.length > 0) {
embed = {
$type: 'app.bsky.embed.images',
images: images.map((p, index) => ({
alt: imageMedia?.[index]?.alt || '',
image: p.buffer.data.blob,
aspectRatio: {
width: p.width,
height: p.height,
},
},
...(loadCid
? {
reply: {
root: {
uri: loadUri,
cid: loadCid,
},
parent: {
uri: loadUri,
cid: loadCid,
},
},
}
: {}),
});
})),
};
}
return postDetails.map((p, index) => ({
id: p.id,
postId: cidUrl[index].url,
status: 'completed',
releaseURL: `https://bsky.app/profile/${id}/post/${cidUrl[index].url
.split('/')
.pop()}`,
}));
return { embed, images };
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
const agent = await this.getAgent(integration);
const [firstPost] = postDetails;
const { embed } = await this.uploadMediaForPost(agent, firstPost);
const rt = new RichText({
text: firstPost.message,
});
await rt.detectFacets(agent);
// @ts-ignore
const { cid, uri, commit } = await agent.post({
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
...(Object.keys(embed).length > 0 ? { embed } : {}),
});
return [
{
id: firstPost.id,
postId: uri,
status: 'completed',
releaseURL: `https://bsky.app/profile/${id}/post/${uri.split('/').pop()}`,
},
];
}
async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
const agent = await this.getAgent(integration);
const [commentPost] = postDetails;
const { embed } = await this.uploadMediaForPost(agent, commentPost);
const rt = new RichText({
text: commentPost.message,
});
await rt.detectFacets(agent);
// Get the parent post info to get its CID
const parentUri = lastCommentId || postId;
// Fetch the parent post to get its CID
const parentThread = await agent.getPostThread({
uri: parentUri,
depth: 0,
});
// @ts-ignore
const parentCid = parentThread.data.thread.post?.cid;
// @ts-ignore
const rootUri = parentThread.data.thread.post?.record?.reply?.root?.uri || postId;
// @ts-ignore
const rootCid = parentThread.data.thread.post?.record?.reply?.root?.cid || parentCid;
// @ts-ignore
const { cid, uri, commit } = await agent.post({
text: rt.text,
facets: rt.facets,
createdAt: new Date().toISOString(),
...(Object.keys(embed).length > 0 ? { embed } : {}),
reply: {
root: {
uri: rootUri,
cid: rootCid,
},
parent: {
uri: parentUri,
cid: parentCid,
},
},
});
return [
{
id: commentPost.id,
postId: uri,
status: 'completed',
releaseURL: `https://bsky.app/profile/${id}/post/${uri.split('/').pop()}`,
},
];
}
@Plug({

View File

@ -140,11 +140,76 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
accessToken: string,
postDetails: PostDetails[]
): Promise<PostResponse[]> {
let channel = postDetails[0].settings.channel;
if (postDetails.length > 1) {
const [firstPost] = postDetails;
const channel = firstPost.settings.channel;
const form = new FormData();
form.append(
'payload_json',
JSON.stringify({
content: firstPost.message.replace(/\[\[\[(@.*?)]]]/g, (match, p1) => {
return `<${p1}>`;
}),
attachments: firstPost.media?.map((p, index) => ({
id: index,
description: `Picture ${index}`,
filename: p.path.split('/').pop(),
})),
})
);
let index = 0;
for (const media of firstPost.media || []) {
const loadMedia = await fetch(media.path);
form.append(
`files[${index}]`,
await loadMedia.blob(),
media.path.split('/').pop()
);
index++;
}
const data = await (
await fetch(`https://discord.com/api/channels/${channel}/messages`, {
method: 'POST',
headers: {
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
},
body: form,
})
).json();
return [
{
id: firstPost.id,
releaseURL: `https://discord.com/channels/${id}/${channel}/${data.id}`,
postId: data.id,
status: 'success',
},
];
}
async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
const [commentPost] = postDetails;
const channel = commentPost.settings.channel;
// For Discord, we create a thread from the original message for comments
// If we don't have a thread yet, create one
let threadChannel = channel;
// Create thread if this is the first comment
if (!lastCommentId) {
const { id: threadId } = await (
await fetch(
`https://discord.com/api/channels/${postDetails[0].settings.channel}/threads`,
`https://discord.com/api/channels/${channel}/messages/${postId}/threads`,
{
method: 'POST',
headers: {
@ -152,64 +217,66 @@ export class DiscordProvider extends SocialAbstract implements SocialProvider {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: postDetails[0].message,
name: 'Thread',
auto_archive_duration: 1440,
type: 11, // Public thread type
}),
}
)
).json();
channel = threadId;
threadChannel = threadId;
} else {
// Extract thread channel from the last comment's URL or use channel directly
threadChannel = channel;
}
const finalData = [];
for (const post of postDetails) {
const form = new FormData();
const form = new FormData();
form.append(
'payload_json',
JSON.stringify({
content: commentPost.message.replace(/\[\[\[(@.*?)]]]/g, (match, p1) => {
return `<${p1}>`;
}),
attachments: commentPost.media?.map((p, index) => ({
id: index,
description: `Picture ${index}`,
filename: p.path.split('/').pop(),
})),
})
);
let index = 0;
for (const media of commentPost.media || []) {
const loadMedia = await fetch(media.path);
form.append(
'payload_json',
JSON.stringify({
content: post.message.replace(/\[\[\[(@.*?)]]]/g, (match, p1) => {
return `<${p1}>`;
}),
attachments: post.media?.map((p, index) => ({
id: index,
description: `Picture ${index}`,
filename: p.path.split('/').pop(),
})),
})
`files[${index}]`,
await loadMedia.blob(),
media.path.split('/').pop()
);
index++;
}
let index = 0;
for (const media of post.media || []) {
const loadMedia = await fetch(media.path);
form.append(
`files[${index}]`,
await loadMedia.blob(),
media.path.split('/').pop()
);
index++;
}
const data = await (
await fetch(`https://discord.com/api/channels/${channel}/messages`, {
const data = await (
await fetch(
`https://discord.com/api/channels/${threadChannel}/messages`,
{
method: 'POST',
headers: {
Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`,
},
body: form,
})
).json();
}
)
).json();
finalData.push({
id: post.id,
releaseURL: `https://discord.com/channels/${id}/${channel}/${data.id}`,
return [
{
id: commentPost.id,
releaseURL: `https://discord.com/channels/${id}/${threadChannel}/${data.id}`,
postId: data.id,
status: 'success',
});
}
return finalData;
},
];
}
async changeNickname(id: string, accessToken: string, name: string) {

View File

@ -10,6 +10,7 @@ import dayjs from 'dayjs';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
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';
export class FacebookProvider extends SocialAbstract implements SocialProvider {
identifier = 'facebook';
@ -293,7 +294,7 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
accessToken: string,
postDetails: PostDetails<FacebookDto>[]
): Promise<PostResponse[]> {
const [firstPost, ...comments] = postDetails;
const [firstPost] = postDetails;
let finalId = '';
let finalUrl = '';
@ -377,36 +378,6 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
finalId = postId;
}
const postsArray = [];
let commentId = finalId;
for (const comment of comments) {
const data = await (
await this.fetch(
`https://graph.facebook.com/v20.0/${commentId}/comments?access_token=${accessToken}&fields=id,permalink_url`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...(comment.media?.length
? { attachment_url: comment.media[0].path }
: {}),
message: comment.message,
}),
},
'add comment'
)
).json();
commentId = data.id;
postsArray.push({
id: comment.id,
postId: data.id,
releaseURL: data.permalink_url,
status: 'success',
});
}
return [
{
id: firstPost.id,
@ -414,7 +385,46 @@ export class FacebookProvider extends SocialAbstract implements SocialProvider {
releaseURL: finalUrl,
status: 'success',
},
...postsArray,
];
}
async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails<FacebookDto>[],
integration: Integration
): Promise<PostResponse[]> {
const [commentPost] = postDetails;
const replyToId = lastCommentId || postId;
const data = await (
await this.fetch(
`https://graph.facebook.com/v20.0/${replyToId}/comments?access_token=${accessToken}&fields=id,permalink_url`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...(commentPost.media?.length
? { attachment_url: commentPost.media[0].path }
: {}),
message: commentPost.message,
}),
},
'add comment'
)
).json();
return [
{
id: commentPost.id,
postId: data.id,
releaseURL: data.permalink_url,
status: 'success',
},
];
}

View File

@ -454,7 +454,7 @@ export class InstagramProvider
integration: Integration,
type = 'graph.facebook.com'
): Promise<PostResponse[]> {
const [firstPost, ...theRest] = postDetails;
const [firstPost] = postDetails;
console.log('in progress', id);
const isStory = firstPost.settings.post_type === 'story';
const medias = await Promise.all(
@ -521,10 +521,6 @@ export class InstagramProvider
}) || []
);
const arr = [];
let containerIdGlobal = '';
let linkGlobal = '';
if (medias.length === 1) {
const { id: mediaId } = await (
await this.fetch(
@ -535,22 +531,20 @@ export class InstagramProvider
)
).json();
containerIdGlobal = mediaId;
const { permalink } = await (
await this.fetch(
`https://${type}/v20.0/${mediaId}?fields=permalink&access_token=${accessToken}`
)
).json();
arr.push({
id: firstPost.id,
postId: mediaId,
releaseURL: permalink,
status: 'success',
});
linkGlobal = permalink;
return [
{
id: firstPost.id,
postId: mediaId,
releaseURL: permalink,
status: 'success',
},
];
} else {
const { id: containerId, ...all3 } = await (
await this.fetch(
@ -589,45 +583,60 @@ export class InstagramProvider
)
).json();
containerIdGlobal = mediaId;
const { permalink } = await (
await this.fetch(
`https://${type}/v20.0/${mediaId}?fields=permalink&access_token=${accessToken}`
)
).json();
arr.push({
id: firstPost.id,
postId: mediaId,
return [
{
id: firstPost.id,
postId: mediaId,
releaseURL: permalink,
status: 'success',
},
];
}
}
async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails<InstagramDto>[],
integration: Integration,
type = 'graph.facebook.com'
): Promise<PostResponse[]> {
const [commentPost] = postDetails;
const { id: commentId } = await (
await this.fetch(
`https://${type}/v20.0/${postId}/comments?message=${encodeURIComponent(
commentPost.message
)}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
// Get the permalink from the parent post
const { permalink } = await (
await this.fetch(
`https://${type}/v20.0/${postId}?fields=permalink&access_token=${accessToken}`
)
).json();
return [
{
id: commentPost.id,
postId: commentId,
releaseURL: permalink,
status: 'success',
});
linkGlobal = permalink;
}
for (const post of theRest) {
const { id: commentId } = await (
await this.fetch(
`https://${type}/v20.0/${containerIdGlobal}/comments?message=${encodeURIComponent(
post.message
)}&access_token=${accessToken}`,
{
method: 'POST',
}
)
).json();
arr.push({
id: post.id,
postId: commentId,
releaseURL: linkGlobal,
status: 'success',
});
}
return arr;
},
];
}
private setTitle(name: string) {

View File

@ -165,6 +165,25 @@ export class InstagramStandaloneProvider
);
}
async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails<InstagramDto>[],
integration: Integration
): Promise<PostResponse[]> {
return instagramProvider.comment(
id,
postId,
lastCommentId,
accessToken,
postDetails,
integration,
'graph.instagram.com'
);
}
async analytics(id: string, accessToken: string, date: number) {
return instagramProvider.analytics(
id,

View File

@ -258,6 +258,25 @@ export class LinkedinPageProvider
return super.post(id, accessToken, postDetails, integration, 'company');
}
override async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
return super.comment(
id,
postId,
lastCommentId,
accessToken,
postDetails,
integration,
'company'
);
}
async analytics(
id: string,
accessToken: string,

View File

@ -652,11 +652,11 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
);
}
const [processedFirstPost, ...restPosts] = processedPostDetails;
const [processedFirstPost] = processedPostDetails;
// Process and upload media for all posts
// Process and upload media for the first post only
const uploadedMedia = await this.processMediaForPosts(
processedPostDetails,
[processedFirstPost],
accessToken,
id,
type
@ -677,25 +677,30 @@ export class LinkedinProvider extends SocialAbstract implements SocialProvider {
!!firstPost.settings?.post_as_images_carousel
);
// Build response array starting with main post
const responses: PostResponse[] = [
this.createPostResponse(mainPostId, processedFirstPost.id, true),
];
// Return response for main post only
return [this.createPostResponse(mainPostId, processedFirstPost.id, true)];
}
// Create comment posts for remaining posts
for (const post of restPosts) {
const commentPostId = await this.createCommentPost(
id,
accessToken,
post,
mainPostId,
type
);
async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails<LinkedinDto>[],
integration: Integration,
type = 'personal' as 'company' | 'personal'
): Promise<PostResponse[]> {
const [commentPost] = postDetails;
responses.push(this.createPostResponse(commentPostId, post.id, false));
}
const commentPostId = await this.createCommentPost(
id,
accessToken,
commentPost,
postId,
type
);
return responses;
return [this.createPostResponse(commentPostId, commentPost.id, false)];
}
@PostPlug({

View File

@ -5,6 +5,7 @@ import {
} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface';
import { MastodonProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.provider';
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { Integration } from '@prisma/client';
export class MastodonCustomProvider extends MastodonProvider {
override identifier = 'mastodon-custom';
@ -81,4 +82,22 @@ export class MastodonCustomProvider extends MastodonProvider {
postDetails
);
}
override async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
return this.dynamicComment(
id,
postId,
lastCommentId,
accessToken,
process.env.MASTODON_URL || 'https://mastodon.social',
postDetails
);
}
}

View File

@ -7,6 +7,7 @@ import {
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract';
import dayjs from 'dayjs';
import { Integration } from '@prisma/client';
export class MastodonProvider extends SocialAbstract implements SocialProvider {
override maxConcurrentJob = 5; // Mastodon instances typically have generous limits
@ -133,47 +134,88 @@ export class MastodonProvider extends SocialAbstract implements SocialProvider {
url: string,
postDetails: PostDetails[]
): Promise<PostResponse[]> {
let loadId = '';
const ids = [] as string[];
for (const getPost of postDetails) {
const uploadFiles = await Promise.all(
getPost?.media?.map((media) =>
this.uploadFile(url, media.path, accessToken)
) || []
);
const [firstPost] = postDetails;
const form = new FormData();
form.append('status', getPost.message);
form.append('visibility', 'public');
if (loadId) {
form.append('in_reply_to_id', loadId);
const uploadFiles = await Promise.all(
firstPost?.media?.map((media) =>
this.uploadFile(url, media.path, accessToken)
) || []
);
const form = new FormData();
form.append('status', firstPost.message);
form.append('visibility', 'public');
if (uploadFiles.length) {
for (const file of uploadFiles) {
form.append('media_ids[]', file);
}
if (uploadFiles.length) {
for (const file of uploadFiles) {
form.append('media_ids[]', file);
}
}
const post = await (
await this.fetch(`${url}/api/v1/statuses`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: form,
})
).json();
ids.push(post.id);
loadId = loadId || post.id;
}
return postDetails.map((p, i) => ({
id: p.id,
postId: ids[i],
releaseURL: `${url}/statuses/${ids[i]}`,
status: 'completed',
}));
const post = await (
await this.fetch(`${url}/api/v1/statuses`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: form,
})
).json();
return [
{
id: firstPost.id,
postId: post.id,
releaseURL: `${url}/statuses/${post.id}`,
status: 'completed',
},
];
}
async dynamicComment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
url: string,
postDetails: PostDetails[]
): Promise<PostResponse[]> {
const [commentPost] = postDetails;
const replyToId = lastCommentId || postId;
const uploadFiles = await Promise.all(
commentPost?.media?.map((media) =>
this.uploadFile(url, media.path, accessToken)
) || []
);
const form = new FormData();
form.append('status', commentPost.message);
form.append('visibility', 'public');
form.append('in_reply_to_id', replyToId);
if (uploadFiles.length) {
for (const file of uploadFiles) {
form.append('media_ids[]', file);
}
}
const post = await (
await this.fetch(`${url}/api/v1/statuses`, {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
},
body: form,
})
).json();
return [
{
id: commentPost.id,
postId: post.id,
releaseURL: `${url}/statuses/${post.id}`,
status: 'completed',
},
];
}
async post(
@ -188,4 +230,22 @@ export class MastodonProvider extends SocialAbstract implements SocialProvider {
postDetails
);
}
async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails[],
integration: Integration
): Promise<PostResponse[]> {
return this.dynamicComment(
id,
postId,
lastCommentId,
accessToken,
process.env.MASTODON_URL || 'https://mastodon.social',
postDetails
);
}
}

View File

@ -13,6 +13,7 @@ import { lookup } from 'mime-types';
import axios from 'axios';
import WebSocket from 'ws';
import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator';
import { Integration } from '@prisma/client';
// @ts-ignore
global.WebSocket = WebSocket;
@ -183,7 +184,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
accessToken: string,
postDetails: PostDetails<RedditSettingsDto>[]
): Promise<PostResponse[]> {
const [post, ...rest] = postDetails;
const [post] = postDetails;
const valueArray: PostResponse[] = [];
for (const firstPostSettings of post.settings.subreddit) {
@ -235,7 +236,7 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
})
).json();
const { id, name, url } = await new Promise<{
const { id: redditId, name, url } = await new Promise<{
id: string;
name: string;
url: string;
@ -268,50 +269,12 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
});
valueArray.push({
postId: id,
postId: redditId,
releaseURL: url,
id: post.id,
status: 'published',
});
for (const comment of rest) {
const {
json: {
data: {
things: [
{
data: { id: commentId, permalink },
},
],
},
},
} = await (
await this.fetch('https://oauth.reddit.com/api/comment', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
text: comment.message,
thing_id: name,
api_type: 'json',
}),
})
).json();
valueArray.push({
postId: commentId,
releaseURL: 'https://www.reddit.com' + permalink,
id: comment.id,
status: 'published',
});
if (rest.length > 1) {
await timer(5000);
}
}
if (post.settings.subreddit.length > 1) {
await timer(5000);
}
@ -325,6 +288,54 @@ export class RedditProvider extends SocialAbstract implements SocialProvider {
}));
}
async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails<RedditSettingsDto>[],
integration: Integration
): Promise<PostResponse[]> {
const [commentPost] = postDetails;
// Reddit uses thing_id format like t3_xxx for posts
const thingId = postId.startsWith('t3_') ? postId : `t3_${postId}`;
const {
json: {
data: {
things: [
{
data: { id: commentId, permalink },
},
],
},
},
} = await (
await this.fetch('https://oauth.reddit.com/api/comment', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
text: commentPost.message,
thing_id: thingId,
api_type: 'json',
}),
})
).json();
return [
{
postId: commentId,
releaseURL: 'https://www.reddit.com' + permalink,
id: commentPost.id,
status: 'published',
},
];
}
@Tool({
description: 'Get list of subreddits with information',
dataSchema: [

View File

@ -342,7 +342,7 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
return [];
}
const [firstPost, ...replies] = postDetails;
const [firstPost] = postDetails;
// Create the initial thread
const initialContentId = await this.createThreadContent(
@ -358,8 +358,8 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
initialContentId
);
// Track the responses
const responses: PostResponse[] = [
// Return the main post response
return [
{
id: firstPost.id,
postId: threadId,
@ -367,60 +367,49 @@ export class ThreadsProvider extends SocialAbstract implements SocialProvider {
releaseURL: permalink,
},
];
}
// Handle replies if any
let lastReplyId = threadId;
async comment(
userId: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails<{
active_thread_finisher: boolean;
thread_finisher: string;
}>[],
integration: Integration
): Promise<PostResponse[]> {
if (!postDetails.length) {
return [];
}
for (const reply of replies) {
// Create reply content
const replyContentId = await this.createThreadContent(
userId,
accessToken,
reply,
lastReplyId
);
const [commentPost] = postDetails;
const replyToId = lastCommentId || postId;
// Publish the reply
const { threadId: replyThreadId } = await this.publishThread(
userId,
accessToken,
replyContentId
);
// Create reply content
const replyContentId = await this.createThreadContent(
userId,
accessToken,
commentPost,
replyToId
);
// Update the last reply ID for chaining
lastReplyId = replyThreadId;
// Publish the reply
const { threadId: replyThreadId, permalink } = await this.publishThread(
userId,
accessToken,
replyContentId
);
// Add to responses
responses.push({
id: reply.id,
postId: threadId, // Main thread ID
return [
{
id: commentPost.id,
postId: replyThreadId,
status: 'success',
releaseURL: permalink, // Main thread URL
});
}
if (postDetails?.[0]?.settings?.active_thread_finisher) {
try {
const replyContentId = await this.createThreadContent(
userId,
accessToken,
{
id: makeId(10),
media: [],
message: postDetails?.[0]?.settings?.thread_finisher,
settings: {},
},
lastReplyId,
threadId
);
await this.publishThread(userId, accessToken, replyContentId);
} catch (err) {
console.log(err);
}
}
return responses;
releaseURL: permalink,
},
];
}
async analytics(

View File

@ -291,38 +291,21 @@ export class XProvider extends SocialAbstract implements SocialProvider {
};
}
async post(
id: string,
accessToken: string,
postDetails: PostDetails<{
active_thread_finisher: boolean;
thread_finisher: string;
community?: string;
who_can_reply_post:
| 'everyone'
| 'following'
| 'mentionedUsers'
| 'subscribers'
| 'verified';
}>[]
): Promise<PostResponse[]> {
private async getClient(accessToken: string) {
const [accessTokenSplit, accessSecretSplit] = accessToken.split(':');
const client = new TwitterApi({
return new TwitterApi({
appKey: process.env.X_API_KEY!,
appSecret: process.env.X_API_SECRET!,
accessToken: accessTokenSplit,
accessSecret: accessSecretSplit,
});
const {
data: { username },
} = await this.runInConcurrent(async () =>
client.v2.me({
'user.fields': 'username',
})
);
}
// upload everything before, you don't want it to fail between the posts
const uploadAll = (
private async uploadMedia(
client: TwitterApi,
postDetails: PostDetails<any>[]
) {
return (
await Promise.all(
postDetails.flatMap((p) =>
p?.media?.flatMap(async (m) => {
@ -361,67 +344,119 @@ export class XProvider extends SocialAbstract implements SocialProvider {
return acc;
}, {} as Record<string, string[]>);
}
const ids: Array<{ postId: string; id: string; releaseURL: string }> = [];
for (const post of postDetails) {
const media_ids = (uploadAll[post.id] || []).filter((f) => f);
async post(
id: string,
accessToken: string,
postDetails: PostDetails<{
active_thread_finisher: boolean;
thread_finisher: string;
community?: string;
who_can_reply_post:
| 'everyone'
| 'following'
| 'mentionedUsers'
| 'subscribers'
| 'verified';
}>[]
): Promise<PostResponse[]> {
const client = await this.getClient(accessToken);
const {
data: { username },
} = await this.runInConcurrent(async () =>
client.v2.me({
'user.fields': 'username',
})
);
// @ts-ignore
const { data }: { data: { id: string } } = await this.runInConcurrent(
async () =>
// @ts-ignore
client.v2.tweet({
...(!postDetails?.[0]?.settings?.who_can_reply_post ||
postDetails?.[0]?.settings?.who_can_reply_post === 'everyone'
? {}
: {
reply_settings:
postDetails?.[0]?.settings?.who_can_reply_post,
}),
...(postDetails?.[0]?.settings?.community
? {
community_id:
postDetails?.[0]?.settings?.community?.split('/').pop() ||
'',
}
: {}),
text: post.message,
...(media_ids.length ? { media: { media_ids } } : {}),
...(ids.length
? { reply: { in_reply_to_tweet_id: ids[ids.length - 1].postId } }
: {}),
})
);
const [firstPost] = postDetails;
ids.push({
// upload media for the first post
const uploadAll = await this.uploadMedia(client, [firstPost]);
const media_ids = (uploadAll[firstPost.id] || []).filter((f) => f);
// @ts-ignore
const { data }: { data: { id: string } } = await this.runInConcurrent(
async () =>
// @ts-ignore
client.v2.tweet({
...(!firstPost?.settings?.who_can_reply_post ||
firstPost?.settings?.who_can_reply_post === 'everyone'
? {}
: {
reply_settings: firstPost?.settings?.who_can_reply_post,
}),
...(firstPost?.settings?.community
? {
community_id:
firstPost?.settings?.community?.split('/').pop() || '',
}
: {}),
text: firstPost.message,
...(media_ids.length ? { media: { media_ids } } : {}),
})
);
return [
{
postId: data.id,
id: post.id,
id: firstPost.id,
releaseURL: `https://twitter.com/${username}/status/${data.id}`,
});
}
status: 'posted',
},
];
}
if (postDetails?.[0]?.settings?.active_thread_finisher) {
try {
await this.runInConcurrent(async () =>
client.v2.tweet({
text:
stripHtmlValidation(
'normal',
postDetails?.[0]?.settings?.thread_finisher!,
true
) +
'\n' +
ids[0].releaseURL,
reply: { in_reply_to_tweet_id: ids[ids.length - 1].postId },
})
);
} catch (err) {}
}
async comment(
id: string,
postId: string,
lastCommentId: string | undefined,
accessToken: string,
postDetails: PostDetails<{
active_thread_finisher: boolean;
thread_finisher: string;
}>[],
integration: Integration
): Promise<PostResponse[]> {
const client = await this.getClient(accessToken);
const {
data: { username },
} = await this.runInConcurrent(async () =>
client.v2.me({
'user.fields': 'username',
})
);
return ids.map((p) => ({
...p,
status: 'posted',
}));
const [commentPost] = postDetails;
// upload media for the comment
const uploadAll = await this.uploadMedia(client, [commentPost]);
const media_ids = (uploadAll[commentPost.id] || []).filter((f) => f);
const replyToId = lastCommentId || postId;
// @ts-ignore
const { data }: { data: { id: string } } = await this.runInConcurrent(
async () =>
// @ts-ignore
client.v2.tweet({
text: commentPost.message,
...(media_ids.length ? { media: { media_ids } } : {}),
reply: { in_reply_to_tweet_id: replyToId },
})
);
return [
{
postId: data.id,
id: commentPost.id,
releaseURL: `https://twitter.com/${username}/status/${data.id}`,
status: 'posted',
},
];
}
private loadAllTweets = async (

View File

@ -3,7 +3,6 @@ import { EmailInterface } from '@gitroom/nestjs-libraries/emails/email.interface
import { ResendProvider } from '@gitroom/nestjs-libraries/emails/resend.provider';
import { EmptyProvider } from '@gitroom/nestjs-libraries/emails/empty.provider';
import { NodeMailerProvider } from '@gitroom/nestjs-libraries/emails/node.mailer.provider';
import { concurrency } from '@gitroom/helpers/utils/concurrency.service';
@Injectable()
export class EmailService {

View File

@ -6,7 +6,6 @@ import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/o
import { makeId } from '@gitroom/nestjs-libraries/services/make.is';
import { BillingSubscribeDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.subscribe.dto';
import { capitalize, groupBy } from 'lodash';
import { MessagesService } from '@gitroom/nestjs-libraries/database/prisma/marketplace/messages.service';
import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing';
import { AuthService } from '@gitroom/helpers/auth/auth.service';
import { TrackService } from '@gitroom/nestjs-libraries/track/track.service';
@ -29,21 +28,6 @@ export class StripeService {
return stripe.webhooks.constructEvent(rawBody, signature, endpointSecret);
}
async updateAccount(event: Stripe.AccountUpdatedEvent) {
if (!event.account) {
return;
}
const accountCharges =
event.data.object.payouts_enabled &&
event.data.object.charges_enabled &&
!event?.data?.object?.requirements?.disabled_reason;
await this._subscriptionService.updateConnectedStatus(
event.account!,
accountCharges
);
}
async checkValidCard(
event:
| Stripe.CustomerSubscriptionCreatedEvent
@ -473,62 +457,6 @@ export class StripeService {
return { url };
}
async createAccountProcess(userId: string, email: string, country: string) {
const account = await this._subscriptionService.getUserAccount(userId);
if (account?.account && account?.connectedAccount) {
return { url: await this.addBankAccount(account.account) };
}
if (account?.account && !account?.connectedAccount) {
await stripe.accounts.del(account.account);
}
const createAccount = await this.createAccount(userId, email, country);
return { url: await this.addBankAccount(createAccount) };
}
async createAccount(userId: string, email: string, country: string) {
const account = await stripe.accounts.create({
type: 'custom',
capabilities: {
transfers: {
requested: true,
},
card_payments: {
requested: true,
},
},
tos_acceptance: {
service_agreement: 'full',
},
metadata: {
service: 'gitroom',
},
country,
email,
});
await this._subscriptionService.updateAccount(userId, account.id);
return account.id;
}
async addBankAccount(userId: string) {
const accountLink = await stripe.accountLinks.create({
account: userId,
refresh_url: process.env['FRONTEND_URL'] + '/marketplace/seller',
return_url: process.env['FRONTEND_URL'] + '/marketplace/seller',
type: 'account_onboarding',
collection_options: {
fields: 'eventually_due',
},
});
return accountLink.url;
}
async finishTrial(paymentId: string) {
const list = (
await stripe.subscriptions.list({
@ -635,63 +563,6 @@ export class StripeService {
return 0;
}
async payAccountStepOne(
userId: string,
organization: Organization,
seller: User,
orderId: string,
ordersItems: Array<{
integrationType: string;
quantity: number;
price: number;
}>,
groupId: string
) {
const customer = (await this.createOrGetCustomer(organization))!;
const price = ordersItems.reduce((all, current) => {
return all + current.price * current.quantity;
}, 0);
const { url } = await stripe.checkout.sessions.create({
customer,
mode: 'payment',
currency: 'usd',
success_url: process.env['FRONTEND_URL'] + `/messages/${groupId}`,
metadata: {
orderId,
service: 'gitroom',
type: 'marketplace',
},
line_items: [
...ordersItems,
{
integrationType: `Gitroom Fee (${+process.env.FEE_AMOUNT! * 100}%)`,
quantity: 1,
price: price * +process.env.FEE_AMOUNT!,
},
].map((item) => ({
price_data: {
currency: 'usd',
product_data: {
// @ts-ignore
name:
(!item.price ? 'Platform: ' : '') +
capitalize(item.integrationType),
},
// @ts-ignore
unit_amount: item.price * 100,
},
quantity: item.quantity,
})),
payment_intent_data: {
transfer_group: orderId,
},
});
return { url };
}
async embedded(
uniqueId: string,
organizationId: string,
@ -883,21 +754,6 @@ export class StripeService {
return { ok: true };
}
async payout(
orderId: string,
charge: string,
account: string,
price: number
) {
return stripe.transfers.create({
amount: price * 100,
currency: 'usd',
destination: account,
source_transaction: charge,
transfer_group: orderId,
});
}
async lifetimeDeal(organizationId: string, code: string) {
const getCurrentSubscription =
await this._subscriptionService.getSubscriptionByOrganizationId(

View File

@ -1,31 +0,0 @@
import json from './trending';
import { Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
import { StarsService } from '@gitroom/nestjs-libraries/database/prisma/stars/stars.service';
import md5 from 'md5';
@Injectable()
export class TrendingService {
constructor(private _starsService: StarsService) {}
async syncTrending() {
for (const language of json) {
const data = await (
await fetch(`https://github.com/trending/${language.link}`)
).text();
const dom = new JSDOM(data);
const trending = Array.from(
dom.window.document.querySelectorAll('[class="Link"]')
);
const arr = trending.map((el, index) => {
return {
name: el?.textContent?.trim().replace(/\s/g, '') || '',
position: index + 1,
};
});
const hashedNames = md5(arr.map((p) => p.name).join(''));
console.log('Updating GitHub trending topic', language, hashedNames);
await this._starsService.updateTrending(language.name, hashedNames, arr);
}
}
}

View File

@ -1,217 +0,0 @@
export default [
{ link: '', name: '' },
{ link: '1c-enterprise', name: '1C Enterprise' },
{ link: 'abap', name: 'ABAP' },
{ link: 'actionscript', name: 'ActionScript' },
{ link: 'adblock-filter-list', name: 'Adblock Filter List' },
{ link: 'al', name: 'AL' },
{ link: 'angelscript', name: 'AngelScript' },
{ link: 'apacheconf', name: 'ApacheConf' },
{ link: 'apex', name: 'Apex' },
{ link: 'apl', name: 'APL' },
{ link: 'applescript', name: 'AppleScript' },
{ link: 'arc', name: 'Arc' },
{ link: 'asl', name: 'ASL' },
{ link: 'classic-asp', name: 'Classic ASP' },
{ link: 'assembly', name: 'Assembly' },
{ link: 'astro', name: 'Astro' },
{ link: 'autohotkey', name: 'AutoHotkey' },
{ link: 'autoit', name: 'AutoIt' },
{ link: 'awk', name: 'Awk' },
{ link: 'batchfile', name: 'Batchfile' },
{ link: 'bicep', name: 'Bicep' },
{ link: 'bikeshed', name: 'Bikeshed' },
{ link: 'bitbake', name: 'BitBake' },
{ link: 'blade', name: 'Blade' },
{ link: 'boo', name: 'Boo' },
{ link: 'brainfuck', name: 'Brainfuck' },
{ link: 'brighterscript', name: 'BrighterScript' },
{ link: 'c', name: 'C' },
{ link: 'c%23', name: 'C#' },
{ link: 'c++', name: 'C++' },
{ link: 'cairo', name: 'Cairo' },
{ link: "cap'n-proto", name: "Cap'n Proto" },
{ link: 'cartocss', name: 'CartoCSS' },
{ link: 'chapel', name: 'Chapel' },
{ link: 'circom', name: 'Circom' },
{ link: 'classic-asp', name: 'Classic ASP' },
{ link: 'clojure', name: 'Clojure' },
{ link: 'cmake', name: 'CMake' },
{ link: 'codeql', name: 'CodeQL' },
{ link: 'coffeescript', name: 'CoffeeScript' },
{ link: 'common-lisp', name: 'Common Lisp' },
{ link: 'component-pascal', name: 'Component Pascal' },
{ link: 'crystal', name: 'Crystal' },
{ link: 'css', name: 'CSS' },
{ link: 'cuda', name: 'Cuda' },
{ link: 'cue', name: 'CUE' },
{ link: 'cython', name: 'Cython' },
{ link: 'd', name: 'D' },
{ link: 'dart', name: 'Dart' },
{ link: 'denizenscript', name: 'DenizenScript' },
{ link: 'digital-command-language', name: 'DIGITAL Command Language' },
{ link: 'dm', name: 'DM' },
{ link: 'dockerfile', name: 'Dockerfile' },
{ link: 'earthly', name: 'Earthly' },
{ link: 'ejs', name: 'EJS' },
{ link: 'elixir', name: 'Elixir' },
{ link: 'elm', name: 'Elm' },
{ link: 'emacs-lisp', name: 'Emacs Lisp' },
{ link: 'emberscript', name: 'EmberScript' },
{ link: 'erlang', name: 'Erlang' },
{ link: 'f%23', name: 'F#' },
{ link: 'f*', name: 'F*' },
{ link: 'fennel', name: 'Fennel' },
{ link: 'fluent', name: 'Fluent' },
{ link: 'forth', name: 'Forth' },
{ link: 'fortran', name: 'Fortran' },
{ link: 'freemarker', name: 'FreeMarker' },
{ link: 'g-code', name: 'G-code' },
{ link: 'gdscript', name: 'GDScript' },
{ link: 'gherkin', name: 'Gherkin' },
{ link: 'gleam', name: 'Gleam' },
{ link: 'glsl', name: 'GLSL' },
{ link: 'go', name: 'Go' },
{ link: 'groovy', name: 'Groovy' },
{ link: 'hack', name: 'Hack' },
{ link: 'handlebars', name: 'Handlebars' },
{ link: 'haskell', name: 'Haskell' },
{ link: 'haxe', name: 'Haxe' },
{ link: 'hcl', name: 'HCL' },
{ link: 'hlsl', name: 'HLSL' },
{ link: 'holyc', name: 'HolyC' },
{ link: 'hoon', name: 'hoon' },
{ link: 'hosts-file', name: 'Hosts File' },
{ link: 'html', name: 'HTML' },
{ link: 'idris', name: 'Idris' },
{ link: 'inform-7', name: 'Inform 7' },
{ link: 'inno-setup', name: 'Inno Setup' },
{ link: 'io', name: 'Io' },
{ link: 'java', name: 'Java' },
{ link: 'javascript', name: 'JavaScript' },
{ link: 'json', name: 'JSON' },
{ link: 'jsonnet', name: 'Jsonnet' },
{ link: 'julia', name: 'Julia' },
{ link: 'jupyter-notebook', name: 'Jupyter Notebook' },
{ link: 'just', name: 'Just' },
{ link: 'kicad-layout', name: 'KiCad Layout' },
{ link: 'kotlin', name: 'Kotlin' },
{ link: 'labview', name: 'LabVIEW' },
{ link: 'lean', name: 'Lean' },
{ link: 'less', name: 'Less' },
{ link: 'lfe', name: 'LFE' },
{ link: 'liquid', name: 'Liquid' },
{ link: 'llvm', name: 'LLVM' },
{ link: 'logos', name: 'Logos' },
{ link: 'lookml', name: 'LookML' },
{ link: 'lua', name: 'Lua' },
{ link: 'm4', name: 'M4' },
{ link: 'makefile', name: 'Makefile' },
{ link: 'markdown', name: 'Markdown' },
{ link: 'mathematica', name: 'Mathematica' },
{ link: 'matlab', name: 'MATLAB' },
{ link: 'mcfunction', name: 'mcfunction' },
{ link: 'mdx', name: 'MDX' },
{ link: 'mermaid', name: 'Mermaid' },
{ link: 'meson', name: 'Meson' },
{ link: 'metal', name: 'Metal' },
{ link: 'mlir', name: 'MLIR' },
{ link: 'move', name: 'Move' },
{ link: 'mustache', name: 'Mustache' },
{ link: 'nasl', name: 'NASL' },
{ link: 'nesc', name: 'nesC' },
{ link: 'nextflow', name: 'Nextflow' },
{ link: 'nim', name: 'Nim' },
{ link: 'nix', name: 'Nix' },
{ link: 'nsis', name: 'NSIS' },
{ link: 'nunjucks', name: 'Nunjucks' },
{ link: 'objective-c', name: 'Objective-C' },
{ link: 'objective-c++', name: 'Objective-C++' },
{ link: 'ocaml', name: 'OCaml' },
{ link: 'odin', name: 'Odin' },
{ link: 'open-policy-agent', name: 'Open Policy Agent' },
{ link: 'openscad', name: 'OpenSCAD' },
{ link: 'papyrus', name: 'Papyrus' },
{ link: 'pascal', name: 'Pascal' },
{ link: 'perl', name: 'Perl' },
{ link: 'php', name: 'PHP' },
{ link: 'plpgsql', name: 'PLpgSQL' },
{ link: 'plsql', name: 'PLSQL' },
{ link: 'pony', name: 'Pony' },
{ link: 'postscript', name: 'PostScript' },
{ link: 'powershell', name: 'PowerShell' },
{ link: 'processing', name: 'Processing' },
{ link: 'prolog', name: 'Prolog' },
{ link: 'pug', name: 'Pug' },
{ link: 'puppet', name: 'Puppet' },
{ link: 'purebasic', name: 'PureBasic' },
{ link: 'purescript', name: 'PureScript' },
{ link: 'python', name: 'Python' },
{ link: 'qml', name: 'QML' },
{ link: 'r', name: 'R' },
{ link: 'racket', name: 'Racket' },
{ link: 'raku', name: 'Raku' },
{ link: 'raml', name: 'RAML' },
{ link: "ren'py", name: "Ren'Py" },
{ link: 'rescript', name: 'ReScript' },
{ link: 'restructuredtext', name: 'reStructuredText' },
{ link: 'rich-text-format', name: 'Rich Text Format' },
{ link: 'robotframework', name: 'RobotFramework' },
{ link: 'roff', name: 'Roff' },
{ link: 'routeros-script', name: 'RouterOS Script' },
{ link: 'rpm-spec', name: 'RPM Spec' },
{ link: 'ruby', name: 'Ruby' },
{ link: 'rust', name: 'Rust' },
{ link: 'sass', name: 'Sass' },
{ link: 'scala', name: 'Scala' },
{ link: 'scheme', name: 'Scheme' },
{ link: 'scss', name: 'SCSS' },
{ link: 'shaderlab', name: 'ShaderLab' },
{ link: 'shell', name: 'Shell' },
{ link: 'smali', name: 'Smali' },
{ link: 'smalltalk', name: 'Smalltalk' },
{ link: 'smarty', name: 'Smarty' },
{ link: 'solidity', name: 'Solidity' },
{ link: 'sqf', name: 'SQF' },
{ link: 'sql', name: 'SQL' },
{ link: 'squirrel', name: 'Squirrel' },
{ link: 'standard-ml', name: 'Standard ML' },
{ link: 'starlark', name: 'Starlark' },
{ link: 'stylus', name: 'Stylus' },
{ link: 'supercollider', name: 'SuperCollider' },
{ link: 'svelte', name: 'Svelte' },
{ link: 'svg', name: 'SVG' },
{ link: 'swift', name: 'Swift' },
{ link: 'swig', name: 'SWIG' },
{ link: 'systemverilog', name: 'SystemVerilog' },
{ link: 'tcl', name: 'Tcl' },
{ link: 'tex', name: 'TeX' },
{ link: 'text', name: 'Text' },
{ link: 'thrift', name: 'Thrift' },
{ link: 'tsql', name: 'TSQL' },
{ link: 'twig', name: 'Twig' },
{ link: 'typescript', name: 'TypeScript' },
{ link: 'typst', name: 'Typst' },
{ link: 'unrealscript', name: 'UnrealScript' },
{ link: 'v', name: 'V' },
{ link: 'vala', name: 'Vala' },
{ link: 'vbscript', name: 'VBScript' },
{ link: 'verilog', name: 'Verilog' },
{ link: 'vhdl', name: 'VHDL' },
{ link: 'vim-script', name: 'Vim Script' },
{ link: 'vim-snippet', name: 'Vim Snippet' },
{ link: 'visual-basic-.net', name: 'Visual Basic .NET' },
{ link: 'visual-basic-.net', name: 'Visual Basic .NET' },
{ link: 'vue', name: 'Vue' },
{ link: 'webassembly', name: 'WebAssembly' },
{ link: 'wgsl', name: 'WGSL' },
{ link: 'witcher-script', name: 'Witcher Script' },
{ link: 'xc', name: 'XC' },
{ link: 'xslt', name: 'XSLT' },
{ link: 'yacc', name: 'Yacc' },
{ link: 'yaml', name: 'YAML' },
{ link: 'yara', name: 'YARA' },
{ link: 'zap', name: 'ZAP' },
{ link: 'zenscript', name: 'ZenScript' },
{ link: 'zig', name: 'Zig' },
];

View File

@ -1,9 +1,10 @@
import { TemporalModule } from 'nestjs-temporal-core';
import { socialIntegrationList } from '@gitroom/nestjs-libraries/integrations/integration.manager';
export const getTemporalModule = (
isWorkers: boolean,
path?: string,
activityClasses?: any[],
activityClasses?: any[]
) => {
return TemporalModule.register({
isGlobal: true,
@ -12,16 +13,28 @@ export const getTemporalModule = (
namespace: process.env.TEMPORAL_NAMESPACE || 'default',
},
taskQueue: 'main',
logLevel: 'error',
...(isWorkers
? {
worker: {
workflowsPath: path!,
activityClasses: activityClasses!,
autoStart: true,
workerOptions: {
maxConcurrentActivityTaskExecutions: 24,
},
},
workers: [
{ identifier: 'main', maxConcurrentJob: undefined },
...socialIntegrationList,
]
.filter((f) => f.identifier.indexOf('-') === -1)
.map((integration) => ({
taskQueue: integration.identifier.split('-')[0],
workflowsPath: path!,
activityClasses: activityClasses!,
autoStart: true,
...(integration.maxConcurrentJob
? {
workerOptions: {
maxConcurrentActivityTaskExecutions:
integration.maxConcurrentJob,
},
}
: {}),
})),
}
: {}),
});

Some files were not shown because too many files have changed in this diff Show More