diff --git a/libraries/nestjs-libraries/src/chat/tools/integration.validation.tool.ts b/libraries/nestjs-libraries/src/chat/tools/integration.validation.tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..14bd16ddff9e7c2eb7bf84e38e23468144dc07ee --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/tools/integration.validation.tool.ts @@ -0,0 +1,114 @@ +import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface'; +import { createTool } from '@mastra/core/tools'; +import { z } from 'zod'; +import { Injectable } from '@nestjs/common'; +import { + IntegrationManager, + socialIntegrationList, +} from '@gitroom/nestjs-libraries/integrations/integration.manager'; +import { getValidationSchemas } from '@gitroom/nestjs-libraries/chat/validation.schemas.helper'; +import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; + +@Injectable() +export class IntegrationValidationTool implements AgentToolInterface { + constructor(private _integrationManager: IntegrationManager) {} + name = 'integrationSchema'; + + run() { + return createTool({ + id: 'integrationSchema', + description: `Everytime we want to schedule a social media post, we need to understand the schema of the integration. + This tool helps us get the schema of the integration. + Sometimes we might get a schema back the requires some id, for that, you can get information from 'tools' + And use the triggerTool function. + `, + mcp: { + annotations: { + title: 'Get Integration Schema', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + inputSchema: z.object({ + isPremium: z + .boolean() + .describe('is this the user premium? if not, set to false'), + platform: z + .string() + .describe( + `platform identifier (${socialIntegrationList + .map((p) => p.identifier) + .join(', ')})` + ), + }), + outputSchema: z.object({ + output: z.object({ + rules: z.string(), + maxLength: z + .number() + .describe('The maximum length of a post / comment'), + settings: z + .any() + .describe('List of settings need to be passed to schedule a post'), + tools: z + .array( + z.object({ + description: z.string().describe('Description of the tool'), + methodName: z + .string() + .describe('Method to call to get the information'), + dataSchema: z + .array( + z.object({ + key: z + .string() + .describe('Name of the settings key to pass'), + description: z + .string() + .describe('Description of the setting key'), + type: z.string(), + }) + ) + .describe( + 'This will be passed to schedulePostTool [output:settings]' + ), + }) + ) + .describe( + "Sometimes settings require some id, tags and stuff, if you don't have, trigger the `triggerTool` function from the tools list [output:callable-tools]" + ), + }), + }), + execute: async (inputData, context) => { + checkAuth(inputData, context); + const integration = socialIntegrationList.find( + (p) => p.identifier === inputData.platform + )!; + + if (!integration) { + return { + output: { rules: '', maxLength: 0, settings: {}, tools: [] }, + }; + } + + const maxLength = integration.maxLength(inputData.isPremium); + const schemas = !integration.dto + ? false + : getValidationSchemas()[integration.dto.name]; + const tools = this._integrationManager.getAllTools(); + const rules = this._integrationManager.getAllRulesDescription(); + + return { + output: { + rules: rules[integration.identifier], + maxLength, + settings: !schemas ? 'No additional settings required' : schemas, + tools: tools[integration.identifier], + }, + }; + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/chat/tools/tool.list.ts b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts new file mode 100644 index 0000000000000000000000000000000000000000..b188daee47007e26773efe894b0a1f54d89033d8 --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts @@ -0,0 +1,23 @@ +import { IntegrationValidationTool } from '@gitroom/nestjs-libraries/chat/tools/integration.validation.tool'; +import { IntegrationTriggerTool } from '@gitroom/nestjs-libraries/chat/tools/integration.trigger.tool'; +import { IntegrationSchedulePostTool } from './integration.schedule.post'; +import { GenerateVideoOptionsTool } from '@gitroom/nestjs-libraries/chat/tools/generate.video.options.tool'; +import { VideoFunctionTool } from '@gitroom/nestjs-libraries/chat/tools/video.function.tool'; +import { GenerateVideoTool } from '@gitroom/nestjs-libraries/chat/tools/generate.video.tool'; +import { GenerateImageTool } from '@gitroom/nestjs-libraries/chat/tools/generate.image.tool'; +import { IntegrationListTool } from '@gitroom/nestjs-libraries/chat/tools/integration.list.tool'; +import { GroupListTool } from '@gitroom/nestjs-libraries/chat/tools/group.list.tool'; +import { UploadFromUrlTool } from '@gitroom/nestjs-libraries/chat/tools/upload.from.url.tool'; + +export const toolList = [ + IntegrationListTool, + GroupListTool, + IntegrationValidationTool, + IntegrationTriggerTool, + IntegrationSchedulePostTool, + GenerateVideoOptionsTool, + VideoFunctionTool, + GenerateVideoTool, + GenerateImageTool, + UploadFromUrlTool, +]; diff --git a/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts b/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..5875e74e06354560bd50b7fce596b79bf9ba752f --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/tools/upload.from.url.tool.ts @@ -0,0 +1,145 @@ +import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface'; +import { createTool } from '@mastra/core/tools'; +import { z } from 'zod'; +import { Injectable } from '@nestjs/common'; +import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; +import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; +import { getMaxSize } from '@gitroom/nestjs-libraries/upload/custom.upload.validation'; +import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; +import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import { Readable } from 'stream'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromBuffer } = require('file-type'); + +// Same allow-list as the public API /upload-from-url route. +const ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/tiff', + 'video/mp4', +]); + +@Injectable() +export class UploadFromUrlTool implements AgentToolInterface { + private storage = UploadFactory.createStorage(); + + constructor(private _mediaService: MediaService) {} + name = 'uploadFromUrlTool'; + + run() { + return createTool({ + id: 'uploadFromUrlTool', + description: `Upload a remote image or video into the media library from a public URL. +Use this before scheduling a post when the user provides an external media URL (not already hosted on our domain), +so the attachment passes the upload-domain validation. Returns the hosted media { id, path } to use as an attachment, or { error } on failure.`, + mcp: { + annotations: { + title: 'Upload Media From URL', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + inputSchema: z.object({ + url: z + .string() + .url() + .describe('The public URL of the image or video to upload'), + }), + // Mastra validates a tool's return against this schema, so it must also + // allow the graceful { error } shape. Fields are optional (rather than + // wrapping everything in an `output` union) to keep the change minimal: + // the existing { id, path } success return and the new { error } return + // both validate without rewriting every return statement. + outputSchema: z.object({ + id: z.string().optional(), + path: z.string().optional(), + error: z.string().optional(), + }), + execute: async (inputData, context) => { + checkAuth(inputData, context); + try { + const org = JSON.parse( + (context?.requestContext as any)?.get('organization') as string + ); + + const response = await fetch(inputData.url, { + // @ts-ignore — undici option, not in lib.dom fetch types + dispatcher: ssrfSafeDispatcher, + }); + + if (!response.ok) { + return { error: 'Failed to fetch URL' }; + } + + // Guard against OOM: bail out before buffering the whole body into + // memory. Content-Length may be absent or wrong, so we re-check the + // real size after download too. The type isn't known yet (sniffed + // below), so the pre-check uses the largest allowed cap (video). + const maxDownloadSize = getMaxSize('video/mp4'); + const declaredSize = Number(response.headers.get('content-length')); + if (declaredSize && declaredSize > maxDownloadSize) { + return { + error: `File is too large: ${declaredSize} bytes (max ${maxDownloadSize} bytes).`, + }; + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const detected = await fromBuffer(buffer); + if (!detected || !ALLOWED_MIME.has(detected.mime)) { + return { error: 'Unsupported file type.' }; + } + + const maxSize = getMaxSize(detected.mime); + if (buffer.length > maxSize) { + return { + error: `File is too large: ${buffer.length} bytes (max ${maxSize} bytes).`, + }; + } + + const getFile = await this.storage.uploadFile({ + buffer, + mimetype: detected.mime, + size: buffer.length, + path: '', + fieldname: '', + destination: '', + stream: new Readable(), + filename: '', + originalname: `upload.${detected.ext}`, + encoding: '', + }); + + return await this._mediaService.saveFile( + org.id, + getFile.originalname, + getFile.path + ); + } catch (err) { + // undici's fetch rejects with a generic TypeError('fetch failed') + // and hides the real reason (DNS, TLS, SSRF block, ...) in + // err.cause, so surface it for the agent. Error.cause isn't in the + // es2020 lib typings this repo compiles against, hence the cast. + const cause = + err instanceof Error + ? (err as Error & { cause?: unknown }).cause + : undefined; + const causeText = + cause instanceof Error && cause.message + ? ` (${cause.message})` + : ''; + return { + error: `Failed to upload media from URL: ${ + err instanceof Error ? err.message : 'Unexpected error' + }${causeText}`, + }; + } + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/chat/tools/video.function.tool.ts b/libraries/nestjs-libraries/src/chat/tools/video.function.tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8f953833c6445e5ffa909dd21c1ac18beefa02f --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/tools/video.function.tool.ts @@ -0,0 +1,56 @@ +import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface'; +import { createTool } from '@mastra/core/tools'; +import { Injectable } from '@nestjs/common'; +import { VideoManager } from '@gitroom/nestjs-libraries/videos/video.manager'; +import z from 'zod'; +import { ModuleRef } from '@nestjs/core'; +import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; + +@Injectable() +export class VideoFunctionTool implements AgentToolInterface { + constructor( + private _videoManagerService: VideoManager, + private _moduleRef: ModuleRef + ) {} + name = 'videoFunctionTool'; + + run() { + return createTool({ + id: 'videoFunctionTool', + description: `Sometimes when we want to generate videos we might need to get some additional information like voice_id, etc`, + mcp: { + annotations: { + title: 'Video Function Helper', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + inputSchema: z.object({ + identifier: z.string(), + functionName: z.string(), + }), + outputSchema: z.object({}).passthrough(), + execute: async (inputData, context) => { + checkAuth(inputData, context); + const videos = this._videoManagerService.getAllVideos(); + const findVideo = videos.find( + (p) => + p.identifier === inputData.identifier && + p.tools.some((p) => p.functionName === inputData.functionName) + ); + + if (!findVideo) { + throw new Error('Function not found'); + } + + const func = await this._moduleRef + // @ts-ignore + .get(findVideo.target, { strict: false }) + [inputData.functionName](); + return func; + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/chat/validation.schemas.helper.ts b/libraries/nestjs-libraries/src/chat/validation.schemas.helper.ts new file mode 100644 index 0000000000000000000000000000000000000000..45a98bbeb53ac3180c12222e205ed5416464106f --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/validation.schemas.helper.ts @@ -0,0 +1,30 @@ +import { + validationMetadatasToSchemas, + targetConstructorToSchema, +} from 'class-validator-jsonschema'; +import { ValidationTypes } from 'class-validator'; +// @ts-ignore +import { defaultMetadataStorage } from 'class-transformer/cjs/storage'; + +export function getValidationSchemas() { + return validationMetadatasToSchemas({ + classTransformerMetadataStorage: defaultMetadataStorage, + additionalConverters: { + [ValidationTypes.NESTED_VALIDATION]: (meta, options) => { + if (typeof meta.target === 'function') { + const typeMeta = options.classTransformerMetadataStorage + ? options.classTransformerMetadataStorage.findTypeMetadata( + meta.target, + meta.propertyName + ) + : null; + if (typeMeta) { + const childType = typeMeta.typeFunction(); + return targetConstructorToSchema(childType, options); + } + } + return {}; + }, + }, + }); +} diff --git a/libraries/nestjs-libraries/src/database/prisma/admin-stats/admin-stats.repository.ts b/libraries/nestjs-libraries/src/database/prisma/admin-stats/admin-stats.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..141c42633a351d08523595ee0aadf804c8c7cb05 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/admin-stats/admin-stats.repository.ts @@ -0,0 +1,157 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +export interface StatsParams { + from: Date; + to: Date; + unknownOnly?: boolean; +} + +// Unknown errors are stored as the serialized error payload, e.g. +// {..."message":"Unknown Error"...}. Matches `message LIKE '%"message":"Unknown Error"%'`. +const UNKNOWN_ERROR_TOKEN = '"message":"Unknown Error"'; + +interface PerSocial { + provider: string; + count: number; +} + +export interface StatsResponse { + from: string; + to: string; + errors: { total: number; perSocial: PerSocial[] }; + posts: { total: number; perSocial: PerSocial[] }; + connected: { total: number; perSocial: PerSocial[] }; +} + +const sortDesc = (list: PerSocial[]) => + list.sort((a, b) => b.count - a.count || a.provider.localeCompare(b.provider)); + +@Injectable() +export class AdminStatsRepository { + constructor( + private _post: PrismaRepository<'post'>, + private _integration: PrismaRepository<'integration'>, + private _errors: PrismaRepository<'errors'> + ) {} + + private async errorStats(params: StatsParams) { + const where: Prisma.ErrorsWhereInput = { + createdAt: { gte: params.from, lte: params.to }, + ...(params.unknownOnly + ? { message: { contains: UNKNOWN_ERROR_TOKEN } } + : {}), + }; + + const [total, grouped] = await Promise.all([ + this._errors.model.errors.count({ where }), + this._errors.model.errors.groupBy({ + by: ['platform'], + where, + _count: { _all: true }, + }), + ]); + + return { + total, + perSocial: sortDesc( + grouped.map((g) => ({ + provider: g.platform, + count: g._count._all, + })) + ), + }; + } + + private async postStats(params: StatsParams) { + // Only count top-level posts (thread children share a parentPostId) so the + // numbers match a "post published to a channel" rather than every fragment. + const where: Prisma.PostWhereInput = { + state: 'PUBLISHED', + parentPostId: null, + deletedAt: null, + publishDate: { gte: params.from, lte: params.to }, + }; + + const [total, grouped] = await Promise.all([ + this._post.model.post.count({ where }), + this._post.model.post.groupBy({ + by: ['integrationId'], + where, + _count: { _all: true }, + }), + ]); + + // groupBy can't reach into the integration relation, so resolve the + // providerIdentifier for the integrations we saw and fold the counts. + const integrationIds = grouped.map((g) => g.integrationId); + const integrations = integrationIds.length + ? await this._integration.model.integration.findMany({ + where: { id: { in: integrationIds } }, + select: { id: true, providerIdentifier: true }, + }) + : []; + const providerById = new Map( + integrations.map((i) => [i.id, i.providerIdentifier]) + ); + + const byProvider = new Map(); + for (const g of grouped) { + const provider = providerById.get(g.integrationId) || 'unknown'; + byProvider.set(provider, (byProvider.get(provider) || 0) + g._count._all); + } + + return { + total, + perSocial: sortDesc( + [...byProvider.entries()].map(([provider, count]) => ({ + provider, + count, + })) + ), + }; + } + + private async connectedStats(params: StatsParams) { + const where: Prisma.IntegrationWhereInput = { + deletedAt: null, + createdAt: { gte: params.from, lte: params.to }, + }; + + const [total, grouped] = await Promise.all([ + this._integration.model.integration.count({ where }), + this._integration.model.integration.groupBy({ + by: ['providerIdentifier'], + where, + _count: { _all: true }, + }), + ]); + + return { + total, + perSocial: sortDesc( + grouped.map((g) => ({ + provider: g.providerIdentifier, + count: g._count._all, + })) + ), + }; + } + + async getStats(params: StatsParams): Promise { + const [errors, posts, connected] = await Promise.all([ + this.errorStats(params), + this.postStats(params), + this.connectedStats(params), + ]); + + return { + from: params.from.toISOString(), + to: params.to.toISOString(), + errors, + posts, + connected, + }; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/admin-stats/admin-stats.service.ts b/libraries/nestjs-libraries/src/database/prisma/admin-stats/admin-stats.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1af176bb6e5ccedb4d702e1cd06a1b236343f56 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/admin-stats/admin-stats.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@nestjs/common'; +import { + AdminStatsRepository, + StatsParams, +} from '@gitroom/nestjs-libraries/database/prisma/admin-stats/admin-stats.repository'; + +@Injectable() +export class AdminStatsService { + constructor(private _adminStatsRepository: AdminStatsRepository) {} + + getStats(params: StatsParams) { + return this._adminStatsRepository.getStats(params); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/agencies/agencies.repository.ts b/libraries/nestjs-libraries/src/database/prisma/agencies/agencies.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..869d72c449c31111b3346227902a2e45af6ef2d2 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/agencies/agencies.repository.ts @@ -0,0 +1,183 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { User } from '@prisma/client'; +import { CreateAgencyDto } from '@gitroom/nestjs-libraries/dtos/agencies/create.agency.dto'; + +@Injectable() +export class AgenciesRepository { + constructor( + private _socialMediaAgencies: PrismaRepository<'socialMediaAgency'>, + private _socialMediaAgenciesNiche: PrismaRepository<'socialMediaAgencyNiche'> + ) {} + + getAllAgencies() { + return this._socialMediaAgencies.model.socialMediaAgency.findMany({ + where: { + deletedAt: null, + approved: true, + }, + include: { + logo: true, + niches: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + getCount() { + return this._socialMediaAgencies.model.socialMediaAgency.count({ + where: { + deletedAt: null, + approved: true, + }, + }); + } + + getAllAgenciesSlug() { + return this._socialMediaAgencies.model.socialMediaAgency.findMany({ + where: { + deletedAt: null, + approved: true, + }, + select: { + slug: true, + }, + }); + } + + approveOrDecline(action: string, id: string) { + return this._socialMediaAgencies.model.socialMediaAgency.update({ + where: { + id, + }, + data: { + approved: action === 'approve', + }, + }); + } + + getAgencyById(id: string) { + return this._socialMediaAgencies.model.socialMediaAgency.findFirst({ + where: { + id, + deletedAt: null, + approved: true, + }, + include: { + logo: true, + niches: true, + user: true, + }, + }); + } + + getAgencyInformation(agency: string) { + return this._socialMediaAgencies.model.socialMediaAgency.findFirst({ + where: { + slug: agency, + deletedAt: null, + approved: true, + }, + include: { + logo: true, + niches: true, + }, + }); + } + + getAgencyByUser(user: User) { + return this._socialMediaAgencies.model.socialMediaAgency.findFirst({ + where: { + userId: user.id, + deletedAt: null, + }, + include: { + logo: true, + niches: true, + }, + }); + } + + async createAgency(user: User, body: CreateAgencyDto) { + const insertAgency = + await this._socialMediaAgencies.model.socialMediaAgency.upsert({ + where: { + userId: user.id, + }, + update: { + userId: user.id, + name: body.name, + website: body.website, + facebook: body.facebook, + instagram: body.instagram, + twitter: body.twitter, + linkedIn: body.linkedIn, + youtube: body.youtube, + tiktok: body.tiktok, + logoId: body.logo.id, + shortDescription: body.shortDescription, + description: body.description, + approved: false, + }, + create: { + userId: user.id, + name: body.name, + website: body.website, + facebook: body.facebook, + instagram: body.instagram, + twitter: body.twitter, + linkedIn: body.linkedIn, + youtube: body.youtube, + tiktok: body.tiktok, + logoId: body.logo.id, + shortDescription: body.shortDescription, + description: body.description, + slug: body.name.toLowerCase().replace(/ /g, '-'), + approved: false, + }, + select: { + id: true, + }, + }); + + await this._socialMediaAgenciesNiche.model.socialMediaAgencyNiche.deleteMany( + { + where: { + agencyId: insertAgency.id, + niche: { + notIn: body.niches, + }, + }, + } + ); + + const currentNiche = + await this._socialMediaAgenciesNiche.model.socialMediaAgencyNiche.findMany( + { + where: { + agencyId: insertAgency.id, + }, + select: { + niche: true, + }, + } + ); + + const addNewNiche = body.niches.filter( + (n) => !currentNiche.some((c) => c.niche === n) + ); + + await this._socialMediaAgenciesNiche.model.socialMediaAgencyNiche.createMany( + { + data: addNewNiche.map((n) => ({ + agencyId: insertAgency.id, + niche: n, + })), + } + ); + + return insertAgency; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/agencies/agencies.service.ts b/libraries/nestjs-libraries/src/database/prisma/agencies/agencies.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b6b2278654cedcbde659fe69777f98ace1d48dd --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/agencies/agencies.service.ts @@ -0,0 +1,217 @@ +import { Injectable } from '@nestjs/common'; +import { AgenciesRepository } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.repository'; +import { User } from '@prisma/client'; +import { CreateAgencyDto } from '@gitroom/nestjs-libraries/dtos/agencies/create.agency.dto'; +import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; + +@Injectable() +export class AgenciesService { + constructor( + private _agenciesRepository: AgenciesRepository, + private _notificationService: NotificationService + ) {} + getAgencyByUser(user: User) { + return this._agenciesRepository.getAgencyByUser(user); + } + + getCount() { + return this._agenciesRepository.getCount(); + } + + getAllAgencies() { + return this._agenciesRepository.getAllAgencies(); + } + + getAllAgenciesSlug() { + return this._agenciesRepository.getAllAgenciesSlug(); + } + + getAgencyInformation(agency: string) { + return this._agenciesRepository.getAgencyInformation(agency); + } + + async approveOrDecline(email: string, action: string, id: string) { + await this._agenciesRepository.approveOrDecline(action, id); + const agency = await this._agenciesRepository.getAgencyById(id); + + if (action === 'approve') { + await this._notificationService.sendEmail( + agency?.user?.email!, + 'Your Agency has been approved and added to Postiz 🚀', + ` + + + + + + Your Agency has been approved and added to Postiz 🚀 + + + + Hi there,

+ Your agency ${agency?.name} has been added to Postiz!
+ You can check it here
+ It will appear on the main agency of Postiz in the next 24 hours.

+ +` + ); + + return; + } + + await this._notificationService.sendEmail( + agency?.user?.email!, + 'Your Agency has been declined 😔', + ` + + + + + + Your Agency has been declined + + + + Hi there,

+ Your agency ${agency?.name} has been declined to Postiz!
+ If you think we have made a mistake, please reply to this email and let us know + +` + ); + + return; + } + + async createAgency(user: User, body: CreateAgencyDto) { + const agency = await this._agenciesRepository.createAgency(user, body); + await this._notificationService.sendEmail( + 'nevo@postiz.com', + 'New agency created', + ` + + + + + + Email Template + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ${ + body.website + } +
+ +

+ Social Medias: + ${ + body.facebook + }
+ ${ + body.instagram + }
+ ${ + body.twitter + }
+ ${ + body.linkedIn + }
+ ${ + body.youtube + }
+ ${ + body.tiktok + } +

+
+ +

Logo

+

+ +

+
+ +

Name

+

${ + body.name + }

+
+ +

Short Description

+

${ + body.shortDescription + }

+
+ +

Description

+

${ + body.description + }

+
+ +

Niches

+

${body.niches.join( + ',' + )}

+
+ To approve click here


+ To decline click here


+
+

© 2024 Your Gitroom Limited All rights reserved.

+
+ + + + ` + ); + return agency; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/announcements/announcements.repository.ts b/libraries/nestjs-libraries/src/database/prisma/announcements/announcements.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..ade84d1ddfcb2fab0af4df354d79efa2421c7ab5 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/announcements/announcements.repository.ts @@ -0,0 +1,35 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { AnnouncementDto } from '@gitroom/nestjs-libraries/dtos/announcements/announcements.dto'; +import { AnnouncementColor } from '@prisma/client'; + +@Injectable() +export class AnnouncementsRepository { + constructor(private _announcements: PrismaRepository<'announcement'>) {} + + getAnnouncements() { + return this._announcements.model.announcement.findMany({ + orderBy: { + createdAt: 'desc', + }, + }); + } + + createAnnouncement(body: AnnouncementDto) { + return this._announcements.model.announcement.create({ + data: { + title: body.title, + description: body.description, + color: (body.color as AnnouncementColor) || AnnouncementColor.INFO, + }, + }); + } + + deleteAnnouncement(id: string) { + return this._announcements.model.announcement.delete({ + where: { + id, + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/announcements/announcements.service.ts b/libraries/nestjs-libraries/src/database/prisma/announcements/announcements.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b81aedf72698aeda968ce6dc378c81d12f016700 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/announcements/announcements.service.ts @@ -0,0 +1,20 @@ +import { Injectable } from '@nestjs/common'; +import { AnnouncementsRepository } from '@gitroom/nestjs-libraries/database/prisma/announcements/announcements.repository'; +import { AnnouncementDto } from '@gitroom/nestjs-libraries/dtos/announcements/announcements.dto'; + +@Injectable() +export class AnnouncementsService { + constructor(private _announcementsRepository: AnnouncementsRepository) {} + + getAnnouncements() { + return this._announcementsRepository.getAnnouncements(); + } + + createAnnouncement(body: AnnouncementDto) { + return this._announcementsRepository.createAnnouncement(body); + } + + deleteAnnouncement(id: string) { + return this._announcementsRepository.deleteAnnouncement(id); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.repository.ts b/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4b1529e99b3c64149d2bd0999de38dd3339db28 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.repository.ts @@ -0,0 +1,107 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { AutopostDto } from '@gitroom/nestjs-libraries/dtos/autopost/autopost.dto'; + +@Injectable() +export class AutopostRepository { + constructor(private _autoPost: PrismaRepository<'autoPost'>) {} + + getTotal(orgId: string) { + return this._autoPost.model.autoPost.count({ + where: { + organizationId: orgId, + deletedAt: null, + }, + }); + } + + getAutoposts(orgId: string) { + return this._autoPost.model.autoPost.findMany({ + where: { + organizationId: orgId, + deletedAt: null, + }, + }); + } + + deleteAutopost(orgId: string, id: string) { + return this._autoPost.model.autoPost.update({ + where: { + id, + organizationId: orgId, + }, + data: { + deletedAt: new Date(), + }, + }); + } + + getAutopost(id: string) { + return this._autoPost.model.autoPost.findUnique({ + where: { + id, + deletedAt: null, + }, + }); + } + + updateUrl(id: string, url: string) { + return this._autoPost.model.autoPost.update({ + where: { + id, + }, + data: { + lastUrl: url, + }, + }); + } + + changeActive(orgId: string, id: string, active: boolean) { + return this._autoPost.model.autoPost.update({ + where: { + id, + organizationId: orgId, + }, + data: { + active, + }, + }); + } + + async createAutopost(orgId: string, body: AutopostDto, id?: string) { + const { id: newId, active } = await this._autoPost.model.autoPost.upsert({ + where: { + id: id || uuidv4(), + organizationId: orgId, + }, + create: { + organizationId: orgId, + url: body.url, + title: body.title, + integrations: JSON.stringify(body.integrations), + active: body.active, + content: body.content, + generateContent: body.generateContent, + addPicture: body.addPicture, + syncLast: body.syncLast, + onSlot: body.onSlot, + lastUrl: body.lastUrl, + }, + update: { + url: body.url, + title: body.title, + integrations: JSON.stringify(body.integrations), + active: body.active, + content: body.content, + generateContent: body.generateContent, + addPicture: body.addPicture, + syncLast: body.syncLast, + onSlot: body.onSlot, + lastUrl: body.lastUrl, + }, + }); + + return { id: newId, active }; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts b/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b7f9e9eea2daf36f6f351b5034b012a183d822b --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/autopost/autopost.service.ts @@ -0,0 +1,371 @@ +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 dayjs from 'dayjs'; +import { END, START, StateGraph } from '@langchain/langgraph'; +import { AutoPost, Integration } from '@prisma/client'; +import { BaseMessage } from '@langchain/core/messages'; +import striptags from 'striptags'; +import { ChatOpenAI, DallEAPIWrapper } from '@langchain/openai'; +import { JSDOM } from 'jsdom'; +import { z } from 'zod'; +import { ChatPromptTemplate } from '@langchain/core/prompts'; +import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; +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, +} from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute'; +const parser = new Parser(); + +interface WorkflowChannelsState { + messages: BaseMessage[]; + integrations: Integration[]; + body: AutoPost; + description: string; + image: string; + id: string; + load: { + date: string; + url: string; + description: string; + }; +} + +const model = new ChatOpenAI({ + apiKey: process.env.OPENAI_API_KEY || 'sk-proj-', + model: 'gpt-4.1', + temperature: 0.7, +}); + +const dalle = new DallEAPIWrapper({ + apiKey: process.env.OPENAI_API_KEY || 'sk-proj-', + model: 'chatgpt-image-latest', +}); + +const generateContent = z.object({ + socialMediaPostContent: z + .string() + .describe('Content for social media posts max 120 chars'), +}); + +const dallePrompt = z.object({ + generatedTextToBeSentToDallE: z + .string() + .describe('Generated prompt from description to be sent to DallE'), +}); + +@Injectable() +export class AutopostService { + constructor( + private _autopostsRepository: AutopostRepository, + private _temporalService: TemporalService, + private _integrationService: IntegrationService, + private _postsService: PostsService + ) {} + + async stopAll(org: string) { + const getAll = (await this.getAutoposts(org)).filter((f) => f.active); + for (const autopost of getAll) { + await this.changeActive(org, autopost.id, false); + } + } + + getAutoposts(orgId: string) { + return this._autopostsRepository.getAutoposts(orgId); + } + + async createAutopost(orgId: string, body: AutopostDto, id?: string) { + const data = await this._autopostsRepository.createAutopost( + orgId, + body, + id + ); + + await this.processCron(body.active, orgId, data.id); + + return data; + } + + async changeActive(orgId: string, id: string, active: boolean) { + const data = await this._autopostsRepository.changeActive( + orgId, + id, + active + ); + await this.processCron(active, orgId, id); + return data; + } + + async processCron(active: boolean, orgId: string, id: string) { + if (active) { + try { + return this._temporalService.client + .getRawClient() + ?.workflow.start('autoPostWorkflow', { + workflowId: `autopost-${id}`, + taskQueue: 'main', + args: [{ id, immediately: true }], + typedSearchAttributes: new TypedSearchAttributes([ + { + key: organizationId, + value: orgId, + }, + ]), + }); + } catch (err) {} + } + + 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, orgId, id); + return data; + } + + async loadXML(url: string) { + try { + const { items } = await parser.parseURL(url); + const findLast = items.reduce( + (all: any, current: any) => { + if (dayjs(current.pubDate).isAfter(all.pubDate)) { + return current; + } + return all; + }, + { pubDate: dayjs().subtract(100, 'years') } + ); + + return { + success: true, + date: findLast.pubDate, + url: findLast.link, + description: striptags( + findLast?.['content:encoded'] || + findLast?.content || + findLast?.description || + '' + ) + .replace(/\n/g, ' ') + .trim(), + }; + } catch (err) { + /** sent **/ + } + + return { success: false }; + } + + static state = () => + new StateGraph({ + channels: { + messages: { + reducer: (currentState, updateValue) => + currentState.concat(updateValue), + default: () => [], + }, + body: null, + description: null, + load: null, + image: null, + integrations: null, + id: null, + }, + }); + + async loadUrl(url: string) { + try { + const loadDom = new JSDOM(await (await fetch(url)).text()); + loadDom.window.document + .querySelectorAll('script') + .forEach((s) => s.remove()); + loadDom.window.document + .querySelectorAll('style') + .forEach((s) => s.remove()); + // remove all html, script and styles + return striptags(loadDom.window.document.body.innerHTML); + } catch (err) { + return ''; + } + } + + async generateDescription(state: WorkflowChannelsState) { + if (!state.body.generateContent) { + return { + ...state, + description: state.body.content, + }; + } + + const description = + state.load.description || (await this.loadUrl(state.load.url)); + if (!description) { + return { + ...state, + description: '', + }; + } + + const structuredOutput = model.withStructuredOutput(generateContent); + const { socialMediaPostContent } = await ChatPromptTemplate.fromTemplate( + ` + You are an assistant that gets raw 'description' of a content and generate a social media post content. + Rules: + - Maximum 100 chars + - Try to make it a short as possible to fit any social media + - Add line breaks between sentences (\\n) + - Don't add hashtags + - Add emojis when needed + + 'description': + {content} + ` + ) + .pipe(structuredOutput) + .invoke({ + content: description, + }); + + return { + ...state, + description: socialMediaPostContent, + }; + } + + async generatePicture(state: WorkflowChannelsState) { + const structuredOutput = model.withStructuredOutput(dallePrompt); + const { generatedTextToBeSentToDallE } = + await ChatPromptTemplate.fromTemplate( + ` + You are an assistant that gets description and generate a prompt that will be sent to DallE to generate pictures. + + content: + {content} + ` + ) + .pipe(structuredOutput) + .invoke({ + content: state.load.description || state.description, + }); + + const image = await dalle.invoke(generatedTextToBeSentToDallE); + + return { ...state, image }; + } + + async schedulePost(state: WorkflowChannelsState) { + const nextTime = await this._postsService.findFreeDateTime( + state.integrations[0].organizationId + ); + + await this._postsService.createPost(state.integrations[0].organizationId, { + date: nextTime + 'Z', + order: makeId(10), + shortLink: false, + type: 'draft', + tags: [], + posts: state.integrations.map((i) => ({ + settings: { + __type: i.providerIdentifier as any, + title: '', + tags: [], + subreddit: [], + }, + group: makeId(10), + integration: { id: i.id }, + value: [ + { + id: makeId(10), + delay: 0, + content: + state.description.replace(/\n/g, '\n\n') + + '\n\n' + + state.load.url, + image: !state.image + ? [] + : [ + { + id: makeId(10), + name: makeId(10), + path: state.image, + organizationId: state.integrations[0].organizationId, + }, + ], + }, + ], + })), + }, 'AUTOPOST'); + } + + async updateUrl(state: WorkflowChannelsState) { + await this._autopostsRepository.updateUrl(state.id, state.load.url); + } + + async startAutopost(id: string) { + const getPost = await this._autopostsRepository.getAutopost(id); + if (!getPost || !getPost.active) { + return; + } + + const load = await this.loadXML(getPost.url); + if (!load.success || load.url === getPost.lastUrl) { + return; + } + + const integrations = await this._integrationService.getIntegrationsList( + getPost.organizationId + ); + + const parseIntegrations = JSON.parse(getPost.integrations || '[]') || []; + const neededIntegrations = integrations.filter((i) => + parseIntegrations.some((ii: any) => ii.id === i.id) + ); + + const integrationsToSend = + parseIntegrations.length === 0 ? integrations : neededIntegrations; + if (integrationsToSend.length === 0) { + return; + } + + const state = AutopostService.state(); + const workflow = state + .addNode('generate-description', this.generateDescription.bind(this)) + .addNode('generate-picture', this.generatePicture.bind(this)) + .addNode('schedule-post', this.schedulePost.bind(this)) + .addNode('update-url', this.updateUrl.bind(this)) + .addEdge(START, 'generate-description') + .addConditionalEdges( + 'generate-description', + (state: WorkflowChannelsState) => { + if (!state.description) { + return 'schedule-post'; + } + if (state.body.addPicture) { + return 'generate-picture'; + } + return 'schedule-post'; + } + ) + .addEdge('generate-picture', 'schedule-post') + .addEdge('schedule-post', 'update-url') + .addEdge('update-url', END); + + const app = workflow.compile(); + await app.invoke({ + messages: [], + id, + body: getPost, + load, + integrations: integrationsToSend, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/database.module.ts b/libraries/nestjs-libraries/src/database/prisma/database.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..252b01d3960ce119be185d6aea9a39dc5024dfb1 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/database.module.ts @@ -0,0 +1,105 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaRepository, PrismaService, PrismaTransaction } from './prisma.service'; +import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository'; +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 { 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'; +import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; +import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository'; +import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; +import { PostsRepository } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.repository'; +import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; +import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; +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 { 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'; +import { AgenciesService } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.service'; +import { AgenciesRepository } from '@gitroom/nestjs-libraries/database/prisma/agencies/agencies.repository'; +import { TrackService } from '@gitroom/nestjs-libraries/track/track.service'; +import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; +import { WebhooksRepository } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.repository'; +import { WebhooksService } from '@gitroom/nestjs-libraries/database/prisma/webhooks/webhooks.service'; +import { SignatureRepository } from '@gitroom/nestjs-libraries/database/prisma/signatures/signature.repository'; +import { SignatureService } from '@gitroom/nestjs-libraries/database/prisma/signatures/signature.service'; +import { AutopostRepository } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.repository'; +import { AutopostService } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.service'; +import { SetsService } from '@gitroom/nestjs-libraries/database/prisma/sets/sets.service'; +import { SetsRepository } from '@gitroom/nestjs-libraries/database/prisma/sets/sets.repository'; +import { ThirdPartyRepository } from '@gitroom/nestjs-libraries/database/prisma/third-party/third-party.repository'; +import { ThirdPartyService } from '@gitroom/nestjs-libraries/database/prisma/third-party/third-party.service'; +import { VideoManager } from '@gitroom/nestjs-libraries/videos/video.manager'; +import { FalService } from '@gitroom/nestjs-libraries/openai/fal.service'; +import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service'; +import { OAuthRepository } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.repository'; +import { OAuthService } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.service'; +import { AnnouncementsRepository } from '@gitroom/nestjs-libraries/database/prisma/announcements/announcements.repository'; +import { AnnouncementsService } from '@gitroom/nestjs-libraries/database/prisma/announcements/announcements.service'; +import { ErrorsRepository } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.repository'; +import { ErrorsService } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.service'; +import { AdminStatsRepository } from '@gitroom/nestjs-libraries/database/prisma/admin-stats/admin-stats.repository'; +import { AdminStatsService } from '@gitroom/nestjs-libraries/database/prisma/admin-stats/admin-stats.service'; + +@Global() +@Module({ + imports: [], + controllers: [], + providers: [ + PrismaService, + PrismaRepository, + PrismaTransaction, + UsersService, + UsersRepository, + OrganizationService, + OrganizationRepository, + SubscriptionService, + SubscriptionRepository, + NotificationService, + NotificationsRepository, + WebhooksRepository, + WebhooksService, + IntegrationService, + IntegrationRepository, + PostsService, + PostsRepository, + StripeService, + SignatureRepository, + AutopostRepository, + AutopostService, + SignatureService, + MediaService, + MediaRepository, + AgenciesService, + AgenciesRepository, + IntegrationManager, + RefreshIntegrationService, + ExtractContentService, + OpenaiService, + FalService, + EmailService, + TrackService, + ShortLinkService, + SetsService, + SetsRepository, + ThirdPartyRepository, + ThirdPartyService, + OAuthRepository, + OAuthService, + VideoManager, + AnnouncementsRepository, + AnnouncementsService, + ErrorsRepository, + ErrorsService, + AdminStatsRepository, + AdminStatsService, + ], + get exports() { + return this.providers; + }, +}) +export class DatabaseModule {} diff --git a/libraries/nestjs-libraries/src/database/prisma/errors/errors.repository.ts b/libraries/nestjs-libraries/src/database/prisma/errors/errors.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..6f3bb51ea67db29e2c4463ff225a229e2153c68a --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/errors/errors.repository.ts @@ -0,0 +1,143 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; + +const UNKNOWN_TOKEN = 'An unknown error occurred'; + +interface ListErrorsParams { + page?: number; + limit?: number; + platform?: string; + email?: string; + unknownFirst?: boolean; +} + +@Injectable() +export class ErrorsRepository { + constructor(private _errors: PrismaRepository<'errors'>) {} + + private buildWhere(params: ListErrorsParams) { + const where: any = {}; + if (params.platform) { + where.platform = params.platform; + } + if (params.email) { + where.organization = { + users: { + some: { + user: { + email: { contains: params.email, mode: 'insensitive' }, + }, + }, + }, + }; + } + return where; + } + + private get include() { + return { + organization: { + select: { + id: true, + name: true, + users: { + select: { + user: { select: { id: true, email: true, name: true } }, + }, + }, + }, + }, + post: { select: { id: true, content: true } }, + } as const; + } + + async listPlatforms() { + const rows = await this._errors.model.errors.findMany({ + distinct: ['platform'], + select: { platform: true }, + orderBy: { platform: 'asc' }, + }); + return rows.map((r) => r.platform); + } + + async listErrors(params: ListErrorsParams) { + const page = Math.max(0, params.page || 0); + const limit = Math.min(Math.max(1, params.limit || 20), 100); + const skip = page * limit; + const where = this.buildWhere(params); + const include = this.include; + + if (!params.unknownFirst) { + const [items, total] = await Promise.all([ + this._errors.model.errors.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + include, + }), + this._errors.model.errors.count({ where }), + ]); + return { + items, + total, + page, + limit, + hasMore: skip + items.length < total, + }; + } + + const unknownWhere = { ...where, message: { contains: UNKNOWN_TOKEN } }; + const knownWhere = { + ...where, + NOT: { message: { contains: UNKNOWN_TOKEN } }, + }; + + const [unknownTotal, knownTotal] = await Promise.all([ + this._errors.model.errors.count({ where: unknownWhere }), + this._errors.model.errors.count({ where: knownWhere }), + ]); + + let unknownItems: any[] = []; + let knownItems: any[] = []; + + if (skip < unknownTotal) { + const takeUnknown = Math.min(unknownTotal - skip, limit); + unknownItems = await this._errors.model.errors.findMany({ + where: unknownWhere, + orderBy: { createdAt: 'desc' }, + skip, + take: takeUnknown, + include, + }); + const remaining = limit - unknownItems.length; + if (remaining > 0) { + knownItems = await this._errors.model.errors.findMany({ + where: knownWhere, + orderBy: { createdAt: 'desc' }, + skip: 0, + take: remaining, + include, + }); + } + } else { + knownItems = await this._errors.model.errors.findMany({ + where: knownWhere, + orderBy: { createdAt: 'desc' }, + skip: skip - unknownTotal, + take: limit, + include, + }); + } + + const items = [...unknownItems, ...knownItems]; + const total = unknownTotal + knownTotal; + return { + items, + total, + page, + limit, + hasMore: skip + items.length < total, + }; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/errors/errors.service.ts b/libraries/nestjs-libraries/src/database/prisma/errors/errors.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..63d3c3ea8625ec1e58ecfcc15f6a2d4d258770b4 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/errors/errors.service.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { ErrorsRepository } from '@gitroom/nestjs-libraries/database/prisma/errors/errors.repository'; + +@Injectable() +export class ErrorsService { + constructor(private _errorsRepository: ErrorsRepository) {} + + listErrors(params: { + page?: number; + limit?: number; + platform?: string; + email?: string; + unknownFirst?: boolean; + }) { + return this._errorsRepository.listErrors(params); + } + + listPlatforms() { + return this._errorsRepository.listPlatforms(); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..15434d6c83841318d1bc4766b8a5ad9ff66be30d --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.repository.ts @@ -0,0 +1,674 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +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'; + +@Injectable() +export class IntegrationRepository { + private storage = UploadFactory.createStorage(); + constructor( + private _integration: PrismaRepository<'integration'>, + private _posts: PrismaRepository<'post'>, + private _plugs: PrismaRepository<'plugs'>, + private _exisingPlugData: PrismaRepository<'exisingPlugData'>, + private _customers: PrismaRepository<'customer'>, + private _mentions: PrismaRepository<'mentions'> + ) {} + + getMentions(platform: string, q: string) { + return this._mentions.model.mentions.findMany({ + where: { + platform, + OR: [ + { + name: { + contains: q, + mode: 'insensitive', + }, + }, + { + username: { + contains: q, + mode: 'insensitive', + }, + }, + ], + }, + orderBy: { + name: 'asc', + }, + take: 100, + select: { + name: true, + username: true, + image: true, + }, + }); + } + + insertMentions( + platform: string, + mentions: { name: string; username: string; image: string }[] + ) { + if (mentions.length === 0) { + return [] as any[]; + } + return this._mentions.model.mentions.createMany({ + data: mentions.map((mention) => ({ + platform, + name: mention.name, + username: mention.username, + image: mention.image, + })), + skipDuplicates: true, + }); + } + + async checkPreviousConnections(org: string, id: string) { + const findIt = await this._integration.model.integration.findMany({ + where: { + rootInternalId: id, + }, + select: { + organizationId: true, + id: true, + }, + }); + + if (findIt.some((f) => f.organizationId === org)) { + return false; + } + + return findIt.length > 0; + } + + updateProviderSettings(org: string, id: string, settings: string) { + return this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: { + additionalSettings: settings, + }, + }); + } + + async setTimes(org: string, id: string, times: IntegrationTimeDto) { + return this._integration.model.integration.update({ + select: { + id: true, + }, + where: { + id, + organizationId: org, + }, + data: { + postingTimes: JSON.stringify(times.time), + }, + }); + } + + getPlug(plugId: string) { + return this._plugs.model.plugs.findFirst({ + where: { + id: plugId, + }, + include: { + integration: true, + }, + }); + } + + async getPlugs(orgId: string, integrationId: string) { + return this._plugs.model.plugs.findMany({ + where: { + integrationId, + organizationId: orgId, + activated: true, + }, + include: { + integration: { + select: { + id: true, + providerIdentifier: true, + }, + }, + }, + }); + } + + async updateIntegration(id: string, params: Partial) { + if ( + params.picture && + (params.picture.indexOf(process.env.CLOUDFLARE_BUCKET_URL!) === -1 || + params.picture.indexOf(process.env.FRONTEND_URL!) === -1) + ) { + params.picture = await this.storage.uploadSimple(params.picture); + } + + const existing = await this._integration.model.integration.findUnique({ + where: { + organizationId_internalId: { + organizationId: params.organizationId!, + internalId: params.internalId, + }, + }, + }); + + if (existing) { + await this._posts.model.post.updateMany({ + where: { + integrationId: id, + }, + data: { + deletedAt: new Date(), + }, + }); + + await this._integration.model.integration.update({ + where: { + id, + }, + data: { + internalId: `deleted_${params.internalId}_${makeId(10)}`, + deletedAt: new Date(), + }, + }); + } + + return this._integration.model.integration.update({ + where: { + ...(existing ? { id: existing.id } : { id }), + }, + data: { + ...params, + disabled: false, + deletedAt: null, + }, + }); + } + + disconnectChannel(org: string, id: string) { + return this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: { + refreshNeeded: true, + }, + }); + } + + async createOrUpdateIntegration( + additionalSettings: + | { + title: string; + description: string; + type: 'checkbox' | 'text' | 'textarea'; + value: any; + regex?: string; + }[] + | undefined, + oneTimeToken: boolean, + org: string, + name: string, + picture: string | undefined, + type: 'article' | 'social', + internalId: string, + provider: string, + token: string, + refreshToken = '', + expiresIn = 999999999, + username?: string, + isBetweenSteps = false, + refresh?: string, + timezone?: number, + customInstanceDetails?: string + ) { + const postTimes = timezone + ? { + postingTimes: JSON.stringify([ + { time: 560 - timezone }, + { time: 850 - timezone }, + { time: 1140 - timezone }, + ]), + } + : {}; + const upsert = await this._integration.model.integration.upsert({ + where: { + organizationId_internalId: { + internalId, + organizationId: org, + }, + }, + create: { + type: type as any, + name, + providerIdentifier: provider, + token, + profile: username, + ...(picture ? { picture } : {}), + inBetweenSteps: isBetweenSteps, + refreshToken, + ...(expiresIn + ? { tokenExpiration: new Date(Date.now() + expiresIn * 1000) } + : {}), + internalId, + ...postTimes, + organizationId: org, + refreshNeeded: false, + rootInternalId: internalId, + ...(customInstanceDetails ? { customInstanceDetails } : {}), + additionalSettings: additionalSettings + ? JSON.stringify(additionalSettings) + : '[]', + }, + update: { + ...(additionalSettings + ? { additionalSettings: JSON.stringify(additionalSettings) } + : {}), + ...(customInstanceDetails ? { customInstanceDetails } : {}), + type: type as any, + ...(!refresh + ? { + inBetweenSteps: isBetweenSteps, + } + : {}), + ...(picture ? { picture } : {}), + profile: username, + providerIdentifier: provider, + token, + refreshToken, + ...(expiresIn + ? { tokenExpiration: new Date(Date.now() + expiresIn * 1000) } + : {}), + internalId, + organizationId: org, + deletedAt: null, + refreshNeeded: false, + }, + }); + + if (oneTimeToken) { + const rootId = + ( + await this._integration.model.integration.findFirst({ + where: { + organizationId: org, + internalId: internalId, + }, + }) + )?.rootInternalId || internalId; + + await this._integration.model.integration.updateMany({ + where: { + id: { + not: upsert.id, + }, + rootInternalId: rootId, + }, + data: { + token, + refreshToken, + refreshNeeded: false, + ...(expiresIn + ? { tokenExpiration: new Date(Date.now() + expiresIn * 1000) } + : {}), + }, + }); + } + + return upsert; + } + + needsToBeRefreshed() { + return this._integration.model.integration.findMany({ + where: { + tokenExpiration: { + lte: dayjs().add(1, 'day').toDate(), + }, + inBetweenSteps: false, + deletedAt: null, + refreshNeeded: false, + }, + }); + } + + async setBetweenRefreshSteps(id: string) { + return this._integration.model.integration.update({ + where: { + id, + }, + data: { + inBetweenSteps: true, + }, + }); + } + refreshNeeded(org: string, id: string) { + return this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: { + refreshNeeded: true, + }, + }); + } + + updateNameAndUrl(id: string, name: string, url: string) { + return this._integration.model.integration.update({ + where: { + id, + }, + data: { + ...(name ? { name } : {}), + ...(url ? { picture: url } : {}), + }, + }); + } + + getIntegrationById(org: string, id: string) { + return this._integration.model.integration.findFirst({ + where: { + organizationId: org, + id, + }, + }); + } + + async getIntegrationForOrder( + id: string, + order: string, + user: string, + org: string + ) { + const integration = await this._posts.model.post.findFirst({ + where: { + integrationId: id, + submittedForOrder: { + id: order, + messageGroup: { + OR: [ + { sellerId: user }, + { buyerId: user }, + { buyerOrganizationId: org }, + ], + }, + }, + }, + select: { + integration: { + select: { + id: true, + name: true, + picture: true, + inBetweenSteps: true, + providerIdentifier: true, + }, + }, + }, + }); + + return integration?.integration; + } + + async updateOnCustomerName(org: string, id: string, name: string) { + const customer = !name + ? undefined + : (await this._customers.model.customer.findFirst({ + where: { + orgId: org, + name, + }, + })) || + (await this._customers.model.customer.create({ + data: { + name, + orgId: org, + }, + })); + + return this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: { + customer: !customer + ? { disconnect: true } + : { + connect: { + id: customer.id, + }, + }, + }, + }); + } + + updateIntegrationGroup(org: string, id: string, group: string) { + return this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: !group + ? { + customer: { + disconnect: true, + }, + } + : { + customer: { + connect: { + id: group, + }, + }, + }, + }); + } + + customers(orgId: string) { + return this._customers.model.customer.findMany({ + where: { + orgId, + deletedAt: null, + }, + }); + } + + getIntegrationsList(org: string) { + return this._integration.model.integration.findMany({ + where: { + organizationId: org, + deletedAt: null, + }, + include: { + customer: true, + }, + }); + } + + async disableChannel(org: string, id: string) { + await this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: { + disabled: true, + }, + }); + } + + async enableChannel(org: string, id: string) { + await this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: { + disabled: false, + }, + }); + } + + getPostsForChannel(org: string, id: string) { + return this._posts.model.post.groupBy({ + by: ['group'], + where: { + organizationId: org, + integrationId: id, + deletedAt: null, + }, + }); + } + + deleteChannel(org: string, id: string) { + return this._integration.model.integration.update({ + where: { + id, + organizationId: org, + }, + data: { + deletedAt: new Date(), + }, + }); + } + + async checkForDeletedOnceAndUpdate(org: string, page: string) { + return this._integration.model.integration.updateMany({ + where: { + organizationId: org, + internalId: page, + deletedAt: { + not: null, + }, + }, + data: { + internalId: makeId(10), + }, + }); + } + + async disableIntegrations(org: string, totalChannels: number) { + const getChannels = await this._integration.model.integration.findMany({ + where: { + organizationId: org, + disabled: false, + deletedAt: null, + }, + take: totalChannels, + select: { + id: true, + }, + }); + + for (const channel of getChannels) { + await this._integration.model.integration.update({ + where: { + id: channel.id, + }, + data: { + disabled: true, + }, + }); + } + } + + getPlugsByIntegrationId(org: string, id: string) { + return this._plugs.model.plugs.findMany({ + where: { + organizationId: org, + integrationId: id, + }, + }); + } + + createOrUpdatePlug(org: string, integrationId: string, body: PlugDto) { + return this._plugs.model.plugs.upsert({ + where: { + organizationId: org, + plugFunction_integrationId: { + integrationId, + plugFunction: body.func, + }, + }, + create: { + integrationId, + organizationId: org, + plugFunction: body.func, + data: JSON.stringify(body.fields), + activated: true, + }, + update: { + data: JSON.stringify(body.fields), + }, + select: { + activated: true, + }, + }); + } + + changePlugActivation(orgId: string, plugId: string, status: boolean) { + return this._plugs.model.plugs.update({ + where: { + organizationId: orgId, + id: plugId, + }, + data: { + activated: !!status, + }, + }); + } + + async loadExisingData( + methodName: string, + integrationId: string, + id: string[] + ) { + return this._exisingPlugData.model.exisingPlugData.findMany({ + where: { + integrationId, + methodName, + value: { + in: id, + }, + }, + }); + } + + async saveExisingData( + methodName: string, + integrationId: string, + value: string[] + ) { + return this._exisingPlugData.model.exisingPlugData.createMany({ + data: value.map((p) => ({ + integrationId, + methodName, + value: p, + })), + }); + } + + async getPostingTimes(orgId: string, integrationsId?: string) { + return this._integration.model.integration.findMany({ + where: { + ...(integrationsId ? { id: integrationsId } : {}), + organizationId: orgId, + disabled: false, + deletedAt: null, + }, + select: { + postingTimes: true, + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..de6dd1af1151aa589cce43507f9f742880c275d3 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/integrations/integration.service.ts @@ -0,0 +1,569 @@ +import { + forwardRef, + HttpException, + HttpStatus, + Inject, + Injectable, +} from '@nestjs/common'; +import { IntegrationRepository } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.repository'; +import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; +import { + AnalyticsData, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { Integration, Organization } from '@prisma/client'; +import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; +import dayjs from 'dayjs'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; +import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { 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 { 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); + +@Injectable() +export class IntegrationService { + private storage = UploadFactory.createStorage(); + constructor( + private _integrationRepository: IntegrationRepository, + private _autopostsRepository: AutopostRepository, + private _integrationManager: IntegrationManager, + private _notificationService: NotificationService, + @Inject(forwardRef(() => 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)) { + try { + await this._temporalService.terminateWorkflow(`autopost-${item.id}`); + } catch (err) {} + } + + return true; + } + + getMentions(platform: string, q: string) { + return this._integrationRepository.getMentions(platform, q); + } + + insertMentions( + platform: string, + mentions: { name: string; username: string; image: string }[] + ) { + return this._integrationRepository.insertMentions(platform, mentions); + } + + async setTimes( + orgId: string, + integrationId: string, + times: IntegrationTimeDto + ) { + return this._integrationRepository.setTimes(orgId, integrationId, times); + } + + updateProviderSettings(org: string, id: string, additionalSettings: string) { + return this._integrationRepository.updateProviderSettings( + org, + id, + additionalSettings + ); + } + + checkPreviousConnections(org: string, id: string) { + return this._integrationRepository.checkPreviousConnections(org, id); + } + + async createOrUpdateIntegration( + additionalSettings: + | { + title: string; + description: string; + type: 'checkbox' | 'text' | 'textarea'; + value: any; + regex?: string; + }[] + | undefined, + oneTimeToken: boolean, + org: string, + name: string, + picture: string | undefined, + type: 'article' | 'social', + internalId: string, + provider: string, + token: string, + refreshToken = '', + expiresIn?: number, + username?: string, + isBetweenSteps = false, + refresh?: string, + timezone?: number, + customInstanceDetails?: string + ) { + const uploadedPicture = picture + ? picture?.indexOf('imagedelivery.net') > -1 + ? picture + : await this.storage.uploadSimple(picture) + : undefined; + + return this._integrationRepository.createOrUpdateIntegration( + additionalSettings, + oneTimeToken, + org, + name, + uploadedPicture, + type, + internalId, + provider, + token, + refreshToken, + expiresIn, + username, + isBetweenSteps, + refresh, + timezone, + customInstanceDetails + ); + } + + updateIntegrationGroup(org: string, id: string, group: string) { + return this._integrationRepository.updateIntegrationGroup(org, id, group); + } + + updateOnCustomerName(org: string, id: string, name: string) { + return this._integrationRepository.updateOnCustomerName(org, id, name); + } + + getIntegrationsList(org: string) { + return this._integrationRepository.getIntegrationsList(org); + } + + getIntegrationForOrder(id: string, order: string, user: string, org: string) { + return this._integrationRepository.getIntegrationForOrder( + id, + order, + user, + org + ); + } + + updateNameAndUrl(id: string, name: string, url: string) { + return this._integrationRepository.updateNameAndUrl(id, name, url); + } + + getIntegrationById(org: string, id: string) { + return this._integrationRepository.getIntegrationById(org, id); + } + + async refreshToken(provider: SocialProvider, refresh: string) { + try { + const { refreshToken, accessToken, expiresIn } = + await provider.refreshToken(refresh); + + if (!refreshToken || !accessToken || !expiresIn) { + return false; + } + + return { refreshToken, accessToken, expiresIn }; + } catch (e) { + return false; + } + } + + async disconnectChannel(orgId: string, integration: Integration) { + await this._integrationRepository.disconnectChannel(orgId, integration.id); + await this.informAboutRefreshError(orgId, integration); + } + + async informAboutRefreshError( + orgId: string, + integration: Integration, + err = '' + ) { + await this._notificationService.inAppNotification( + orgId, + `Could not refresh your ${integration.providerIdentifier} channel ${err}`, + `Could not refresh your ${integration.providerIdentifier} channel ${err}. Please go back to the system and connect it again ${process.env.FRONTEND_URL}/launches`, + true, + false, + 'info' + ); + } + + async refreshNeeded(org: string, id: string) { + return this._integrationRepository.refreshNeeded(org, id); + } + + async setBetweenRefreshSteps(id: string) { + return this._integrationRepository.setBetweenRefreshSteps(id); + } + + async refreshTokens() { + const integrations = await this._integrationRepository.needsToBeRefreshed(); + for (const integration of integrations) { + const provider = this._integrationManager.getSocialIntegration( + integration.providerIdentifier + ); + + const data = await this.refreshToken(provider, integration.refreshToken!); + + if (!data) { + await this.informAboutRefreshError( + integration.organizationId, + integration + ); + await this._integrationRepository.refreshNeeded( + integration.organizationId, + integration.id + ); + return; + } + + const { refreshToken, accessToken, expiresIn } = data; + + await this.createOrUpdateIntegration( + undefined, + !!provider.oneTimeToken, + integration.organizationId, + integration.name, + undefined, + 'social', + integration.internalId, + integration.providerIdentifier, + accessToken, + refreshToken, + expiresIn + ); + } + } + + async disableChannel(org: string, id: string) { + return this._integrationRepository.disableChannel(org, id); + } + + async enableChannel(org: string, totalChannels: number, id: string) { + const integrations = ( + await this._integrationRepository.getIntegrationsList(org) + ).filter((f) => !f.disabled); + if ( + !!process.env.STRIPE_PUBLISHABLE_KEY && + integrations.length >= totalChannels + ) { + throw new Error('You have reached the maximum number of channels'); + } + + return this._integrationRepository.enableChannel(org, id); + } + + async getPostsForChannel(org: string, id: string) { + return this._integrationRepository.getPostsForChannel(org, id); + } + + async deleteChannel(org: string, id: string) { + return this._integrationRepository.deleteChannel(org, id); + } + + async disableIntegrations(org: string, totalChannels: number) { + return this._integrationRepository.disableIntegrations(org, totalChannels); + } + + async checkForDeletedOnceAndUpdate(org: string, page: string) { + return this._integrationRepository.checkForDeletedOnceAndUpdate(org, page); + } + + async saveProviderPage(org: string, id: string, data: any) { + const getIntegration = await this._integrationRepository.getIntegrationById( + org, + id + ); + if (!getIntegration) { + throw new HttpException('Integration not found', HttpStatus.NOT_FOUND); + } + if (!getIntegration.inBetweenSteps) { + throw new HttpException('Invalid request', HttpStatus.BAD_REQUEST); + } + + const provider = this._integrationManager.getSocialIntegration( + getIntegration.providerIdentifier + ); + + if (!provider.fetchPageInformation) { + throw new HttpException( + 'Provider does not support page selection', + HttpStatus.BAD_REQUEST + ); + } + + const getIntegrationInformation = await provider.fetchPageInformation( + getIntegration.token, + data + ); + + await this.checkForDeletedOnceAndUpdate( + org, + String(getIntegrationInformation.id) + ); + await this._integrationRepository.updateIntegration(id, { + picture: getIntegrationInformation.picture, + internalId: String(getIntegrationInformation.id), + organizationId: org, + name: getIntegrationInformation.name, + inBetweenSteps: false, + token: getIntegrationInformation.access_token, + profile: getIntegrationInformation.username, + }); + + return { success: true }; + } + + async checkAnalytics( + org: Organization, + integration: string, + date: string, + forceRefresh = false + ): Promise { + const getIntegration = await this.getIntegrationById(org.id, integration); + + if (!getIntegration) { + throw new Error('Invalid integration'); + } + + if (getIntegration.type !== 'social') { + return []; + } + + const integrationProvider = this._integrationManager.getSocialIntegration( + getIntegration.providerIdentifier + ); + + if ( + dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) || + forceRefresh + ) { + const data = await this._refreshIntegrationService.refresh( + getIntegration + ); + if (!data) { + return []; + } + + const { accessToken } = data; + + if (accessToken) { + getIntegration.token = accessToken; + + if (integrationProvider.refreshWait) { + await timer(10000); + } + } else { + await this.disconnectChannel(org.id, getIntegration); + return []; + } + } + + const getIntegrationData = await ioRedis.get( + `integration:${org.id}:${integration}:${date}` + ); + if (getIntegrationData) { + return JSON.parse(getIntegrationData); + } + + if (integrationProvider.analytics) { + try { + const loadAnalytics = await integrationProvider.analytics( + getIntegration.internalId, + getIntegration.token, + +date + ); + await ioRedis.set( + `integration:${org.id}:${integration}:${date}`, + JSON.stringify(loadAnalytics), + 'EX', + !process.env.NODE_ENV || process.env.NODE_ENV === 'development' + ? 1 + : 3600 + ); + return loadAnalytics; + } catch (e) { + if (e instanceof RefreshToken) { + return this.checkAnalytics(org, integration, date, true); + } + } + } + + return []; + } + + customers(orgId: string) { + return this._integrationRepository.customers(orgId); + } + + getPlugsByIntegrationId(org: string, integrationId: string) { + return this._integrationRepository.getPlugsByIntegrationId( + org, + integrationId + ); + } + + async processInternalPlug( + data: { + post: string; + originalIntegration: string; + integration: string; + plugName: string; + orgId: string; + delay: number; + information: any; + }, + forceRefresh = false + ): Promise { + const originalIntegration = + await this._integrationRepository.getIntegrationById( + data.orgId, + data.originalIntegration + ); + + const getIntegration = await this._integrationRepository.getIntegrationById( + data.orgId, + data.integration + ); + + if (!getIntegration || !originalIntegration) { + return; + } + + const getAllInternalPlugs = this._integrationManager + .getInternalPlugs(getIntegration.providerIdentifier) + .internalPlugs.find((p: any) => p.identifier === data.plugName); + + if (!getAllInternalPlugs) { + return; + } + + const getSocialIntegration = this._integrationManager.getSocialIntegration( + getIntegration.providerIdentifier + ); + + // @ts-ignore + await getSocialIntegration?.[getAllInternalPlugs.methodName]?.( + getIntegration, + originalIntegration, + data.post, + data.information + ); + + return; + } + + async processPlugs(data: { + plugId: string; + postId: string; + delay: number; + totalRuns: number; + currentRun: number; + }) { + const getPlugById = await this._integrationRepository.getPlug(data.plugId); + if (!getPlugById) { + return true; + } + + const integration = this._integrationManager.getSocialIntegration( + getPlugById.integration.providerIdentifier + ); + + // @ts-ignore + const process = await integration[getPlugById.plugFunction]( + getPlugById.integration, + data.postId, + JSON.parse(getPlugById.data).reduce((all: any, current: any) => { + all[current.name] = current.value; + return all; + }, {}) + ); + + if (process) { + return true; + } + + if (data.totalRuns === data.currentRun) { + return true; + } + + return false; + } + + async createOrUpdatePlug( + orgId: string, + integrationId: string, + body: PlugDto + ) { + const { activated } = await this._integrationRepository.createOrUpdatePlug( + orgId, + integrationId, + body + ); + + return { + activated, + }; + } + + async changePlugActivation(orgId: string, plugId: string, status: boolean) { + const { id, integrationId, plugFunction } = + await this._integrationRepository.changePlugActivation( + orgId, + plugId, + status + ); + + return { id }; + } + + async getPlugs(orgId: string, integrationId: string) { + return this._integrationRepository.getPlugs(orgId, integrationId); + } + + async loadExisingData( + methodName: string, + integrationId: string, + id: string[] + ) { + const exisingData = await this._integrationRepository.loadExisingData( + methodName, + integrationId, + id + ); + const loadOnlyIds = exisingData.map((p) => p.value); + return difference(id, loadOnlyIds); + } + + async findFreeDateTime( + orgId: string, + integrationsId?: string + ): Promise { + const findTimes = await this._integrationRepository.getPostingTimes( + orgId, + integrationsId + ); + return uniq( + findTimes.reduce((all: any, current: any) => { + return [ + ...all, + ...JSON.parse(current.postingTimes).map( + (p: { time: number }) => p.time + ), + ]; + }, [] as number[]) + ); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..208def20f64b3b69052e6a2cbc5e60a33ec9f2fd --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/media/media.repository.ts @@ -0,0 +1,123 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { SaveMediaInformationDto } from '@gitroom/nestjs-libraries/dtos/media/save.media.information.dto'; + +@Injectable() +export class MediaRepository { + constructor(private _media: PrismaRepository<'media'>) {} + + saveFile(org: string, fileName: string, filePath: string, originalName?: string) { + return this._media.model.media.create({ + data: { + organization: { + connect: { + id: org, + }, + }, + name: fileName, + path: filePath, + originalName: originalName || null, + }, + select: { + id: true, + name: true, + originalName: true, + path: true, + thumbnail: true, + alt: true, + }, + }); + } + + getMediaById(id: string) { + return this._media.model.media.findUnique({ + where: { + id, + }, + }); + } + + deleteMedia(org: string, id: string) { + return this._media.model.media.update({ + where: { + id, + organizationId: org, + }, + data: { + deletedAt: new Date(), + }, + }); + } + + saveMediaInformation(org: string, data: SaveMediaInformationDto) { + return this._media.model.media.update({ + where: { + id: data.id, + organizationId: org, + }, + data: { + alt: data.alt, + thumbnail: data.thumbnail, + thumbnailTimestamp: data.thumbnailTimestamp, + }, + select: { + id: true, + name: true, + originalName: true, + alt: true, + thumbnail: true, + path: true, + thumbnailTimestamp: true, + }, + }); + } + + async getMedia(org: string, page: number, search?: string) { + const pageNum = (page || 1) - 1; + const trimmedSearch = search?.trim(); + const searchFilter = trimmedSearch + ? { + originalName: { + contains: trimmedSearch, + mode: 'insensitive' as const, + }, + } + : {}; + const query = { + where: { + organization: { + id: org, + }, + deletedAt: null, + ...searchFilter, + }, + }; + const pages = Math.ceil((await this._media.model.media.count(query)) / 18); + const results = await this._media.model.media.findMany({ + where: { + organizationId: org, + deletedAt: null, + ...searchFilter, + }, + orderBy: { + createdAt: 'desc', + }, + select: { + id: true, + name: true, + originalName: true, + path: true, + thumbnail: true, + alt: true, + thumbnailTimestamp: true, + }, + skip: pageNum * 18, + take: 18, + }); + + return { + pages, + results, + }; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts b/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d1164a9192767816d84c10c4d72f3155bc441c1 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/media/media.service.ts @@ -0,0 +1,157 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { MediaRepository } from '@gitroom/nestjs-libraries/database/prisma/media/media.repository'; +import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service'; +import { generationError } from '@gitroom/nestjs-libraries/openai/generation.error'; +import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { Organization } from '@prisma/client'; +import { SaveMediaInformationDto } from '@gitroom/nestjs-libraries/dtos/media/save.media.information.dto'; +import { VideoManager } from '@gitroom/nestjs-libraries/videos/video.manager'; +import { VideoDto } from '@gitroom/nestjs-libraries/dtos/videos/video.dto'; +import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; +import { + AuthorizationActions, + Sections, + SubscriptionException, +} from '@gitroom/backend/services/auth/permissions/permission.exception.class'; + +@Injectable() +export class MediaService { + private storage = UploadFactory.createStorage(); + + constructor( + private _mediaRepository: MediaRepository, + private _openAi: OpenaiService, + private _subscriptionService: SubscriptionService, + private _videoManager: VideoManager + ) {} + + async deleteMedia(org: string, id: string) { + return this._mediaRepository.deleteMedia(org, id); + } + + getMediaById(id: string) { + return this._mediaRepository.getMediaById(id); + } + + async generateImage( + prompt: string, + org: Organization, + generatePromptFirst?: boolean + ) { + try { + const generating = await this._subscriptionService.useCredit( + org, + 'ai_images', + async () => { + if (generatePromptFirst) { + prompt = await this._openAi.generatePromptForPicture(prompt); + console.log('Prompt:', prompt); + } + return this._openAi.generateImage(prompt); + } + ); + + return generating; + } catch (err) { + throw generationError(err); + } + } + + saveFile(org: string, fileName: string, filePath: string, originalName?: string) { + return this._mediaRepository.saveFile(org, fileName, filePath, originalName); + } + + getMedia(org: string, page: number, search?: string) { + return this._mediaRepository.getMedia(org, page, search); + } + + saveMediaInformation(org: string, data: SaveMediaInformationDto) { + return this._mediaRepository.saveMediaInformation(org, data); + } + + getVideoOptions() { + return this._videoManager.getAllVideos(); + } + + async generateVideoAllowed(org: Organization, type: string) { + const video = this._videoManager.getVideoByName(type); + if (!video) { + throw new Error(`Video type ${type} not found`); + } + + if (!video.trial && org.isTrailing) { + throw new HttpException('This video is not available in trial mode', 406); + } + + return true; + } + + async generateVideo(org: Organization, body: VideoDto) { + try { + const totalCredits = await this._subscriptionService.checkCredits( + org, + 'ai_videos' + ); + + if (totalCredits.credits <= 0) { + throw new SubscriptionException({ + action: AuthorizationActions.Create, + section: Sections.VIDEOS_PER_MONTH, + }); + } + + const video = this._videoManager.getVideoByName(body.type); + if (!video) { + throw new Error(`Video type ${body.type} not found`); + } + + if (!video.trial && org.isTrailing) { + throw new HttpException( + 'This video is not available in trial mode', + 406 + ); + } + + console.log(body.customParams); + await video.instance.processAndValidate(body.customParams); + console.log('no err'); + + return await this._subscriptionService.useCredit( + org, + 'ai_videos', + async () => { + const loadedData = await video.instance.process( + body.output, + body.customParams + ); + + const file = await this.storage.uploadSimple(loadedData); + return this.saveFile(org.id, file.split('/').pop(), file); + } + ); + } catch (err) { + throw generationError(err); + } + } + + async videoFunction(identifier: string, functionName: string, body: any) { + const video = this._videoManager.getVideoByName(identifier); + if (!video) { + throw new Error(`Video with identifier ${identifier} not found`); + } + + // @ts-ignore + const functionToCall = video.instance[functionName]; + if ( + typeof functionToCall !== 'function' || + this._videoManager.checkAvailableVideoFunction(functionToCall) + ) { + throw new HttpException( + `Function ${functionName} not found on video instance`, + 400 + ); + } + + return functionToCall(body); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/notifications/notification.service.ts b/libraries/nestjs-libraries/src/database/prisma/notifications/notification.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..31cd52fb472476e5dfd85e6348f726e986c5714f --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/notifications/notification.service.ts @@ -0,0 +1,117 @@ +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 { 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'; + +@Injectable() +export class NotificationService { + constructor( + private _notificationRepository: NotificationsRepository, + private _emailService: EmailService, + private _organizationRepository: OrganizationRepository, + private _temporalService: TemporalService + ) {} + + getMainPageCount(organizationId: string, userId: string) { + return this._notificationRepository.getMainPageCount( + organizationId, + userId + ); + } + + getNotificationsPaginated(organizationId: string, page: number) { + return this._notificationRepository.getNotificationsPaginated( + organizationId, + page + ); + } + + getNotifications(organizationId: string, userId: string) { + return this._notificationRepository.getNotifications( + organizationId, + userId + ); + } + + async inAppNotification( + orgId: string, + subject: string, + message: string, + sendEmail = false, + digest = false, + type: NotificationType = 'success' + ) { + await this._notificationRepository.createNotification(orgId, message); + if (!sendEmail) { + return; + } + + if (digest) { + try { + await this._temporalService.client + .getRawClient() + ?.workflow.signalWithStart('digestEmailWorkflow', { + workflowId: 'digest_email_workflow_' + orgId, + signal: 'email', + signalArgs: [ + [ + { + title: subject, + message, + type, + }, + ], + ], + taskQueue: 'main', + workflowIdConflictPolicy: 'USE_EXISTING', + args: [{ organizationId: orgId }], + typedSearchAttributes: new TypedSearchAttributes([ + { + key: organizationId, + value: orgId, + }, + ]), + }); + } catch (err) {} + + return; + } + + await this.sendEmailsToOrg(orgId, subject, message, type); + } + + async sendEmailsToOrg( + orgId: string, + subject: string, + message: string, + type?: NotificationType + ) { + const userOrg = await this._organizationRepository.getAllUsersOrgs(orgId); + for (const user of userOrg?.users || []) { + // 'info' type is always sent regardless of preferences + if (type !== 'info') { + // Filter users based on their email preferences + if (type === 'success' && !user.user.sendSuccessEmails) { + continue; + } + if (type === 'fail' && !user.user.sendFailureEmails) { + continue; + } + } + await this.sendEmail(user.user.email, subject, message); + } + } + + async sendEmail(to: string, subject: string, html: string, replyTo?: string) { + await this._emailService.sendEmail(to, subject, html, 'top', replyTo); + } + + hasEmailProvider() { + return this._emailService.hasProvider(); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/notifications/notifications.repository.ts b/libraries/nestjs-libraries/src/database/prisma/notifications/notifications.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..958fe58a259c07f0d4fc672de95a25f9729b6682 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/notifications/notifications.repository.ts @@ -0,0 +1,126 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class NotificationsRepository { + constructor( + private _notifications: PrismaRepository<'notifications'>, + private _user: PrismaRepository<'user'> + ) {} + + getLastReadNotification(userId: string) { + return this._user.model.user.findFirst({ + where: { + id: userId, + }, + select: { + lastReadNotifications: true, + }, + }); + } + + async getMainPageCount(organizationId: string, userId: string) { + const { lastReadNotifications } = (await this.getLastReadNotification( + userId + ))!; + + return { + total: await this._notifications.model.notifications.count({ + where: { + organizationId, + createdAt: { + gt: lastReadNotifications!, + }, + }, + }), + }; + } + + async createNotification(organizationId: string, content: string) { + await this._notifications.model.notifications.create({ + data: { + organizationId, + content, + }, + }); + } + + async getNotificationsSince(organizationId: string, since: string) { + return this._notifications.model.notifications.findMany({ + where: { + organizationId, + createdAt: { + gte: new Date(since), + }, + }, + }); + } + + async getNotificationsPaginated(organizationId: string, page: number) { + const limit = 100; + const skip = page * limit; + + const where = { + organizationId, + deletedAt: null as Date | null, + }; + + const [notifications, total] = await Promise.all([ + this._notifications.model.notifications.findMany({ + where, + orderBy: { + createdAt: 'desc', + }, + skip, + take: limit, + select: { + id: true, + content: true, + link: true, + createdAt: true, + }, + }), + this._notifications.model.notifications.count({ where }), + ]); + + return { + notifications, + total, + page, + limit, + hasMore: skip + notifications.length < total, + }; + } + + async getNotifications(organizationId: string, userId: string) { + const { lastReadNotifications } = (await this.getLastReadNotification( + userId + ))!; + + await this._user.model.user.update({ + where: { + id: userId, + }, + data: { + lastReadNotifications: new Date(), + }, + }); + + return { + lastReadNotifications, + notifications: await this._notifications.model.notifications.findMany({ + orderBy: { + createdAt: 'desc', + }, + take: 10, + where: { + organizationId, + }, + select: { + createdAt: true, + content: true, + }, + }), + }; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.repository.ts b/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3acf6c1f65872b7a3b5412366bd10173b9f5adb --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.repository.ts @@ -0,0 +1,252 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; + +@Injectable() +export class OAuthRepository { + constructor( + private _oauthApp: PrismaRepository<'oAuthApp'>, + private _oauthAuth: PrismaRepository<'oAuthAuthorization'> + ) {} + + getAppByOrgId(orgId: string) { + return this._oauthApp.model.oAuthApp.findFirst({ + where: { + organizationId: orgId, + deletedAt: null, + }, + include: { + picture: true, + }, + }); + } + + getAppByClientId(clientId: string) { + return this._oauthApp.model.oAuthApp.findFirst({ + where: { + clientId, + deletedAt: null, + }, + include: { + picture: true, + }, + }); + } + + createApp( + orgId: string, + data: { + name: string; + description?: string; + pictureId?: string; + redirectUrl: string; + clientId: string; + clientSecret: string; + } + ) { + return this._oauthApp.model.oAuthApp.create({ + data: { + organizationId: orgId, + name: data.name, + description: data.description, + pictureId: data.pictureId, + redirectUrl: data.redirectUrl, + clientId: data.clientId, + clientSecret: data.clientSecret, + }, + include: { + picture: true, + }, + }); + } + + async updateApp( + orgId: string, + data: { + name?: string; + description?: string; + pictureId?: string; + redirectUrl?: string; + } + ) { + const app = await this._oauthApp.model.oAuthApp.findFirst({ + where: { + organizationId: orgId, + deletedAt: null, + }, + }); + if (!app) { + return null; + } + return this._oauthApp.model.oAuthApp.update({ + where: { id: app.id }, + data, + include: { + picture: true, + }, + }); + } + + async deleteApp(orgId: string) { + const app = await this._oauthApp.model.oAuthApp.findFirst({ + where: { + organizationId: orgId, + deletedAt: null, + }, + }); + if (!app) { + return null; + } + return this._oauthApp.model.oAuthApp.update({ + where: { id: app.id }, + data: { + deletedAt: new Date(), + }, + }); + } + + async updateClientSecret(orgId: string, newSecret: string) { + const app = await this._oauthApp.model.oAuthApp.findFirst({ + where: { + organizationId: orgId, + deletedAt: null, + }, + }); + if (!app) { + return null; + } + return this._oauthApp.model.oAuthApp.update({ + where: { id: app.id }, + data: { + clientSecret: newSecret, + }, + }); + } + + createAuthorization(data: { + oauthAppId: string; + userId: string; + organizationId: string; + authorizationCode: string; + codeExpiresAt: Date; + }) { + return this._oauthAuth.model.oAuthAuthorization.upsert({ + where: { + oauthAppId_userId_organizationId: { + oauthAppId: data.oauthAppId, + userId: data.userId, + organizationId: data.organizationId, + }, + }, + create: { + oauthAppId: data.oauthAppId, + userId: data.userId, + organizationId: data.organizationId, + authorizationCode: data.authorizationCode, + codeExpiresAt: data.codeExpiresAt, + }, + update: { + authorizationCode: data.authorizationCode, + codeExpiresAt: data.codeExpiresAt, + accessToken: null, + revokedAt: null, + }, + }); + } + + findByCode(encryptedCode: string) { + return this._oauthAuth.model.oAuthAuthorization.findFirst({ + where: { + authorizationCode: encryptedCode, + revokedAt: null, + }, + }); + } + + exchangeCodeForToken(id: string, encryptedToken: string) { + return this._oauthAuth.model.oAuthAuthorization.update({ + where: { id }, + select: { + organizationId: true, + organization: { + select: { + paymentId: true, + } + } + }, + data: { + accessToken: encryptedToken, + authorizationCode: null, + codeExpiresAt: null, + }, + }); + } + + findByAccessToken(encryptedToken: string) { + return this._oauthAuth.model.oAuthAuthorization.findFirst({ + where: { + accessToken: encryptedToken, + revokedAt: null, + }, + include: { + organization: { + include: { + subscription: { + select: { + subscriptionTier: true, + totalChannels: true, + isLifetime: true, + }, + }, + }, + }, + user: { + select: { id: true }, + }, + }, + }); + } + + getApprovedApps(userId: string) { + return this._oauthAuth.model.oAuthAuthorization.findMany({ + where: { + userId, + revokedAt: null, + accessToken: { not: null }, + }, + include: { + oauthApp: { + include: { + picture: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + revokeAuthorization(userId: string, authId: string) { + return this._oauthAuth.model.oAuthAuthorization.update({ + where: { + id: authId, + userId, + }, + data: { + revokedAt: new Date(), + }, + }); + } + + revokeAllForApp(oauthAppId: string) { + return this._oauthAuth.model.oAuthAuthorization.updateMany({ + where: { + oauthAppId, + revokedAt: null, + }, + data: { + revokedAt: new Date(), + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts b/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a1033dd46ad9f1a5e6b9c46d7fdfbca5553d59c --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/oauth/oauth.service.ts @@ -0,0 +1,170 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { OAuthRepository } from '@gitroom/nestjs-libraries/database/prisma/oauth/oauth.repository'; +import { CreateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/create-oauth-app.dto'; +import { UpdateOAuthAppDto } from '@gitroom/nestjs-libraries/dtos/oauth/update-oauth-app.dto'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; + +@Injectable() +export class OAuthService { + constructor(private _oauthRepository: OAuthRepository) {} + + async getApp(orgId: string) { + const app = await this._oauthRepository.getAppByOrgId(orgId); + if (!app) return false; + const { clientSecret, ...rest } = app; + return rest; + } + + async createApp(orgId: string, dto: CreateOAuthAppDto) { + const existing = await this._oauthRepository.getAppByOrgId(orgId); + if (existing) { + throw new HttpException( + 'You can only have one OAuth application per organization', + HttpStatus.BAD_REQUEST + ); + } + + const clientId = 'pca_' + makeId(32); + const clientSecret = 'pcs_' + makeId(48); + const encryptedSecret = AuthService.fixedEncryption(clientSecret); + + const app = await this._oauthRepository.createApp(orgId, { + name: dto.name, + description: dto.description, + pictureId: dto.pictureId, + redirectUrl: dto.redirectUrl, + clientId, + clientSecret: encryptedSecret, + }); + + return { ...app, clientSecret }; + } + + async updateApp(orgId: string, dto: UpdateOAuthAppDto) { + return this._oauthRepository.updateApp(orgId, { + ...(dto.name && { name: dto.name }), + ...(dto.description !== undefined && { description: dto.description }), + ...(dto.pictureId !== undefined && { pictureId: dto.pictureId }), + ...(dto.redirectUrl && { redirectUrl: dto.redirectUrl }), + }); + } + + async deleteApp(orgId: string) { + const app = await this._oauthRepository.getAppByOrgId(orgId); + if (!app) { + throw new HttpException('No OAuth app found', HttpStatus.NOT_FOUND); + } + await this._oauthRepository.revokeAllForApp(app.id); + await this._oauthRepository.deleteApp(orgId); + return { success: true }; + } + + async rotateSecret(orgId: string) { + const app = await this._oauthRepository.getAppByOrgId(orgId); + if (!app) { + throw new HttpException('No OAuth app found', HttpStatus.NOT_FOUND); + } + + const newSecret = 'pcs_' + makeId(48); + const encrypted = AuthService.fixedEncryption(newSecret); + await this._oauthRepository.updateClientSecret(orgId, encrypted); + return { clientSecret: newSecret }; + } + + async validateAuthorizationRequest(clientId: string) { + const app = await this._oauthRepository.getAppByClientId(clientId); + if (!app) { + throw new HttpException('Invalid client_id', HttpStatus.BAD_REQUEST); + } + return app; + } + + async createAuthorizationCode( + oauthAppId: string, + userId: string, + organizationId: string + ) { + const code = makeId(32); + const encryptedCode = AuthService.fixedEncryption(code); + const codeExpiresAt = new Date(Date.now() + 10 * 60 * 1000); + + await this._oauthRepository.createAuthorization({ + oauthAppId, + userId, + organizationId, + authorizationCode: encryptedCode, + codeExpiresAt, + }); + + return code; + } + + async exchangeCodeForToken( + code: string, + clientId: string, + clientSecret: string + ) { + const app = await this._oauthRepository.getAppByClientId(clientId); + if (!app) { + throw new HttpException( + { error: 'invalid_client' }, + HttpStatus.UNAUTHORIZED + ); + } + + if (app.clientSecret !== AuthService.fixedEncryption(clientSecret)) { + throw new HttpException( + { error: 'invalid_client' }, + HttpStatus.UNAUTHORIZED + ); + } + + const encryptedCode = AuthService.fixedEncryption(code); + const auth = await this._oauthRepository.findByCode(encryptedCode); + if (!auth || auth.oauthAppId !== app.id) { + throw new HttpException( + { error: 'invalid_grant' }, + HttpStatus.BAD_REQUEST + ); + } + + if (!auth.codeExpiresAt || new Date() > auth.codeExpiresAt) { + throw new HttpException( + { error: 'invalid_grant', error_description: 'Code has expired' }, + HttpStatus.BAD_REQUEST + ); + } + + const token = 'pos_' + makeId(40); + const encryptedToken = AuthService.fixedEncryption(token); + const { + organizationId, + organization: { paymentId }, + } = await this._oauthRepository.exchangeCodeForToken( + auth.id, + encryptedToken + ); + + return { + id: organizationId, + cus: paymentId, + access_token: token, + token_type: 'bearer', + }; + } + + async getOrgByOAuthToken(token: string) { + const encrypted = AuthService.fixedEncryption(token); + return this._oauthRepository.findByAccessToken(encrypted); + } + + async getApprovedApps(userId: string) { + return this._oauthRepository.getApprovedApps(userId); + } + + async revokeApp(userId: string, authId: string) { + await this._oauthRepository.revokeAuthorization(userId, authId); + return { success: true }; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..a18bda09a6d6505bfa8c235b077a1fa17f638835 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.repository.ts @@ -0,0 +1,422 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Role, ShortLinkPreference, SubscriptionTier } from '@prisma/client'; +import { Injectable } from '@nestjs/common'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; +import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; + +@Injectable() +export class OrganizationRepository { + constructor( + private _organization: PrismaRepository<'organization'>, + private _userOrg: PrismaRepository<'userOrganization'>, + private _user: PrismaRepository<'user'> + ) {} + + createMaxUser(id: string, name: string, saasName: string, email: string) { + return this._organization.model.organization.create({ + select: { + id: true, + apiKey: true, + }, + data: { + name: name ? `${name}###${id}` : `Unnamed User###${id}`, + apiKey: AuthService.fixedEncryption(makeId(20)), + isTrailing: false, + subscription: { + create: { + totalChannels: 1000000, + subscriptionTier: 'ULTIMATE', + isLifetime: true, + period: 'YEARLY', + }, + }, + users: { + create: { + role: Role.SUPERADMIN, + user: { + create: { + activated: true, + email: email + ? email.split('@').join(`+${saasName}@`) + : `${saasName}+` + makeId(10) + '@postiz.com', + name: name ? `${name}###${id}` : `Unnamed User###${id}`, + providerName: 'LOCAL', + password: AuthService.hashPassword(makeId(500)), + timezone: 0, + }, + }, + }, + }, + }, + }); + } + + getOrgByApiKey(api: string) { + return this._organization.model.organization.findFirst({ + where: { + apiKey: api, + }, + include: { + subscription: { + select: { + subscriptionTier: true, + totalChannels: true, + isLifetime: true, + }, + }, + }, + }); + } + + getCount() { + return this._organization.model.organization.count(); + } + + getUserOrg(id: string) { + return this._userOrg.model.userOrganization.findFirst({ + where: { + id, + }, + select: { + user: true, + organization: { + include: { + users: { + select: { + id: true, + disabled: true, + role: true, + userId: true, + }, + }, + subscription: { + select: { + subscriptionTier: true, + totalChannels: true, + isLifetime: true, + }, + }, + }, + }, + }, + }); + } + + getImpersonateUser(name: string) { + return this._userOrg.model.userOrganization.findMany({ + where: { + OR: [ + { + organizationId: { + contains: name, + }, + }, + { + user: { + OR: [ + { + name: { + contains: name, + }, + }, + { + email: { + contains: name, + }, + }, + { + id: { + contains: name, + }, + }, + ], + }, + }, + ], + }, + select: { + id: true, + organization: { + select: { + id: true, + }, + }, + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }); + } + + updateApiKey(orgId: string) { + return this._organization.model.organization.update({ + where: { + id: orgId, + }, + data: { + apiKey: AuthService.fixedEncryption(makeId(20)), + }, + }); + } + + async getOrgsByUserId(userId: string) { + return this._organization.model.organization.findMany({ + where: { + users: { + some: { + userId, + }, + }, + }, + include: { + users: { + where: { + userId, + }, + select: { + disabled: true, + role: true, + }, + }, + subscription: { + select: { + subscriptionTier: true, + totalChannels: true, + isLifetime: true, + createdAt: true, + }, + }, + }, + }); + } + + async getOrgById(id: string) { + return this._organization.model.organization.findUnique({ + where: { + id, + }, + }); + } + + async addUserToOrg( + userId: string, + id: string, + orgId: string, + role: 'USER' | 'ADMIN' + ) { + const checkIfInviteExists = await this._user.model.user.findFirst({ + where: { + inviteId: id, + }, + }); + + if (checkIfInviteExists) { + return false; + } + + const checkForSubscription = + await this._organization.model.organization.findFirst({ + where: { + id: orgId, + }, + select: { + subscription: true, + }, + }); + + if ( + process.env.STRIPE_PUBLISHABLE_KEY && + checkForSubscription?.subscription?.subscriptionTier === + SubscriptionTier.STANDARD + ) { + return false; + } + + const create = await this._userOrg.model.userOrganization.create({ + data: { + role, + userId, + organizationId: orgId, + }, + }); + + await this._user.model.user.update({ + where: { + id: userId, + }, + data: { + inviteId: id, + }, + }); + + return create; + } + + async createOrgAndUser( + body: Omit & { providerId?: string }, + hasEmail: boolean, + ip: string, + userAgent: string + ) { + return this._organization.model.organization.create({ + data: { + name: body.company, + apiKey: AuthService.fixedEncryption(makeId(20)), + allowTrial: true, + isTrailing: true, + users: { + create: { + role: Role.SUPERADMIN, + user: { + create: { + activated: body.provider !== 'LOCAL' || !hasEmail, + email: body.email, + password: body.password + ? AuthService.hashPassword(body.password) + : '', + providerName: body.provider, + providerId: body.providerId || '', + timezone: 0, + ip, + agent: userAgent, + }, + }, + }, + }, + }, + select: { + id: true, + users: { + select: { + user: true, + }, + }, + }, + }); + } + + getOrgByCustomerId(customerId: string) { + return this._organization.model.organization.findFirst({ + where: { + paymentId: customerId, + }, + }); + } + + async setStreak(organizationId: string, type: 'start' | 'end') { + try { + await this._organization.model.organization.update({ + where: { + id: organizationId, + ...(type === 'start' + ? { + streakSince: null, + } + : {}), + }, + data: { + ...(type === 'end' ? { streakSince: null } : {}), + ...(type === 'start' ? { streakSince: new Date() } : {}), + }, + }); + } catch (err) {} + } + + async getTeam(orgId: string) { + return this._organization.model.organization.findUnique({ + where: { + id: orgId, + }, + select: { + users: { + select: { + role: true, + user: { + select: { + email: true, + id: true, + sendSuccessEmails: true, + sendFailureEmails: true, + sendStreakEmails: true, + }, + }, + }, + }, + }, + }); + } + + getAllUsersOrgs(orgId: string) { + return this._organization.model.organization.findUnique({ + where: { + id: orgId, + }, + select: { + users: { + select: { + user: { + select: { + email: true, + id: true, + sendSuccessEmails: true, + sendFailureEmails: true, + }, + }, + }, + }, + }, + }); + } + + async deleteTeamMember(orgId: string, userId: string) { + return this._userOrg.model.userOrganization.delete({ + where: { + userId_organizationId: { + userId, + organizationId: orgId, + }, + }, + }); + } + + disableOrEnableNonSuperAdminUsers(orgId: string, disable: boolean) { + return this._userOrg.model.userOrganization.updateMany({ + where: { + organizationId: orgId, + role: { + not: Role.SUPERADMIN, + }, + }, + data: { + disabled: disable, + }, + }); + } + + getShortlinkPreference(orgId: string) { + return this._organization.model.organization.findUnique({ + where: { + id: orgId, + }, + select: { + shortlink: true, + }, + }); + } + + updateShortlinkPreference(orgId: string, shortlink: ShortLinkPreference) { + return this._organization.model.organization.update({ + where: { + id: orgId, + }, + data: { + shortlink, + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..50c92884ff038f1b4313404f8632d948384907a1 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/organizations/organization.service.ts @@ -0,0 +1,133 @@ +import { CreateOrgUserDto } from '@gitroom/nestjs-libraries/dtos/auth/create.org.user.dto'; +import { Injectable } from '@nestjs/common'; +import { OrganizationRepository } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.repository'; +import { NotificationService } from '@gitroom/nestjs-libraries/database/prisma/notifications/notification.service'; +import { AddTeamMemberDto } from '@gitroom/nestjs-libraries/dtos/settings/add.team.member.dto'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; +import dayjs from 'dayjs'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { Organization, ShortLinkPreference } from '@prisma/client'; +import { AutopostService } from '@gitroom/nestjs-libraries/database/prisma/autopost/autopost.service'; + +@Injectable() +export class OrganizationService { + constructor( + private _organizationRepository: OrganizationRepository, + private _notificationsService: NotificationService + ) {} + async createOrgAndUser( + body: Omit & { providerId?: string }, + ip: string, + userAgent: string + ) { + return this._organizationRepository.createOrgAndUser( + body, + this._notificationsService.hasEmailProvider(), + ip, + userAgent + ); + } + + async getCount() { + return this._organizationRepository.getCount(); + } + + async createMaxUser(id: string, name: string, saasName: string, email: string) { + return this._organizationRepository.createMaxUser(id, name, saasName, email); + } + + addUserToOrg( + userId: string, + id: string, + orgId: string, + role: 'USER' | 'ADMIN' + ) { + return this._organizationRepository.addUserToOrg(userId, id, orgId, role); + } + + getOrgById(id: string) { + return this._organizationRepository.getOrgById(id); + } + + getOrgByApiKey(api: string) { + return this._organizationRepository.getOrgByApiKey(api); + } + + getUserOrg(id: string) { + return this._organizationRepository.getUserOrg(id); + } + + getOrgsByUserId(userId: string) { + return this._organizationRepository.getOrgsByUserId(userId); + } + + updateApiKey(orgId: string) { + return this._organizationRepository.updateApiKey(orgId); + } + + getTeam(orgId: string) { + return this._organizationRepository.getTeam(orgId); + } + + async setStreak(organizationId: string, type: 'start' | 'end') { + return this._organizationRepository.setStreak(organizationId, type); + } + + getOrgByCustomerId(customerId: string) { + return this._organizationRepository.getOrgByCustomerId(customerId); + } + + async inviteTeamMember(orgId: string, body: AddTeamMemberDto) { + const timeLimit = dayjs().add(2, 'day').format('YYYY-MM-DD HH:mm:ss'); + const id = makeId(5); + const url = + process.env.FRONTEND_URL + + `/?org=${AuthService.signJWT({ ...body, orgId, timeLimit, id })}`; + if (body.sendEmail) { + await this._notificationsService.sendEmail( + body.email, + 'You have been invited to join an organization', + `You have been invited to join an organization. Click here to join.
The link will expire in 2 days.` + ); + } + return { url }; + } + + async deleteTeamMember(org: Organization, userId: string) { + const userOrgs = await this._organizationRepository.getOrgsByUserId(userId); + const findOrgToDelete = userOrgs.find((orgUser) => orgUser.id === org.id); + if (!findOrgToDelete) { + throw new Error('User is not part of this organization'); + } + + // @ts-ignore + const myRole = org.users[0].role; + const userRole = findOrgToDelete.users[0].role; + const myLevel = myRole === 'USER' ? 0 : myRole === 'ADMIN' ? 1 : 2; + const userLevel = userRole === 'USER' ? 0 : userRole === 'ADMIN' ? 1 : 2; + + if (myLevel < userLevel) { + throw new Error('You do not have permission to delete this user'); + } + + return this._organizationRepository.deleteTeamMember(org.id, userId); + } + + disableOrEnableNonSuperAdminUsers(orgId: string, disable: boolean) { + return this._organizationRepository.disableOrEnableNonSuperAdminUsers( + orgId, + disable + ); + } + + getShortlinkPreference(orgId: string) { + return this._organizationRepository.getShortlinkPreference(orgId); + } + + updateShortlinkPreference(orgId: string, shortlink: ShortLinkPreference) { + return this._organizationRepository.updateShortlinkPreference( + orgId, + shortlink + ); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a3b2b20596dd2d1e084ad6c2aab91b456f8080a --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -0,0 +1,923 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { Post as PostBody } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto'; +import { + APPROVED_SUBMIT_FOR_ORDER, + CreationMethod, + Post, + State, +} from '@prisma/client'; +import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto'; +import { GetPostsListDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.list.dto'; +import dayjs from 'dayjs'; +import isoWeek from 'dayjs/plugin/isoWeek'; +import weekOfYear from 'dayjs/plugin/weekOfYear'; +import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; +import utc from 'dayjs/plugin/utc'; +import { v4 as uuidv4 } from 'uuid'; +import { CreateTagDto } from '@gitroom/nestjs-libraries/dtos/posts/create.tag.dto'; + +dayjs.extend(isoWeek); +dayjs.extend(weekOfYear); +dayjs.extend(isSameOrAfter); +dayjs.extend(utc); + +@Injectable() +export class PostsRepository { + constructor( + private _post: PrismaRepository<'post'>, + private _popularPosts: PrismaRepository<'popularPosts'>, + private _comments: PrismaRepository<'comments'>, + private _tags: PrismaRepository<'tags'>, + private _tagsPosts: PrismaRepository<'tagsPosts'>, + private _errors: PrismaRepository<'errors'> + ) {} + + searchForMissingThreeHoursPosts() { + return this._post.model.post.findMany({ + where: { + integration: { + refreshNeeded: false, + inBetweenSteps: false, + disabled: false, + deletedAt: null, + }, + publishDate: { + gte: dayjs.utc().subtract(2, 'day').toDate(), + lt: dayjs.utc().toDate(), + }, + state: 'QUEUE', + deletedAt: null, + parentPostId: null, + }, + select: { + id: true, + organizationId: true, + integration: { + select: { + providerIdentifier: true, + }, + }, + publishDate: true, + }, + }); + } + + getOldPosts(orgId: string, date: string) { + return this._post.model.post.findMany({ + where: { + integration: { + refreshNeeded: false, + inBetweenSteps: false, + disabled: false, + }, + organizationId: orgId, + publishDate: { + lte: dayjs(date).toDate(), + }, + deletedAt: null, + parentPostId: null, + }, + orderBy: { + publishDate: 'desc', + }, + select: { + id: true, + content: true, + publishDate: true, + releaseURL: true, + state: true, + integration: { + select: { + id: true, + name: true, + providerIdentifier: true, + picture: true, + type: true, + }, + }, + }, + }); + } + + updateImages(id: string, images: string) { + return this._post.model.post.update({ + where: { + id, + }, + data: { + image: images, + }, + }); + } + + getPostUrls(orgId: string, ids: string[]) { + return this._post.model.post.findMany({ + where: { + organizationId: orgId, + id: { + in: ids, + }, + }, + select: { + id: true, + releaseURL: true, + }, + }); + } + + async getPosts(orgId: string, query: GetPostsDto) { + // Use the provided start and end dates directly + const startDate = dayjs.utc(query.startDate).toDate(); + const endDate = dayjs.utc(query.endDate).toDate(); + + const list = await this._post.model.post.findMany({ + where: { + AND: [ + { + OR: [ + { + organizationId: orgId, + }, + ], + }, + { + OR: [ + { + publishDate: { + gte: startDate, + lte: endDate, + }, + }, + { + intervalInDays: { + not: null, + }, + }, + ], + }, + ], + integration: { + deletedAt: null, + organizationId: orgId, + }, + deletedAt: null, + parentPostId: null, + ...(query.customer + ? { + integration: { + customerId: query.customer, + }, + } + : {}), + }, + select: { + id: true, + content: true, + publishDate: true, + releaseURL: true, + releaseId: true, + state: true, + intervalInDays: true, + group: true, + creationMethod: true, + tags: { + select: { + tag: true, + }, + }, + integration: { + select: { + id: true, + providerIdentifier: true, + name: true, + picture: true, + }, + }, + }, + }); + + return list.reduce((all, post) => { + if (!post.intervalInDays) { + return [...all, post]; + } + + const addMorePosts = []; + let startingDate = dayjs.utc(post.publishDate); + while (dayjs.utc(endDate).isSameOrAfter(startingDate)) { + if (dayjs(startingDate).isSameOrAfter(dayjs.utc(post.publishDate))) { + addMorePosts.push({ + ...post, + publishDate: startingDate.toDate(), + actualDate: post.publishDate, + }); + } + + startingDate = startingDate.add(post.intervalInDays, 'days'); + } + + return [...all, ...addMorePosts]; + }, [] as any[]); + } + + async getPostsList(orgId: string, query: GetPostsListDto) { + const page = query.page || 0; + const limit = query.limit || 20; + const skip = page * limit; + + const stateFilter = query.state || 'all'; + const stateAndDate = + stateFilter === 'scheduled' + ? { + state: State.QUEUE, + } + : stateFilter === 'draft' + ? { state: State.DRAFT } + : stateFilter === 'published' + ? { state: State.PUBLISHED } + : { + state: { + in: [State.QUEUE, State.DRAFT, State.PUBLISHED, State.ERROR], + }, + }; + + const orderDirection: 'asc' | 'desc' = + stateFilter === 'published' ? 'desc' : 'asc'; + + const where = { + AND: [ + { + OR: [ + { + organizationId: orgId, + }, + ], + }, + ], + ...stateAndDate, + // Published posts were already posted (publishDate in the past), so fetch + // all of them; everything else stays upcoming. Ordering handles the rest. + ...(stateFilter === 'published' + ? {} + : { publishDate: { gte: dayjs.utc().toDate() } }), + deletedAt: null as Date | null, + parentPostId: null as string | null, + intervalInDays: null as number | null, + + integration: { + deletedAt: null as any, + organizationId: orgId, + ...(query.customer + ? { + customerId: query.customer, + } + : {}), + }, + }; + + const [posts, total] = await Promise.all([ + this._post.model.post.findMany({ + where, + skip, + take: limit, + orderBy: { + publishDate: orderDirection, + }, + select: { + id: true, + content: true, + publishDate: true, + releaseURL: true, + releaseId: true, + state: true, + intervalInDays: true, + group: true, + creationMethod: true, + tags: { + select: { + tag: true, + }, + }, + integration: { + select: { + id: true, + providerIdentifier: true, + name: true, + picture: true, + }, + }, + }, + }), + this._post.model.post.count({ where }), + ]); + + return { + posts, + total, + page, + limit, + hasMore: skip + posts.length < total, + }; + } + + async deletePost(orgId: string, group: string) { + await this._post.model.post.updateMany({ + where: { + organizationId: orgId, + group, + }, + data: { + deletedAt: new Date(), + }, + }); + + return this._post.model.post.findFirst({ + where: { + organizationId: orgId, + group, + parentPostId: null, + }, + select: { + id: true, + }, + }); + } + + getPostsByGroup(orgId: string, group: string) { + return this._post.model.post.findMany({ + where: { + group, + ...(orgId ? { organizationId: orgId } : {}), + deletedAt: null, + }, + include: { + integration: true, + tags: { + select: { + tag: true, + }, + }, + }, + }); + } + + getPost( + id: string, + includeIntegration = false, + orgId?: string, + isFirst?: boolean + ) { + return this._post.model.post.findUnique({ + where: { + id, + ...(orgId ? { organizationId: orgId } : {}), + deletedAt: null, + }, + include: { + ...(includeIntegration + ? { + integration: true, + tags: { + select: { + tag: true, + }, + }, + } + : {}), + childrenPost: true, + }, + }); + } + + updatePost(id: string, postId: string, releaseURL: string) { + return this._post.model.post.update({ + where: { + id, + }, + data: { + state: 'PUBLISHED', + releaseURL, + releaseId: postId, + }, + }); + } + + updateReleaseId(id: string, orgId: string, releaseId: string) { + return this._post.model.post.update({ + where: { + id, + organizationId: orgId, + releaseId: 'missing', + }, + data: { + releaseId: String(releaseId), + }, + }); + } + + async changeState(id: string, state: State, err?: any, body?: any) { + const update = await this._post.model.post.update({ + where: { + id, + }, + data: { + state, + ...(err + ? { error: typeof err === 'string' ? err : JSON.stringify(err) } + : {}), + }, + include: { + integration: { + select: { + providerIdentifier: true, + }, + }, + }, + }); + + if (state === 'ERROR' && err && body) { + try { + await this._errors.model.errors.create({ + data: { + message: typeof err === 'string' ? err : JSON.stringify(err), + organizationId: update.organizationId, + platform: update.integration.providerIdentifier, + postId: update.id, + body: typeof body === 'string' ? body : JSON.stringify(body), + }, + }); + } catch (err) {} + } + + return update; + } + + getErrorsByPostIds(postIds: string[]) { + return this._errors.model.errors.findMany({ + where: { + postId: { in: postIds }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async changeDate( + orgId: string, + id: string, + date: string, + isDraft: boolean, + action: 'schedule' | 'update' = 'schedule' + ) { + return this._post.model.post.update({ + where: { + organizationId: orgId, + id, + }, + data: { + publishDate: dayjs(date).toDate(), + // schedule: set state to QUEUE (or DRAFT if it was a draft) + // update: don't change the state + ...(action === 'schedule' + ? { + state: isDraft ? 'DRAFT' : 'QUEUE', + releaseId: null, + releaseURL: null, + } + : {}), + }, + }); + } + + countPostsFromDay(orgId: string, date: Date) { + return this._post.model.post.count({ + where: { + organizationId: orgId, + publishDate: { + gte: date, + }, + OR: [ + { + deletedAt: null, + state: { + in: ['QUEUE'], + }, + }, + { + state: 'PUBLISHED', + }, + ], + }, + }); + } + + async createOrUpdatePost( + state: 'draft' | 'schedule' | 'now' | 'update', + orgId: string, + date: string, + body: PostBody, + tags: { value: string; label: string }[], + creationMethod: CreationMethod, + inter?: number + ) { + const posts: Post[] = []; + const uuid = uuidv4(); + + for (const value of body.value) { + const updateData = (type: 'create' | 'update') => ({ + publishDate: dayjs(date).toDate(), + integration: { + connect: { + id: body.integration.id, + organizationId: orgId, + }, + }, + ...(posts?.[posts.length - 1]?.id + ? { + parentPost: { + connect: { + id: posts[posts.length - 1]?.id, + }, + }, + } + : type === 'update' + ? { + parentPost: { + disconnect: true, + }, + } + : {}), + content: value.content, + delay: value.delay || 0, + group: uuid, + intervalInDays: inter ? +inter : null, + approvedSubmitForOrder: APPROVED_SUBMIT_FOR_ORDER.NO, + ...(type === 'create' ? { creationMethod } : {}), + ...(state === 'update' + ? {} + : { + state: + state === 'draft' ? ('DRAFT' as const) : ('QUEUE' as const), + }), + image: JSON.stringify(value.image), + settings: JSON.stringify(body.settings), + organization: { + connect: { + id: orgId, + }, + }, + }); + + posts.push( + await this._post.model.post.upsert({ + where: { + id: value.id || uuidv4(), + }, + create: { ...updateData('create') }, + update: { + ...updateData('update'), + lastMessage: { + disconnect: true, + }, + submittedForOrder: { + disconnect: true, + }, + }, + }) + ); + + if (posts.length === 1) { + await this._tagsPosts.model.tagsPosts.deleteMany({ + where: { + post: { + id: posts[0].id, + }, + }, + }); + + if (tags.length) { + const tagsList = await this._tags.model.tags.findMany({ + where: { + orgId: orgId, + name: { + in: tags.map((tag) => tag.label).filter((f) => f), + }, + }, + }); + + if (tagsList.length) { + await this._post.model.post.update({ + where: { + id: posts[posts.length - 1].id, + }, + data: { + tags: { + createMany: { + data: tagsList.map((tag) => ({ + tagId: tag.id, + })), + }, + }, + }, + }); + } + } + } + } + + const previousPost = body.group + ? ( + await this._post.model.post.findFirst({ + where: { + group: body.group, + deletedAt: null, + parentPostId: null, + }, + select: { + id: true, + }, + }) + )?.id! + : undefined; + + if (body.group) { + await this._post.model.post.updateMany({ + where: { + group: body.group, + deletedAt: null, + }, + data: { + parentPostId: null, + deletedAt: new Date(), + }, + }); + } + + return { previousPost, posts }; + } + + async submit(id: string, order: string, buyerOrganizationId: string) { + return this._post.model.post.update({ + where: { + id, + }, + data: { + submittedForOrderId: order, + approvedSubmitForOrder: 'WAITING_CONFIRMATION', + submittedForOrganizationId: buyerOrganizationId, + }, + select: { + id: true, + description: true, + submittedForOrder: { + select: { + messageGroupId: true, + }, + }, + }, + }); + } + + updateMessage(id: string, messageId: string) { + return this._post.model.post.update({ + where: { + id, + }, + data: { + lastMessageId: messageId, + }, + }); + } + + getPostById(id: string, org?: string) { + return this._post.model.post.findUnique({ + where: { + id, + ...(org ? { organizationId: org } : {}), + }, + include: { + integration: true, + submittedForOrder: { + include: { + posts: { + where: { + state: 'PUBLISHED', + }, + }, + ordersItems: true, + seller: { + select: { + id: true, + account: true, + }, + }, + }, + }, + }, + }); + } + + findAllExistingCategories() { + return this._popularPosts.model.popularPosts.findMany({ + select: { + category: true, + }, + distinct: ['category'], + }); + } + + findAllExistingTopicsOfCategory(category: string) { + return this._popularPosts.model.popularPosts.findMany({ + where: { + category, + }, + select: { + topic: true, + }, + distinct: ['topic'], + }); + } + + findPopularPosts(category: string, topic?: string) { + return this._popularPosts.model.popularPosts.findMany({ + where: { + category, + ...(topic ? { topic } : {}), + }, + select: { + content: true, + hook: true, + }, + }); + } + + createPopularPosts(post: { + category: string; + topic: string; + content: string; + hook: string; + }) { + return this._popularPosts.model.popularPosts.create({ + data: { + category: 'category', + topic: 'topic', + content: 'content', + hook: 'hook', + }, + }); + } + + async getPostsCountsByDates( + orgId: string, + times: number[], + date: dayjs.Dayjs + ) { + const dates = await this._post.model.post.findMany({ + where: { + deletedAt: null, + organizationId: orgId, + publishDate: { + in: times.map((time) => { + return date.clone().add(time, 'minutes').toDate(); + }), + }, + }, + }); + + return times.filter( + (time) => + date.clone().add(time, 'minutes').isAfter(dayjs.utc()) && + !dates.find((dateFind) => { + return ( + dayjs + .utc(dateFind.publishDate) + .diff(date.clone().startOf('day'), 'minutes') == time + ); + }) + ); + } + + async getComments(postId: string) { + return this._comments.model.comments.findMany({ + where: { + postId, + }, + orderBy: { + createdAt: 'asc', + }, + }); + } + + async getTags(orgId: string) { + return this._tags.model.tags.findMany({ + where: { + orgId, + deletedAt: null, + }, + }); + } + + createTag(orgId: string, body: CreateTagDto) { + return this._tags.model.tags.create({ + data: { + orgId, + name: body.name, + color: body.color, + }, + }); + } + + editTag(id: string, orgId: string, body: CreateTagDto) { + return this._tags.model.tags.update({ + where: { + id, + }, + data: { + name: body.name, + color: body.color, + }, + }); + } + + deleteTag(id: string, orgId: string) { + return this._tags.model.tags.update({ + where: { + id, + orgId, + }, + data: { + deletedAt: new Date(), + }, + }); + } + + createComment( + orgId: string, + userId: string, + postId: string, + content: string + ) { + return this._comments.model.comments.create({ + data: { + organizationId: orgId, + userId, + postId, + content, + }, + }); + } + + async getPostByForWebhookId(postId: string) { + return this._post.model.post.findMany({ + where: { + id: postId, + deletedAt: null, + parentPostId: null, + }, + select: { + id: true, + content: true, + publishDate: true, + releaseURL: true, + state: true, + integration: { + select: { + id: true, + name: true, + providerIdentifier: true, + picture: true, + type: true, + }, + }, + }, + }); + } + + async getPostsSince(orgId: string, since: string) { + return this._post.model.post.findMany({ + where: { + organizationId: orgId, + publishDate: { + gte: new Date(since), + }, + deletedAt: null, + parentPostId: null, + }, + select: { + id: true, + content: true, + publishDate: true, + releaseURL: true, + state: true, + integration: { + select: { + id: true, + name: true, + providerIdentifier: true, + picture: true, + type: true, + }, + }, + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..57f73373ec6cf40e2f6543647777fab3d9bb7333 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -0,0 +1,1174 @@ +import { + BadRequestException, + Injectable, + ValidationPipe, +} from '@nestjs/common'; +import { PostsRepository } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.repository'; +import { CreatePostDto } from '@gitroom/nestjs-libraries/dtos/posts/create.post.dto'; +import dayjs from 'dayjs'; +import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; +import { + Integration, + Post, + Media, + From, + CreationMethod, + State, +} from '@prisma/client'; +import { GetPostsDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.dto'; +import { GetPostsListDto } from '@gitroom/nestjs-libraries/dtos/posts/get.posts.list.dto'; +import { shuffle } from 'lodash'; +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'; +import utc from 'dayjs/plugin/utc'; +import { MediaService } from '@gitroom/nestjs-libraries/database/prisma/media/media.service'; +import { ShortLinkService } from '@gitroom/nestjs-libraries/short-linking/short.link.service'; +import { CreateTagDto } from '@gitroom/nestjs-libraries/dtos/posts/create.tag.dto'; +import { + minifyPostsList, + minifyPosts, +} from '@gitroom/helpers/utils/posts.list.minify'; +import axios from 'axios'; +import sharp from 'sharp'; +import { UploadFactory } from '@gitroom/nestjs-libraries/upload/upload.factory'; +import { Readable } from 'stream'; +import { OpenaiService } from '@gitroom/nestjs-libraries/openai/openai.service'; +dayjs.extend(utc); +import * as Sentry from '@sentry/nestjs'; +import { TemporalService } from 'nestjs-temporal-core'; +import { TypedSearchAttributes } from '@temporalio/common'; +import { + organizationId, + postId as postIdSearchParam, +} from '@gitroom/nestjs-libraries/temporal/temporal.search.attribute'; +import { AnalyticsData } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { ioRedis } from '@gitroom/nestjs-libraries/redis/redis.service'; +import { RefreshToken } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { RefreshIntegrationService } from '@gitroom/nestjs-libraries/integrations/refresh.integration.service'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; +import { stripLinks } from '@gitroom/helpers/utils/strip.links'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation'; +import { weightedLength } from '@gitroom/helpers/utils/count.length'; + +type PostWithConditionals = Post & { + integration?: Integration; + childrenPost: Post[]; +}; + +@Injectable() +export class PostsService { + private storage = UploadFactory.createStorage(); + constructor( + private _postRepository: PostsRepository, + private _integrationManager: IntegrationManager, + private _integrationService: IntegrationService, + private _mediaService: MediaService, + private _shortLinkService: ShortLinkService, + private _openaiService: OpenaiService, + private _temporalService: TemporalService, + private _refreshIntegrationService: RefreshIntegrationService + ) {} + + searchForMissingThreeHoursPosts() { + return this._postRepository.searchForMissingThreeHoursPosts(); + } + + updatePost(id: string, postId: string, releaseURL: string) { + return this._postRepository.updatePost(id, postId, releaseURL); + } + + async getMissingContent( + orgId: string, + postId: string, + forceRefresh = false + ): Promise<{ id: string; url: string }[]> { + const post = await this._postRepository.getPostById(postId, orgId); + if (!post || post.releaseId !== 'missing') { + return []; + } + + const integrationProvider = this._integrationManager.getSocialIntegration( + post.integration.providerIdentifier + ); + + if (!integrationProvider.missing) { + return []; + } + + const getIntegration = post.integration!; + + if ( + dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) || + forceRefresh + ) { + const data = await this._refreshIntegrationService.refresh( + getIntegration + ); + if (!data) { + return []; + } + + const { accessToken } = data; + + if (accessToken) { + getIntegration.token = accessToken; + + if (integrationProvider.refreshWait) { + await timer(10000); + } + } else { + await this._integrationService.disconnectChannel(orgId, getIntegration); + return []; + } + } + + try { + return await integrationProvider.missing( + getIntegration.internalId, + getIntegration.token + ); + } catch (e) { + console.log(e); + if (e instanceof RefreshToken) { + return this.getMissingContent(orgId, postId, true); + } + } + + return []; + } + + async getPostById(postId: string, orgId: string) { + return this._postRepository.getPostById(postId, orgId); + } + + async updateReleaseId(orgId: string, postId: string, releaseId: string) { + return this._postRepository.updateReleaseId(postId, orgId, releaseId); + } + + async checkPostAnalytics( + orgId: string, + postId: string, + date: number, + forceRefresh = false + ): Promise { + const post = await this._postRepository.getPostById(postId, orgId); + if (!post || !post.releaseId) { + return []; + } + + if (post.releaseId === 'missing') { + return { missing: true }; + } + + const integrationProvider = this._integrationManager.getSocialIntegration( + post.integration.providerIdentifier + ); + + if (!integrationProvider.postAnalytics) { + return []; + } + + const getIntegration = post.integration!; + + if ( + dayjs(getIntegration?.tokenExpiration).isBefore(dayjs()) || + forceRefresh + ) { + const data = await this._refreshIntegrationService.refresh( + getIntegration + ); + if (!data) { + return []; + } + + const { accessToken } = data; + + if (accessToken) { + getIntegration.token = accessToken; + + if (integrationProvider.refreshWait) { + await timer(10000); + } + } else { + await this._integrationService.disconnectChannel(orgId, getIntegration); + return []; + } + } + + // const getIntegrationData = await ioRedis.get( + // `integration:${orgId}:${post.id}:${date}` + // ); + // if (getIntegrationData) { + // return JSON.parse(getIntegrationData); + // } + + try { + const loadAnalytics = await integrationProvider.postAnalytics( + getIntegration.internalId, + getIntegration.token, + post.releaseId, + date + ); + await ioRedis.set( + `integration:${orgId}:${post.id}:${date}`, + JSON.stringify(loadAnalytics), + 'EX', + !process.env.NODE_ENV || process.env.NODE_ENV === 'development' + ? 1 + : 3600 + ); + return loadAnalytics; + } catch (e) { + console.log(e); + if (e instanceof RefreshToken) { + return this.checkPostAnalytics(orgId, postId, date, true); + } + } + + return []; + } + + async getStatistics(orgId: string, id: string) { + const getPost = await this.getPostsRecursively(id, true, orgId, true); + const content = getPost.map((p) => p.content); + const shortLinksTracking = await this._shortLinkService.getStatistics( + content + ); + + return { + clicks: shortLinksTracking, + }; + } + + async mapTypeToPost( + body: CreatePostDto, + organization: string, + replaceDraft: boolean = false + ): Promise { + if (!body?.posts?.every((p) => p?.integration?.id)) { + throw new BadRequestException('All posts must have an integration id'); + } + + const mappedValues = { + ...body, + type: replaceDraft ? 'schedule' : body?.type, + posts: await Promise.all( + body?.posts?.map(async (post) => { + const integration = await this._integrationService.getIntegrationById( + organization, + post.integration.id + ); + + if (!integration) { + throw new BadRequestException( + `Integration with id ${post.integration.id} not found` + ); + } + + return { + type: replaceDraft ? 'schedule' : body?.type, + ...post, + settings: { + ...(post.settings || ({} as any)), + __type: integration.providerIdentifier, + }, + }; + }) || [] + ), + }; + + const validationPipe = new ValidationPipe({ + skipMissingProperties: false, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }); + + return await validationPipe.transform(mappedValues, { + type: 'body', + metatype: CreatePostDto, + }); + } + + async getPostsRecursively( + id: string, + includeIntegration = false, + orgId?: string, + isFirst?: boolean + ): Promise { + const post = await this._postRepository.getPost( + id, + includeIntegration, + orgId, + isFirst + ); + + if (!post) { + return []; + } + + return [ + post!, + ...(post?.childrenPost?.length + ? await this.getPostsRecursively( + post?.childrenPost?.[0]?.id, + false, + orgId, + false + ) + : []), + ]; + } + + async getPosts(orgId: string, query: GetPostsDto) { + return this._postRepository.getPosts(orgId, query); + } + + async getPostsMinified(orgId: string, query: GetPostsDto) { + return minifyPosts({ + posts: await this._postRepository.getPosts(orgId, query), + }); + } + + async getPostsList(orgId: string, query: GetPostsListDto) { + return minifyPostsList( + await this._postRepository.getPostsList(orgId, query) + ); + } + + async updateMedia(id: string, imagesList: any[], convertToJPEG = false) { + try { + let imageUpdateNeeded = false; + const getImageList = await Promise.all( + ( + await Promise.all( + (imagesList || []).map(async (p: any) => { + if (!p.path && p.id) { + imageUpdateNeeded = true; + return this._mediaService.getMediaById(p.id); + } + + return p; + }) + ) + ) + .map((m) => { + return { + ...m, + url: + m.path.indexOf('http') === -1 + ? process.env.FRONTEND_URL + + '/' + + process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY + + m.path + : m.path, + type: 'image', + path: + m.path.indexOf('http') === -1 + ? process.env.UPLOAD_DIRECTORY + m.path + : m.path, + }; + }) + .map(async (m) => { + if (!convertToJPEG) { + return m; + } + + if (hasExtension(m.path, 'png')) { + imageUpdateNeeded = true; + const response = await axios.get(m.url, { + responseType: 'arraybuffer', + }); + + const imageBuffer = Buffer.from(response.data); + + // Use sharp to get the metadata of the image + const buffer = await sharp(imageBuffer) + .jpeg({ quality: 100 }) + .toBuffer(); + + const { path, originalname } = await this.storage.uploadFile({ + buffer, + mimetype: 'image/jpeg', + size: buffer.length, + path: '', + fieldname: '', + destination: '', + stream: new Readable(), + filename: '', + originalname: '', + encoding: '', + }); + + return { + ...m, + name: originalname, + url: + path.indexOf('http') === -1 + ? process.env.FRONTEND_URL + + '/' + + process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY + + path + : path, + type: 'image', + path: + path.indexOf('http') === -1 + ? process.env.UPLOAD_DIRECTORY + path + : path, + }; + } + + return m; + }) + ); + + if (imageUpdateNeeded) { + await this._postRepository.updateImages( + id, + JSON.stringify(getImageList) + ); + } + + return getImageList; + } catch (err: any) { + return imagesList; + } + } + + async getPostGroupDebugExport(orgId: string, group: string) { + const loadAll = await this._postRepository.getPostsByGroup(orgId, group); + const errors = await this._postRepository.getErrorsByPostIds( + loadAll.map((p) => p.id) + ); + const posts = this.arrangePostsByGroup(loadAll, undefined); + const rootPost = posts[0] as any; + + return { + type: 'draft' as const, + shortLink: false, + date: rootPost.publishDate.toISOString(), + tags: + rootPost.tags?.map((t: any) => ({ + value: t.tag.id, + label: t.tag.name, + })) || [], + posts: [ + { + integration: { id: 'REPLACE_WITH_LOCAL_INTEGRATION_ID' }, + group: rootPost.group, + settings: JSON.parse(rootPost.settings || '{}'), + value: posts.map((post) => ({ + content: post.content, + image: JSON.parse(post.image || '[]'), + delay: post.delay || 0, + })), + }, + ], + _debug: { + providerIdentifier: rootPost.integration?.providerIdentifier, + providerName: rootPost.integration?.name, + state: rootPost.state, + error: rootPost.error, + errors: errors.map((e) => ({ + message: e.message, + platform: e.platform, + body: e.body, + createdAt: e.createdAt, + })), + originalGroup: group, + originalPublishDate: rootPost.publishDate, + exportedAt: new Date().toISOString(), + }, + }; + } + + async getPostsByGroup(orgId: string, group: string) { + const convertToJPEG = false; + const loadAll = await this._postRepository.getPostsByGroup(orgId, group); + const posts = this.arrangePostsByGroup(loadAll, undefined); + + return { + group: posts?.[0]?.group, + posts: await Promise.all( + (posts || []).map(async (post) => ({ + ...post, + image: await this.updateMedia( + post.id, + JSON.parse(post.image || '[]'), + convertToJPEG + ), + })) + ), + integrationPicture: posts[0]?.integration?.picture, + integration: posts[0].integrationId, + settings: JSON.parse(posts[0].settings || '{}'), + }; + } + + arrangePostsByGroup(all: any, parent?: string): PostWithConditionals[] { + const findAll = all + .filter((p: any) => + !parent ? !p.parentPostId : p.parentPostId === parent + ) + .map(({ integration, ...all }: any) => ({ + ...all, + ...(!parent ? { integration } : {}), + })); + + return [ + ...findAll, + ...(findAll.length + ? findAll.flatMap((p: any) => this.arrangePostsByGroup(all, p.id)) + : []), + ]; + } + + async getPost(orgId: string, id: string, convertToJPEG = false) { + const posts = await this.getPostsRecursively(id, true, orgId, true); + const list = { + group: posts?.[0]?.group, + posts: await Promise.all( + (posts || []).map(async (post) => ({ + ...post, + image: await this.updateMedia( + post.id, + JSON.parse(post.image || '[]'), + convertToJPEG + ), + })) + ), + integrationPicture: posts[0]?.integration?.picture, + integration: posts[0].integrationId, + settings: JSON.parse(posts[0].settings || '{}'), + }; + + return list; + } + + async getOldPosts(orgId: string, date: string) { + return this._postRepository.getOldPosts(orgId, date); + } + + public async updateTags(orgId: string, post: Post[]): Promise { + const plainText = JSON.stringify(post); + const extract = Array.from( + plainText.match(/\(post:[a-zA-Z0-9-_]+\)/g) || [] + ); + if (!extract.length) { + return post; + } + + const ids = (extract || []).map((e) => + e.replace('(post:', '').replace(')', '') + ); + const urls = await this._postRepository.getPostUrls(orgId, ids); + const newPlainText = ids.reduce((acc, value) => { + const findUrl = urls?.find?.((u) => u.id === value)?.releaseURL || ''; + return acc.replace( + new RegExp(`\\(post:${value}\\)`, 'g'), + findUrl.split(',')[0] + ); + }, plainText); + + return this.updateTags(orgId, JSON.parse(newPlainText) as Post[]); + } + + public async checkInternalPlug( + integration: Integration, + orgId: string, + id: string, + settings: any + ) { + const plugs = Object.entries(settings).filter(([key]) => { + return key.indexOf('plug-') > -1; + }); + + if (plugs.length === 0) { + return []; + } + + const parsePlugs = plugs.reduce((all, [key, value]) => { + const [_, name, identifier] = key.split('--'); + all[name] = all[name] || { name }; + all[name][identifier] = value; + return all; + }, {} as any); + + const list: { + name: string; + integrations: { id: string }[]; + delay: string; + active: boolean; + }[] = Object.values(parsePlugs); + + return (list || []).flatMap((trigger) => { + return (trigger?.integrations || []).flatMap((int) => ({ + type: 'internal-plug', + post: id, + originalIntegration: integration.id, + integration: int.id, + plugName: trigger.name, + orgId: orgId, + delay: +trigger.delay, + information: trigger, + })); + }); + } + + public async checkPlugs( + orgId: string, + providerName: string, + integrationId: string + ) { + const loadAllPlugs = this._integrationManager.getAllPlugs(); + const getPlugs = await this._integrationService.getPlugs( + orgId, + integrationId + ); + + const currentPlug = loadAllPlugs.find((p) => p.identifier === providerName); + + return getPlugs + .filter((plug) => { + return currentPlug?.plugs?.some( + (p: any) => p.methodName === plug.plugFunction + ); + }) + .map((plug) => { + const runPlug = currentPlug?.plugs?.find( + (p: any) => p.methodName === plug.plugFunction + )!; + return { + type: 'global', + plugId: plug.id, + delay: runPlug.runEveryMilliseconds, + totalRuns: runPlug.totalRuns, + }; + }); + } + + async deletePost(orgId: string, group: string) { + const post = await this._postRepository.deletePost(orgId, group); + + if (post?.id) { + try { + const workflows = this._temporalService.client + .getRawClient() + ?.workflow.list({ + query: `postId="${post.id}" AND ExecutionStatus="Running"`, + }); + + for await (const executionInfo of workflows) { + try { + const workflow = + await this._temporalService.client.getWorkflowHandle( + executionInfo.workflowId + ); + if ( + workflow && + (await workflow.describe()).status.name !== 'TERMINATED' + ) { + await workflow.terminate(); + } + } catch (err) {} + } + } catch (err) {} + } + + return { error: true }; + } + + async countPostsFromDay(orgId: string, date: Date) { + return this._postRepository.countPostsFromDay(orgId, date); + } + + getPostByForWebhookId(id: string) { + return this._postRepository.getPostByForWebhookId(id); + } + + async startWorkflow( + taskQueue: string, + postId: string, + orgId: string, + state: State + ) { + try { + const workflows = this._temporalService.client + .getRawClient() + ?.workflow.list({ + query: `postId="${postId}" AND ExecutionStatus="Running"`, + }); + + for await (const executionInfo of workflows) { + try { + const workflow = await this._temporalService.client.getWorkflowHandle( + executionInfo.workflowId + ); + if ( + workflow && + (await workflow.describe()).status.name !== 'TERMINATED' + ) { + await workflow.terminate(); + } + } catch (err) {} + } + } catch (err) {} + + if (state === 'DRAFT') { + return; + } + + try { + await this._temporalService.client + .getRawClient() + ?.workflow.start('postWorkflowV105', { + workflowId: `post_${postId}`, + taskQueue: 'main', + workflowIdConflictPolicy: 'TERMINATE_EXISTING', + args: [ + { + taskQueue: taskQueue, + postId: postId, + organizationId: orgId, + }, + ], + typedSearchAttributes: new TypedSearchAttributes([ + { + key: postIdSearchParam, + value: postId, + }, + { + key: organizationId, + value: orgId, + }, + ]), + }); + } catch (err) {} + } + + /** + * Server-side validation that used to live on the client (`checkValidity` + + * the manage modal loop). Runs the provider's settings DTO validation, the + * provider `checkValidity` (media rules) and the empty-content / too-long + * character checks. Returns one result per post so the frontend can show the + * same toasts it did before — and so `/posts` can refuse to create invalid + * posts. + */ + async validatePosts( + orgId: string, + posts: Array<{ + integration: { id: string }; + value: Array<{ + content?: string; + image?: Array<{ path: string; thumbnail?: string }>; + }>; + settings?: any; + }> + ) { + return Promise.all( + (posts || []).map(async (post) => { + const integration = await this._integrationService.getIntegrationById( + orgId, + post?.integration?.id + ); + + if (!integration) { + throw new BadRequestException( + `Integration with id ${post?.integration?.id} not found` + ); + } + + const provider = this._integrationManager.getSocialIntegration( + integration.providerIdentifier + ); + + let additionalSettings: any[] = []; + try { + additionalSettings = JSON.parse( + integration.additionalSettings || '[]' + ); + } catch { + additionalSettings = []; + } + + const settings = post.settings || {}; + const media = (post.value || []).map((p) => p.image || []); + + // Settings DTO validation — mirrors the client `form.trigger()`. + let valid = true; + let settingsError = ''; + if (provider?.dto) { + const instance = plainToInstance(provider.dto, settings, { + enableImplicitConversion: false, + }); + const validationErrors = await validate(instance as object, { + skipMissingProperties: false, + }); + settingsError = this.firstValidationError(validationErrors); + valid = validationErrors.length === 0; + } + + // Provider-specific media validation (the old client `checkValidity`). + let errors: string | true = true; + try { + errors = await provider.checkValidity( + media, + settings, + additionalSettings + ); + } catch (err: any) { + errors = err?.message || 'Invalid media'; + } + + const maximumCharacters = provider.maxLength(additionalSettings); + const isX = integration.providerIdentifier === 'x'; + + const emptyContent = (post.value || []).some((a) => { + const strip = stripHtmlValidation('normal', a.content || '', true); + const length = isX ? weightedLength(strip) : strip.length; + return length === 0 && (a.image || []).length === 0; + }); + + const tooLong = (post.value || []).some((a) => { + const strip = stripHtmlValidation('normal', a.content || '', true); + const weighted = isX ? weightedLength(strip) : strip.length; + const totalCharacters = + weighted > strip.length ? weighted : strip.length; + return totalCharacters > (maximumCharacters || 1000000); + }); + + return { + id: integration.id, + identifier: integration.providerIdentifier, + name: integration.name, + valid, + settingsError, + errors, + emptyContent, + tooLong, + maximumCharacters, + }; + }) + ); + } + + /** Returns the first class-validator message (incl. nested children), or ''. */ + private firstValidationError(errors: any[]): string { + for (const e of errors || []) { + if (e?.constraints) { + return Object.values(e.constraints as Record)[0] || ''; + } + const child = e?.children?.length + ? this.firstValidationError(e.children) + : ''; + if (child) { + return child; + } + } + return ''; + } + + async createPost( + orgId: string, + body: CreatePostDto, + creationMethod: CreationMethod + ): Promise { + const postList = []; + for (const post of body.posts) { + const provider = this._integrationManager.getSocialIntegration( + (post.settings as any)?.__type + ); + const removeLinks = !!provider?.stripLinks?.(); + + const messages = (post.value || []).map((p) => p.content); + // No point shortlinking links on platforms that strip them out anyway + const updateContent = + !body.shortLink || removeLinks + ? messages + : await this._shortLinkService.convertTextToShortLinks( + orgId, + messages + ); + + post.value = (post.value || []).map((p, i) => ({ + ...p, + content: removeLinks ? stripLinks(updateContent[i]) : updateContent[i], + })); + + const { posts } = await this._postRepository.createOrUpdatePost( + body.type, + orgId, + body.type === 'now' ? dayjs().format('YYYY-MM-DDTHH:mm:00') : body.date, + post, + body.tags, + creationMethod, + body.inter + ); + + if (!posts?.length) { + return [] as any[]; + } + + if (body.type !== 'update') { + this.startWorkflow( + post.settings.__type.split('-')[0].toLowerCase(), + posts[0].id, + orgId, + posts[0].state + ).catch((err) => {}); + } + + Sentry.metrics.count('post_created', 1); + postList.push({ + postId: posts[0].id, + integration: post.integration.id, + }); + } + + return postList; + } + + async separatePosts(content: string, len: number) { + return this._openaiService.separatePosts(content, len); + } + + async changeState(id: string, state: State, err?: any, body?: any) { + return this._postRepository.changeState(id, state, err, body); + } + + async changePostStatus( + orgId: string, + id: string, + status: 'draft' | 'schedule' + ) { + const getPostById = await this._postRepository.getPostById(id, orgId); + if (!getPostById) { + throw new BadRequestException('Post not found'); + } + + const state: State = status === 'draft' ? 'DRAFT' : 'QUEUE'; + await this._postRepository.changeState(id, state); + + try { + await this.startWorkflow( + getPostById.integration.providerIdentifier.split('-')[0].toLowerCase(), + getPostById.id, + orgId, + state + ); + } catch (err) {} + + return { id, state }; + } + + async changeDate( + orgId: string, + id: string, + date: string, + action: 'schedule' | 'update' = 'schedule' + ) { + const getPostById = await this._postRepository.getPostById(id, orgId); + + // schedule: Set status to QUEUE and change date (reschedule the post) + // update: Just change the date without changing the status + const newDate = await this._postRepository.changeDate( + orgId, + id, + date, + getPostById.state === 'DRAFT', + action + ); + + if (action === 'schedule') { + try { + await this.startWorkflow( + getPostById.integration.providerIdentifier + .split('-')[0] + .toLowerCase(), + getPostById.id, + orgId, + getPostById.state === 'DRAFT' ? 'DRAFT' : 'QUEUE' + ); + } catch (err) {} + } + + return newDate; + } + + async generatePostsDraft(orgId: string, body: CreateGeneratedPostsDto) { + const getAllIntegrations = ( + await this._integrationService.getIntegrationsList(orgId) + ).filter((f) => !f.disabled && f.providerIdentifier !== 'reddit'); + + // const posts = chunk(body.posts, getAllIntegrations.length); + const allDates = dayjs() + .isoWeek(body.week) + .year(body.year) + .startOf('isoWeek'); + + const dates = [...new Array(7)].map((_, i) => { + return allDates.add(i, 'day').format('YYYY-MM-DD'); + }); + + const findTime = (): string => { + const totalMinutes = Math.floor(Math.random() * 144) * 10; + + // Convert total minutes to hours and minutes + const hours = Math.floor(totalMinutes / 60); + const minutes = totalMinutes % 60; + + // Format hours and minutes to always be two digits + const formattedHours = hours.toString().padStart(2, '0'); + const formattedMinutes = minutes.toString().padStart(2, '0'); + const randomDate = + shuffle(dates)[0] + 'T' + `${formattedHours}:${formattedMinutes}:00`; + + if (dayjs(randomDate).isBefore(dayjs())) { + return findTime(); + } + + return randomDate; + }; + + for (const integration of getAllIntegrations) { + for (const toPost of body.posts) { + const group = makeId(10); + const randomDate = findTime(); + + await this.createPost( + orgId, + { + type: 'draft', + date: randomDate, + order: '', + shortLink: false, + tags: [], + posts: [ + { + group, + integration: { + id: integration.id, + }, + settings: { + __type: integration.providerIdentifier as any, + title: '', + tags: [], + subreddit: [], + }, + value: [ + ...toPost.list.map((l) => ({ + id: '', + content: l.post, + delay: 0, + image: [], + })), + { + id: '', + delay: 0, + content: `Check out the full story here:\n${ + body.postId || body.url + }`, + image: [], + }, + ], + }, + ], + }, + 'WEB' + ); + } + } + } + + findAllExistingCategories() { + return this._postRepository.findAllExistingCategories(); + } + + findAllExistingTopicsOfCategory(category: string) { + return this._postRepository.findAllExistingTopicsOfCategory(category); + } + + findPopularPosts(category: string, topic?: string) { + return this._postRepository.findPopularPosts(category, topic); + } + + async findFreeDateTime(orgId: string, integrationId?: string) { + const findTimes = await this._integrationService.findFreeDateTime( + orgId, + integrationId + ); + return this.findFreeDateTimeRecursive( + orgId, + findTimes, + dayjs.utc().startOf('day') + ); + } + + async createPopularPosts(post: { + category: string; + topic: string; + content: string; + hook: string; + }) { + return this._postRepository.createPopularPosts(post); + } + + private async findFreeDateTimeRecursive( + orgId: string, + times: number[], + date: dayjs.Dayjs + ): Promise { + const list = await this._postRepository.getPostsCountsByDates( + orgId, + times, + date + ); + + if (!list.length) { + return this.findFreeDateTimeRecursive(orgId, times, date.add(1, 'day')); + } + + const num = list.reduce((prev, curr) => { + if (prev === null || prev > curr) { + return curr; + } + return prev; + }, null) as number; + + return date.clone().add(num, 'minutes').format('YYYY-MM-DDTHH:mm:00'); + } + + getComments(postId: string) { + return this._postRepository.getComments(postId); + } + + getTags(orgId: string) { + return this._postRepository.getTags(orgId); + } + + createTag(orgId: string, body: CreateTagDto) { + return this._postRepository.createTag(orgId, body); + } + + editTag(id: string, orgId: string, body: CreateTagDto) { + return this._postRepository.editTag(id, orgId, body); + } + + deleteTag(id: string, orgId: string) { + return this._postRepository.deleteTag(id, orgId); + } + + createComment( + orgId: string, + userId: string, + postId: string, + comment: string + ) { + return this._postRepository.createComment(orgId, userId, postId, comment); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/prisma.service.ts b/libraries/nestjs-libraries/src/database/prisma/prisma.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..089afc5d75affc63959d7913305477cb88bb0404 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/prisma.service.ts @@ -0,0 +1,39 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + super({ + log: [ + { + emit: 'event', + level: 'query', + }, + ], + }); + } + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} + +@Injectable() +export class PrismaRepository { + public model: Pick; + constructor(private _prismaService: PrismaService) { + this.model = this._prismaService; + } +} + +@Injectable() +export class PrismaTransaction { + public model: Pick; + constructor(private _prismaService: PrismaService) { + this.model = this._prismaService; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/schema.prisma b/libraries/nestjs-libraries/src/database/prisma/schema.prisma new file mode 100644 index 0000000000000000000000000000000000000000..7f48b7bf074795c825dfb5a242c9bbac439d4bee --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/schema.prisma @@ -0,0 +1,970 @@ +generator client { + provider = "prisma-client-js" + runtime = "nodejs" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Organization { + id String @id @default(uuid()) + name String + description String? + apiKey String? + paymentId String? + streakSince DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + allowTrial Boolean @default(false) + isTrailing Boolean @default(false) + shortlink ShortLinkPreference @default(ASK) + autoPost AutoPost[] + Comments Comments[] + credits Credits[] + customers Customer[] + errors Errors[] + github GitHub[] + Integration Integration[] + media Media[] + buyerOrganization MessagesGroup[] + notifications Notifications[] + plugs Plugs[] + post Post[] @relation("organization") + submittedPost Post[] @relation("submittedForOrg") + sets Sets[] + signatures Signatures[] + subscription Subscription? + tags Tags[] + thirdParty ThirdParty[] + usedCodes UsedCodes[] + users UserOrganization[] + webhooks Webhooks[] + oauthApp OAuthApp[] + oauthAuthorizations OAuthAuthorization[] + + @@index([apiKey]) + @@index([streakSince]) + @@index([paymentId]) +} + +model Tags { + id String @id @default(uuid()) + name String + color String + orgId String + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [orgId], references: [id]) + posts TagsPosts[] + + @@index([orgId]) + @@index([deletedAt]) +} + +model TagsPosts { + postId String + tagId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + post Post @relation(fields: [postId], references: [id]) + tag Tags @relation(fields: [tagId], references: [id]) + + @@id([postId, tagId]) + @@unique([postId, tagId]) +} + +model User { + id String @id @default(uuid()) + email String + password String? + providerName Provider + name String? + lastName String? + isSuperAdmin Boolean @default(false) + bio String? + audience Int @default(0) + pictureId String? + providerId String? + timezone Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + lastReadNotifications DateTime @default(now()) + inviteId String? + activated Boolean @default(true) + account String? + connectedAccount Boolean @default(false) + lastOnline DateTime @default(now()) + ip String? + agent String? + comments Comments[] + items ItemUser[] + groupBuyer MessagesGroup[] @relation("groupBuyer") + groupSeller MessagesGroup[] @relation("groupSeller") + orderBuyer Orders[] @relation("orderBuyer") + orderSeller Orders[] @relation("orderSeller") + payoutProblems PayoutProblems[] + agencies SocialMediaAgency? + picture Media? @relation(fields: [pictureId], references: [id]) + organizations UserOrganization[] + sendSuccessEmails Boolean @default(true) + sendFailureEmails Boolean @default(true) + sendStreakEmails Boolean @default(true) + oauthAuthorizations OAuthAuthorization[] + + @@unique([email, providerName]) + @@index([lastReadNotifications]) + @@index([inviteId]) + @@index([account]) + @@index([lastOnline]) + @@index([pictureId]) +} + +model UsedCodes { + id String @id @default(uuid()) + code String + orgId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [orgId], references: [id]) + + @@index([code]) +} + +model UserOrganization { + id String @id @default(uuid()) + userId String + organizationId String + disabled Boolean @default(false) + role Role @default(USER) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [organizationId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@unique([userId, organizationId]) + @@index([disabled]) +} + +model GitHub { + id String @id @default(uuid()) + login String? + name String? + token String + jobId String? + organizationId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [organizationId], references: [id]) + + @@index([login]) + @@index([organizationId]) +} + +model Trending { + id String @id @default(uuid()) + trendingList String + language String? @unique + hash String + date DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([hash]) +} + +model TrendingLog { + id String @id @default(uuid()) + language String? + date DateTime +} + +model ItemUser { + id String @id @default(uuid()) + userId String + key String + user User @relation(fields: [userId], references: [id]) + + @@unique([userId, key]) + @@index([userId]) + @@index([key]) +} + +model Star { + id String @id @default(uuid()) + stars Int + totalStars Int + forks Int + totalForks Int + login String + date DateTime @default(now()) @db.Date + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([login, date]) +} + +model Media { + id String @id @default(uuid()) + name String + originalName String? + path String + organizationId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + fileSize Int @default(0) + type String @default("image") + thumbnail String? + alt String? + thumbnailTimestamp Int? + organization Organization @relation(fields: [organizationId], references: [id]) + agencies SocialMediaAgency[] + userPicture User[] + oauthApps OAuthApp[] + + @@index([name]) + @@index([organizationId]) + @@index([type]) +} + +model SocialMediaAgency { + id String @id @default(uuid()) + userId String @unique + name String + logoId String? + website String? + slug String? + facebook String? + instagram String? + twitter String? + linkedIn String? + youtube String? + tiktok String? + otherSocialMedia String? + shortDescription String + description String + approved Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + logo Media? @relation(fields: [logoId], references: [id]) + user User @relation(fields: [userId], references: [id]) + niches SocialMediaAgencyNiche[] + + @@index([userId]) + @@index([deletedAt]) + @@index([id]) +} + +model SocialMediaAgencyNiche { + agencyId String + niche String + agency SocialMediaAgency @relation(fields: [agencyId], references: [id]) + + @@id([agencyId, niche]) +} + +model Credits { + id String @id @default(uuid()) + organizationId String + credits Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + type String @default("ai_images") + organization Organization @relation(fields: [organizationId], references: [id]) + + @@index([organizationId]) + @@index([createdAt]) +} + +model Subscription { + id String @id @default(cuid()) + organizationId String @unique + subscriptionTier SubscriptionTier + identifier String? + cancelAt DateTime? + period Period + totalChannels Int + isLifetime Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + organization Organization @relation(fields: [organizationId], references: [id]) + + @@index([organizationId]) + @@index([deletedAt]) +} + +model Customer { + id String @id @default(uuid()) + name String + orgId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + organization Organization @relation(fields: [orgId], references: [id]) + integrations Integration[] + + @@unique([orgId, name, deletedAt]) +} + +model Integration { + id String @id @default(cuid()) + internalId String + organizationId String + name String + picture String? + providerIdentifier String + type String + token String + disabled Boolean @default(false) + tokenExpiration DateTime? + refreshToken String? + profile String? + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt + inBetweenSteps Boolean @default(false) + refreshNeeded Boolean @default(false) + postingTimes String @default("[{\"time\":120}, {\"time\":400}, {\"time\":700}]") + customInstanceDetails String? + customerId String? + rootInternalId String? + additionalSettings String? @default("[]") + exisingPlugData ExisingPlugData[] + customer Customer? @relation(fields: [customerId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id]) + webhooks IntegrationsWebhooks[] + orderItems OrderItems[] + plugs Plugs[] + posts Post[] + + @@unique([organizationId, internalId]) + @@index([rootInternalId]) + @@index([organizationId]) + @@index([providerIdentifier]) + @@index([updatedAt]) + @@index([createdAt]) + @@index([deletedAt]) + @@index([customerId]) + @@index([inBetweenSteps]) + @@index([refreshNeeded]) + @@index([disabled]) +} + +model Signatures { + id String @id @default(uuid()) + organizationId String + content String + autoAdd Boolean + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + organization Organization @relation(fields: [organizationId], references: [id]) + + @@index([createdAt]) + @@index([organizationId]) + @@index([deletedAt]) +} + +model Comments { + id String @id @default(uuid()) + content String + organizationId String + postId String + userId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + organization Organization @relation(fields: [organizationId], references: [id]) + post Post @relation(fields: [postId], references: [id]) + user User @relation(fields: [userId], references: [id]) + + @@index([createdAt]) + @@index([organizationId]) + @@index([userId]) + @@index([postId]) + @@index([deletedAt]) +} + +model Post { + id String @id @default(cuid()) + state State @default(QUEUE) + publishDate DateTime + organizationId String + integrationId String + content String + delay Int @default(0) + group String + title String? + description String? + parentPostId String? + releaseId String? + releaseURL String? + settings String? + image String? + submittedForOrderId String? + submittedForOrganizationId String? + approvedSubmitForOrder APPROVED_SUBMIT_FOR_ORDER @default(NO) + creationMethod CreationMethod @default(UNKNOWN) + lastMessageId String? + intervalInDays Int? + error String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + comments Comments[] + errors Errors[] + payoutProblems PayoutProblems[] + integration Integration @relation(fields: [integrationId], references: [id]) + lastMessage Messages? @relation(fields: [lastMessageId], references: [id]) + organization Organization @relation("organization", fields: [organizationId], references: [id]) + parentPost Post? @relation("parentPostId", fields: [parentPostId], references: [id]) + childrenPost Post[] @relation("parentPostId") + submittedForOrder Orders? @relation(fields: [submittedForOrderId], references: [id]) + submittedForOrganization Organization? @relation("submittedForOrg", fields: [submittedForOrganizationId], references: [id]) + tags TagsPosts[] + + @@index([group]) + @@index([deletedAt]) + @@index([publishDate]) + @@index([state]) + @@index([organizationId]) + @@index([parentPostId]) + @@index([submittedForOrderId]) + @@index([intervalInDays]) + @@index([approvedSubmitForOrder]) + @@index([creationMethod]) + @@index([lastMessageId]) + @@index([createdAt]) + @@index([updatedAt]) + @@index([releaseURL]) + @@index([integrationId]) +} + +model Notifications { + id String @id @default(uuid()) + organizationId String + content String + link String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + organization Organization @relation(fields: [organizationId], references: [id]) + + @@index([createdAt]) + @@index([organizationId]) + @@index([deletedAt]) +} + +model MessagesGroup { + id String @id @default(uuid()) + buyerOrganizationId String + buyerId String + sellerId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + messages Messages[] + buyer User @relation("groupBuyer", fields: [buyerId], references: [id]) + buyerOrganization Organization @relation(fields: [buyerOrganizationId], references: [id]) + seller User @relation("groupSeller", fields: [sellerId], references: [id]) + orders Orders[] + + @@unique([buyerId, sellerId]) + @@index([createdAt]) + @@index([updatedAt]) + @@index([buyerOrganizationId]) +} + +model PayoutProblems { + id String @id @default(uuid()) + status String + orderId String + userId String + postId String? + amount Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + order Orders @relation(fields: [orderId], references: [id]) + post Post? @relation(fields: [postId], references: [id]) + user User @relation(fields: [userId], references: [id]) +} + +model Orders { + id String @id @default(uuid()) + buyerId String + sellerId String + status OrderStatus + messageGroupId String + captureId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + ordersItems OrderItems[] + buyer User @relation("orderBuyer", fields: [buyerId], references: [id]) + messageGroup MessagesGroup @relation(fields: [messageGroupId], references: [id]) + seller User @relation("orderSeller", fields: [sellerId], references: [id]) + payoutProblems PayoutProblems[] + posts Post[] + + @@index([buyerId]) + @@index([sellerId]) + @@index([updatedAt]) + @@index([createdAt]) + @@index([messageGroupId]) +} + +model OrderItems { + id String @id @default(uuid()) + orderId String + integrationId String + quantity Int + price Int + integration Integration @relation(fields: [integrationId], references: [id]) + order Orders @relation(fields: [orderId], references: [id]) + + @@index([orderId]) + @@index([integrationId]) +} + +model Messages { + id String @id @default(uuid()) + from From + content String? + groupId String + special String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + group MessagesGroup @relation(fields: [groupId], references: [id]) + posts Post[] + + @@index([groupId]) + @@index([createdAt]) + @@index([deletedAt]) +} + +model Plugs { + id String @id @default(uuid()) + organizationId String + plugFunction String + data String + integrationId String + activated Boolean @default(true) + integration Integration @relation(fields: [integrationId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id]) + + @@unique([plugFunction, integrationId]) + @@index([organizationId]) +} + +model ExisingPlugData { + id String @id @default(uuid()) + integrationId String + methodName String + value String + integration Integration @relation(fields: [integrationId], references: [id]) + + @@unique([integrationId, methodName, value]) +} + +model PopularPosts { + id String @id @default(uuid()) + category String + topic String + content String + hook String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model IntegrationsWebhooks { + integrationId String + webhookId String + integration Integration @relation(fields: [integrationId], references: [id]) + webhook Webhooks @relation(fields: [webhookId], references: [id]) + + @@id([integrationId, webhookId]) + @@unique([integrationId, webhookId]) + @@index([integrationId]) + @@index([webhookId]) +} + +model Webhooks { + id String @id @default(uuid()) + name String + organizationId String + url String + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + integrations IntegrationsWebhooks[] + organization Organization @relation(fields: [organizationId], references: [id]) + + @@index([organizationId]) + @@index([deletedAt]) +} + +model AutoPost { + id String @id @default(uuid()) + organizationId String + title String + content String? + onSlot Boolean + syncLast Boolean + url String + lastUrl String + active Boolean + addPicture Boolean + generateContent Boolean + integrations String + deletedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [organizationId], references: [id]) + + @@index([deletedAt]) +} + +model Sets { + id String @id @default(uuid()) + organizationId String + name String + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + organization Organization @relation(fields: [organizationId], references: [id]) + + @@index([organizationId]) +} + +model ThirdParty { + id String @id @default(uuid()) + organizationId String + identifier String + name String + internalId String + apiKey String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + organization Organization @relation(fields: [organizationId], references: [id]) + + @@unique([organizationId, internalId]) + @@index([organizationId]) + @@index([deletedAt]) +} + +model Errors { + id String @id @default(uuid()) + message String + platform String + organizationId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + postId String + body String @default("{}") + organization Organization @relation(fields: [organizationId], references: [id]) + post Post @relation(fields: [postId], references: [id]) + + @@index([organizationId]) + @@index([createdAt]) +} + +model Mentions { + name String + username String + platform String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + image String + + @@id([name, username, platform, image]) + @@index([createdAt]) +} + +/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client. +model mastra_ai_spans { + traceId String + spanId String + parentSpanId String? + name String + scope Json? + spanType String + attributes Json? + metadata Json? + links Json? + input Json? + output Json? + error Json? + startedAt DateTime @db.Timestamp(6) + endedAt DateTime? @db.Timestamp(6) + createdAt DateTime @db.Timestamp(6) + updatedAt DateTime? @db.Timestamp(6) + isEvent Boolean + startedAtZ DateTime? @default(now()) @db.Timestamptz(6) + endedAtZ DateTime? @default(now()) @db.Timestamptz(6) + createdAtZ DateTime? @default(now()) @db.Timestamptz(6) + updatedAtZ DateTime? @default(now()) @db.Timestamptz(6) + + @@index([name], map: "public_mastra_ai_spans_name_idx") + @@index([parentSpanId, startedAt(sort: Desc)], map: "public_mastra_ai_spans_parentspanid_startedat_idx") + @@index([spanType, startedAt(sort: Desc)], map: "public_mastra_ai_spans_spantype_startedat_idx") + @@index([traceId, startedAt(sort: Desc)], map: "public_mastra_ai_spans_traceid_startedat_idx") + @@ignore +} + +/// The underlying table does not contain a valid unique identifier and can therefore currently not be handled by Prisma Client. +model mastra_evals { + input String + output String + result Json + agent_name String + metric_name String + instructions String + test_info Json? + global_run_id String + run_id String + created_at DateTime @db.Timestamp(6) + createdAt DateTime? @db.Timestamp(6) + created_atZ DateTime? @default(now()) @db.Timestamptz(6) + createdAtZ DateTime? @default(now()) @db.Timestamptz(6) + + @@index([agent_name, created_at(sort: Desc)], map: "public_mastra_evals_agent_name_created_at_idx") + @@ignore +} + +model mastra_messages { + id String @id + thread_id String + content String + role String + type String + createdAt DateTime @db.Timestamp(6) + resourceId String? + createdAtZ DateTime? @default(now()) @db.Timestamptz(6) + + @@index([thread_id, createdAt(sort: Desc)], map: "public_mastra_messages_thread_id_createdat_idx") +} + +model mastra_resources { + id String @id + workingMemory String? + metadata Json? + createdAt DateTime @db.Timestamp(6) + updatedAt DateTime @db.Timestamp(6) + createdAtZ DateTime? @default(now()) @db.Timestamptz(6) + updatedAtZ DateTime? @default(now()) @db.Timestamptz(6) +} + +model mastra_scorers { + id String @id + scorerId String + traceId String? + runId String + scorer Json + preprocessStepResult Json? + extractStepResult Json? + analyzeStepResult Json? + score Float + reason String? + metadata Json? + preprocessPrompt String? + extractPrompt String? + generateScorePrompt String? + generateReasonPrompt String? + analyzePrompt String? + reasonPrompt String? + input Json + output Json + additionalContext Json? + runtimeContext Json? + entityType String? + entity Json? + entityId String? + source String + resourceId String? + threadId String? + createdAt DateTime @db.Timestamp(6) + updatedAt DateTime @db.Timestamp(6) + createdAtZ DateTime? @default(now()) @db.Timestamptz(6) + updatedAtZ DateTime? @default(now()) @db.Timestamptz(6) + spanId String? + + @@index([traceId, spanId, createdAt(sort: Desc)], map: "public_mastra_scores_trace_id_span_id_created_at_idx") +} + +model mastra_threads { + id String @id + resourceId String + title String + metadata String? + createdAt DateTime @db.Timestamp(6) + updatedAt DateTime @db.Timestamp(6) + createdAtZ DateTime? @default(now()) @db.Timestamptz(6) + updatedAtZ DateTime? @default(now()) @db.Timestamptz(6) + + @@index([resourceId, createdAt(sort: Desc)], map: "public_mastra_threads_resourceid_createdat_idx") +} + +model mastra_traces { + id String @id + parentSpanId String? + name String + traceId String + scope String + kind Int + attributes Json? + status Json? + events Json? + links Json? + other String? + startTime BigInt + endTime BigInt + createdAt DateTime @db.Timestamp(6) + createdAtZ DateTime? @default(now()) @db.Timestamptz(6) + + @@index([name, startTime(sort: Desc)], map: "public_mastra_traces_name_starttime_idx") +} + +model mastra_workflow_snapshot { + workflow_name String + run_id String + resourceId String? + snapshot String + createdAt DateTime @db.Timestamp(6) + updatedAt DateTime @db.Timestamp(6) + createdAtZ DateTime? @default(now()) @db.Timestamptz(6) + updatedAtZ DateTime? @default(now()) @db.Timestamptz(6) + + @@unique([workflow_name, run_id], map: "public_mastra_workflow_snapshot_workflow_name_run_id_key") +} + +model OAuthApp { + id String @id @default(uuid()) + organizationId String + name String + description String? + pictureId String? + redirectUrl String + clientId String @unique + clientSecret String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + deletedAt DateTime? + organization Organization @relation(fields: [organizationId], references: [id]) + picture Media? @relation(fields: [pictureId], references: [id]) + authorizations OAuthAuthorization[] + + @@unique([organizationId, deletedAt]) + @@index([clientId]) + @@index([organizationId]) + @@index([deletedAt]) +} + +model OAuthAuthorization { + id String @id @default(uuid()) + oauthAppId String + userId String + organizationId String + accessToken String? + authorizationCode String? + codeExpiresAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + oauthApp OAuthApp @relation(fields: [oauthAppId], references: [id]) + user User @relation(fields: [userId], references: [id]) + organization Organization @relation(fields: [organizationId], references: [id]) + + @@unique([oauthAppId, userId, organizationId]) + @@index([accessToken]) + @@index([authorizationCode]) + @@index([oauthAppId]) + @@index([userId]) + @@index([organizationId]) + @@index([revokedAt]) +} + +enum OrderStatus { + PENDING + ACCEPTED + CANCELED + COMPLETED +} + +enum From { + BUYER + SELLER +} + +enum State { + QUEUE + PUBLISHED + ERROR + DRAFT +} + +enum SubscriptionTier { + STANDARD + PRO + TEAM + ULTIMATE +} + +enum Period { + MONTHLY + YEARLY +} + +enum Provider { + LOCAL + GITHUB + GOOGLE + FARCASTER + WALLET + GENERIC +} + +enum Role { + SUPERADMIN + ADMIN + USER +} + +enum APPROVED_SUBMIT_FOR_ORDER { + NO + WAITING_CONFIRMATION + YES +} + +enum CreationMethod { + UNKNOWN + WEB + MCP + API + AUTOPOST + CLI +} + +enum ShortLinkPreference { + ASK + YES + NO +} + +enum AnnouncementColor { + INFO + WARNING + ERROR +} + +model Announcement { + id String @id @default(uuid()) + title String + description String + color AnnouncementColor @default(INFO) + createdAt DateTime @default(now()) +} diff --git a/libraries/nestjs-libraries/src/database/prisma/sets/sets.repository.ts b/libraries/nestjs-libraries/src/database/prisma/sets/sets.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..37154d3c10d178b445875508822971ed4de25de0 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/sets/sets.repository.ts @@ -0,0 +1,58 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { SetsDto } from '@gitroom/nestjs-libraries/dtos/sets/sets.dto'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class SetsRepository { + constructor(private _sets: PrismaRepository<'sets'>) {} + + getTotal(orgId: string) { + return this._sets.model.sets.count({ + where: { + organizationId: orgId, + }, + }); + } + + getSets(orgId: string) { + return this._sets.model.sets.findMany({ + where: { + organizationId: orgId, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + deleteSet(orgId: string, id: string) { + return this._sets.model.sets.delete({ + where: { + id, + organizationId: orgId, + }, + }); + } + + async createSet(orgId: string, body: SetsDto) { + const { id } = await this._sets.model.sets.upsert({ + where: { + id: body.id || uuidv4(), + organizationId: orgId, + }, + create: { + id: body.id || uuidv4(), + organizationId: orgId, + name: body.name, + content: body.content, + }, + update: { + name: body.name, + content: body.content, + }, + }); + + return { id }; + } +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/database/prisma/sets/sets.service.ts b/libraries/nestjs-libraries/src/database/prisma/sets/sets.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b74f24487f0db4af3325667b00f190cb74c342bf --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/sets/sets.service.ts @@ -0,0 +1,24 @@ +import { Injectable } from '@nestjs/common'; +import { SetsRepository } from '@gitroom/nestjs-libraries/database/prisma/sets/sets.repository'; +import { SetsDto } from '@gitroom/nestjs-libraries/dtos/sets/sets.dto'; + +@Injectable() +export class SetsService { + constructor(private _setsRepository: SetsRepository) {} + + getTotal(orgId: string) { + return this._setsRepository.getTotal(orgId); + } + + getSets(orgId: string) { + return this._setsRepository.getSets(orgId); + } + + createSet(orgId: string, body: SetsDto) { + return this._setsRepository.createSet(orgId, body); + } + + deleteSet(orgId: string, id: string) { + return this._setsRepository.deleteSet(orgId, id); + } +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/database/prisma/signatures/signature.repository.ts b/libraries/nestjs-libraries/src/database/prisma/signatures/signature.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6fd0523ce5d38a0fc2a2835c5c786373adb979e --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/signatures/signature.repository.ts @@ -0,0 +1,55 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { v4 as uuidv4 } from 'uuid'; +import { SignatureDto } from '@gitroom/nestjs-libraries/dtos/signature/signature.dto'; + +@Injectable() +export class SignatureRepository { + constructor(private _signatures: PrismaRepository<'signatures'>) {} + + getSignaturesByOrgId(orgId: string) { + return this._signatures.model.signatures.findMany({ + where: { organizationId: orgId, deletedAt: null }, + }); + } + + getDefaultSignature(orgId: string) { + return this._signatures.model.signatures.findFirst({ + where: { organizationId: orgId, autoAdd: true, deletedAt: null }, + }); + } + + async createOrUpdateSignature( + orgId: string, + signature: SignatureDto, + id?: string + ) { + const values = { + organizationId: orgId, + content: signature.content, + autoAdd: signature.autoAdd, + }; + + const { id: updatedId } = await this._signatures.model.signatures.upsert({ + where: { id: id || uuidv4(), organizationId: orgId }, + update: values, + create: values, + }); + + if (values.autoAdd) { + await this._signatures.model.signatures.updateMany({ + where: { organizationId: orgId, id: { not: updatedId } }, + data: { autoAdd: false }, + }); + } + + return { id: updatedId }; + } + + deleteSignature(orgId: string, id: string) { + return this._signatures.model.signatures.update({ + where: { id, organizationId: orgId }, + data: { deletedAt: new Date() }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/signatures/signature.service.ts b/libraries/nestjs-libraries/src/database/prisma/signatures/signature.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..1dfaacbfeb497e96f0c5efab5f3b6c4282e4177c --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/signatures/signature.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { SignatureRepository } from '@gitroom/nestjs-libraries/database/prisma/signatures/signature.repository'; +import { SignatureDto } from '@gitroom/nestjs-libraries/dtos/signature/signature.dto'; + +@Injectable() +export class SignatureService { + constructor(private _signatureRepository: SignatureRepository) {} + + getSignaturesByOrgId(orgId: string) { + return this._signatureRepository.getSignaturesByOrgId(orgId); + } + + getDefaultSignature(orgId: string) { + return this._signatureRepository.getDefaultSignature(orgId); + } + + createOrUpdateSignature(orgId: string, signature: SignatureDto, id?: string) { + return this._signatureRepository.createOrUpdateSignature( + orgId, + signature, + id + ); + } + + deleteSignature(orgId: string, id: string) { + return this._signatureRepository.deleteSignature(orgId, id); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts new file mode 100644 index 0000000000000000000000000000000000000000..d8d58ef005eb7e1e1c0c22c09b50fabf109726ae --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/pricing.ts @@ -0,0 +1,113 @@ +export interface PricingInnerInterface { + current: string; + month_price: number; + year_price: number; + channel?: number; + posts_per_month: number; + team_members: boolean; + community_features: boolean; + featured_by_gitroom: boolean; + ai: boolean; + import_from_channels: boolean; + image_generator?: boolean; + image_generation_count: number; + generate_videos: number; + public_api: boolean; + webhooks: number; + autoPost: boolean; +} +export interface PricingInterface { + [key: string]: PricingInnerInterface; +} +export const pricing: PricingInterface = { + FREE: { + current: 'FREE', + month_price: 0, + year_price: 0, + channel: 0, + image_generation_count: 0, + posts_per_month: 0, + team_members: false, + community_features: false, + featured_by_gitroom: false, + ai: false, + import_from_channels: false, + image_generator: false, + public_api: false, + webhooks: 0, + autoPost: false, + generate_videos: 0, + }, + STANDARD: { + current: 'STANDARD', + month_price: 29, + year_price: 278, + channel: 5, + posts_per_month: 400, + image_generation_count: 20, + team_members: false, + ai: true, + community_features: false, + featured_by_gitroom: false, + import_from_channels: true, + image_generator: false, + public_api: true, + webhooks: 2, + autoPost: false, + generate_videos: 3, + }, + TEAM: { + current: 'TEAM', + month_price: 39, + year_price: 374, + channel: 10, + posts_per_month: 1000000, + image_generation_count: 100, + community_features: true, + team_members: true, + featured_by_gitroom: true, + ai: true, + import_from_channels: true, + image_generator: true, + public_api: true, + webhooks: 10, + autoPost: true, + generate_videos: 10, + }, + PRO: { + current: 'PRO', + month_price: 49, + year_price: 470, + channel: 30, + posts_per_month: 1000000, + image_generation_count: 300, + community_features: true, + team_members: true, + featured_by_gitroom: true, + ai: true, + import_from_channels: true, + image_generator: true, + public_api: true, + webhooks: 30, + autoPost: true, + generate_videos: 30, + }, + ULTIMATE: { + current: 'ULTIMATE', + month_price: 99, + year_price: 950, + channel: 100, + posts_per_month: 1000000, + image_generation_count: 500, + community_features: true, + team_members: true, + featured_by_gitroom: true, + ai: true, + import_from_channels: true, + image_generator: true, + public_api: true, + webhooks: 10000, + autoPost: true, + generate_videos: 60, + }, +}; diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..afaa9b09a4e83e2bb220bb3e2452b5368470ae6f --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.repository.ts @@ -0,0 +1,286 @@ +import { Injectable } from '@nestjs/common'; +import { + PrismaRepository, + PrismaTransaction, +} from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import dayjs from 'dayjs'; +import { Organization } from '@prisma/client'; + +@Injectable() +export class SubscriptionRepository { + constructor( + private readonly _subscription: PrismaRepository<'subscription'>, + private readonly _organization: PrismaRepository<'organization'>, + private readonly _user: PrismaRepository<'user'>, + private readonly _credits: PrismaRepository<'credits'>, + private _usedCodes: PrismaRepository<'usedCodes'> + ) {} + + getUserAccount(userId: string) { + return this._user.model.user.findFirst({ + where: { + id: userId, + }, + select: { + account: true, + connectedAccount: true, + }, + }); + } + + getCode(code: string) { + return this._usedCodes.model.usedCodes.findFirst({ + where: { + code, + }, + }); + } + + updateAccount(userId: string, account: string) { + return this._user.model.user.update({ + where: { + id: userId, + }, + data: { + account, + }, + }); + } + + getSubscriptionByOrganizationId(organizationId: string) { + return this._subscription.model.subscription.findFirst({ + where: { + organizationId, + deletedAt: null, + }, + }); + } + + updateConnectedStatus(account: string, accountCharges: boolean) { + return this._user.model.user.updateMany({ + where: { + account, + }, + data: { + connectedAccount: accountCharges, + }, + }); + } + + getCustomerIdByOrgId(organizationId: string) { + return this._organization.model.organization.findFirst({ + where: { + id: organizationId, + }, + select: { + paymentId: true, + }, + }); + } + + checkSubscription(organizationId: string, subscriptionId: string) { + return this._subscription.model.subscription.findFirst({ + where: { + organizationId, + identifier: subscriptionId, + deletedAt: null, + }, + }); + } + + deleteSubscriptionByCustomerId(customerId: string) { + return this._subscription.model.subscription.deleteMany({ + where: { + organization: { + paymentId: customerId, + }, + }, + }); + } + + updateCustomerId(organizationId: string, customerId: string) { + return this._organization.model.organization.update({ + where: { + id: organizationId, + }, + data: { + paymentId: customerId, + }, + }); + } + + async getSubscriptionByOrgId(orgId: string) { + return this._subscription.model.subscription.findFirst({ + where: { + organizationId: orgId, + }, + }); + } + + async getSubscriptionByCustomerId(customerId: string) { + return this._subscription.model.subscription.findFirst({ + where: { + organization: { + paymentId: customerId, + }, + }, + }); + } + + async getOrganizationByCustomerId(customerId: string) { + return this._organization.model.organization.findFirst({ + where: { + paymentId: customerId, + }, + }); + } + + async createOrUpdateSubscription( + isTrailing: boolean, + identifier: string, + customerId: string, + totalChannels: number, + billing: 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE', + period: 'MONTHLY' | 'YEARLY', + cancelAt: number | null, + code?: string, + org?: { id: string } + ) { + const findOrg = + org || (await this.getOrganizationByCustomerId(customerId))!; + + if (!findOrg) { + return; + } + + await this._subscription.model.subscription.upsert({ + where: { + organizationId: findOrg.id, + ...(!code + ? { + organization: { + paymentId: customerId, + }, + } + : {}), + }, + update: { + subscriptionTier: billing, + totalChannels, + period, + identifier, + isLifetime: !!code, + cancelAt: cancelAt ? new Date(cancelAt * 1000) : null, + deletedAt: null, + }, + create: { + organizationId: findOrg.id, + subscriptionTier: billing, + isLifetime: !!code, + totalChannels, + period, + cancelAt: cancelAt ? new Date(cancelAt * 1000) : null, + identifier, + deletedAt: null, + }, + }); + + await this._organization.model.organization.update({ + where: { + id: findOrg.id, + }, + data: { + isTrailing, + allowTrial: false, + }, + }); + + if (code) { + await this._usedCodes.model.usedCodes.create({ + data: { + code, + orgId: findOrg.id, + }, + }); + } + } + + getSubscriptionByIdentifier(identifier: string) { + return this._subscription.model.subscription.findFirst({ + where: { + identifier, + deletedAt: null, + }, + include: { + organization: true, + }, + }); + } + + getSubscription(organizationId: string) { + return this._subscription.model.subscription.findFirst({ + where: { + organizationId, + deletedAt: null, + }, + }); + } + + async getCreditsFrom( + organizationId: string, + from: dayjs.Dayjs, + type = 'ai_images' + ) { + const load = await this._credits.model.credits.groupBy({ + by: ['organizationId'], + where: { + organizationId, + type, + createdAt: { + gte: from.toDate(), + }, + }, + _sum: { + credits: true, + }, + }); + + return load?.[0]?._sum?.credits || 0; + } + + async useCredit( + org: Organization, + type = 'ai_images', + func: () => Promise + ) { + const data = await this._credits.model.credits.create({ + data: { + organizationId: org.id, + credits: 1, + type, + }, + }); + + try { + return await func(); + } catch (err) { + await this._credits.model.credits.delete({ + where: { + id: data.id, + }, + }); + throw err; + } + } + + setCustomerId(orgId: string, customerId: string) { + return this._organization.model.organization.update({ + where: { + id: orgId, + }, + data: { + paymentId: customerId, + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..f607e6d1341ed91e94a02e9f29054da9a36b456e --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/subscriptions/subscription.service.ts @@ -0,0 +1,264 @@ +import { Injectable } from '@nestjs/common'; +import { pricing } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/pricing'; +import { SubscriptionRepository } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.repository'; +import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; +import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; +import { Organization } from '@prisma/client'; +import dayjs from 'dayjs'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; + +@Injectable() +export class SubscriptionService { + constructor( + private readonly _subscriptionRepository: SubscriptionRepository, + private readonly _integrationService: IntegrationService, + private readonly _organizationService: OrganizationService + ) {} + + getSubscriptionByOrganizationId(organizationId: string) { + return this._subscriptionRepository.getSubscriptionByOrganizationId( + organizationId + ); + } + + useCredit( + organization: Organization, + type = 'ai_images', + func: () => Promise + ): Promise { + return this._subscriptionRepository.useCredit(organization, type, func); + } + + getCode(code: string) { + return this._subscriptionRepository.getCode(code); + } + + async deleteSubscription(customerId: string) { + await this.modifySubscription( + customerId, + pricing.FREE.channel || 0, + 'FREE' + ); + return this._subscriptionRepository.deleteSubscriptionByCustomerId( + customerId + ); + } + + updateCustomerId(organizationId: string, customerId: string) { + return this._subscriptionRepository.updateCustomerId( + organizationId, + customerId + ); + } + + async checkSubscription(organizationId: string, subscriptionId: string) { + return await this._subscriptionRepository.checkSubscription( + organizationId, + subscriptionId + ); + } + + async modifySubscriptionByOrg( + organizationId: string, + totalChannels: number, + billing: 'FREE' | 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE' + ) { + if (!organizationId) { + return false; + } + + const getCurrentSubscription = + (await this._subscriptionRepository.getSubscriptionByOrgId( + organizationId + ))!; + + const from = pricing[getCurrentSubscription?.subscriptionTier || 'FREE']; + const to = pricing[billing]; + + const currentTotalChannels = ( + await this._integrationService.getIntegrationsList(organizationId) + ).filter((f) => !f.disabled); + + if (currentTotalChannels.length > totalChannels) { + await this._integrationService.disableIntegrations( + organizationId, + currentTotalChannels.length - totalChannels + ); + } + + if (from.team_members && !to.team_members) { + await this._organizationService.disableOrEnableNonSuperAdminUsers( + organizationId, + true + ); + } + + if (!from.team_members && to.team_members) { + await this._organizationService.disableOrEnableNonSuperAdminUsers( + organizationId, + false + ); + } + + if (billing === 'FREE') { + await this._integrationService.changeActiveCron(organizationId); + } + + return true; + } + + async modifySubscription( + customerId: string, + totalChannels: number, + billing: 'FREE' | 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE' + ) { + if (!customerId) { + return false; + } + + const getOrgByCustomerId = + await this._subscriptionRepository.getOrganizationByCustomerId( + customerId + ); + + const getCurrentSubscription = + (await this._subscriptionRepository.getSubscriptionByCustomerId( + customerId + ))!; + + if ( + !getOrgByCustomerId || + (getCurrentSubscription && getCurrentSubscription?.isLifetime) + ) { + return false; + } + + const from = pricing[getCurrentSubscription?.subscriptionTier || 'FREE']; + const to = pricing[billing]; + + const currentTotalChannels = ( + await this._integrationService.getIntegrationsList( + getOrgByCustomerId?.id! + ) + ).filter((f) => !f.disabled); + + if (currentTotalChannels.length > totalChannels) { + await this._integrationService.disableIntegrations( + getOrgByCustomerId?.id!, + currentTotalChannels.length - totalChannels + ); + } + + if (from.team_members && !to.team_members) { + await this._organizationService.disableOrEnableNonSuperAdminUsers( + getOrgByCustomerId?.id!, + true + ); + } + + if (!from.team_members && to.team_members) { + await this._organizationService.disableOrEnableNonSuperAdminUsers( + getOrgByCustomerId?.id!, + false + ); + } + + if (billing === 'FREE') { + await this._integrationService.changeActiveCron(getOrgByCustomerId?.id!); + } + + return true; + } + + async createOrUpdateSubscription( + isTrailing: boolean, + identifier: string, + customerId: string, + totalChannels: number, + billing: 'STANDARD' | 'TEAM' | 'PRO' | 'ULTIMATE', + period: 'MONTHLY' | 'YEARLY', + cancelAt: number | null, + code?: string, + org?: string + ) { + if (!code) { + try { + const load = await this.modifySubscription( + customerId, + totalChannels, + billing + ); + if (!load) { + return {}; + } + } catch (e) { + return {}; + } + } + return this._subscriptionRepository.createOrUpdateSubscription( + isTrailing, + identifier, + customerId, + totalChannels, + billing, + period, + cancelAt, + code, + org ? { id: org } : undefined + ); + } + + getSubscriptionByIdentifier(identifier: string) { + return this._subscriptionRepository.getSubscriptionByIdentifier(identifier); + } + + async getSubscription(organizationId: string) { + return this._subscriptionRepository.getSubscription(organizationId); + } + + async checkCredits(organization: Organization, checkType = 'ai_images') { + // @ts-ignore + const type = organization?.subscription?.subscriptionTier || 'FREE'; + + if (type === 'FREE') { + return { credits: 0 }; + } + + // @ts-ignore + let date = dayjs(organization.subscription.createdAt); + while (date.isBefore(dayjs())) { + date = date.add(1, 'month'); + } + + const checkFromMonth = date.subtract(1, 'month'); + const imageGenerationCount = + checkType === 'ai_images' + ? pricing[type].image_generation_count + : pricing[type].generate_videos; + + const totalUse = await this._subscriptionRepository.getCreditsFrom( + organization.id, + checkFromMonth, + checkType + ); + + return { + credits: imageGenerationCount - totalUse, + }; + } + + async addSubscription(orgId: string, userId: string, subscription: any) { + await this._subscriptionRepository.setCustomerId(orgId, userId); + return this.createOrUpdateSubscription( + false, + makeId(5), + userId, + pricing[subscription].channel!, + subscription, + 'MONTHLY', + null, + undefined, + orgId + ); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.repository.ts b/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..48a9933efda59fc9b64b6d9b7ce35a594664c7f9 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.repository.ts @@ -0,0 +1,64 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; + +@Injectable() +export class ThirdPartyRepository { + constructor(private _thirdParty: PrismaRepository<'thirdParty'>) {} + + getAllThirdPartiesByOrganization(org: string) { + return this._thirdParty.model.thirdParty.findMany({ + where: { organizationId: org, deletedAt: null }, + select: { + id: true, + name: true, + identifier: true, + }, + }); + } + + deleteIntegration(org: string, id: string) { + return this._thirdParty.model.thirdParty.update({ + where: { id, organizationId: org }, + data: { deletedAt: new Date() }, + }); + } + + getIntegrationById(org: string, id: string) { + return this._thirdParty.model.thirdParty.findFirst({ + where: { id, organizationId: org, deletedAt: null }, + }); + } + + saveIntegration( + org: string, + identifier: string, + apiKey: string, + data: { name: string; username: string; id: string } + ) { + return this._thirdParty.model.thirdParty.upsert({ + where: { + organizationId_internalId: { + internalId: data.id, + organizationId: org, + }, + }, + create: { + organizationId: org, + name: data.name, + internalId: data.id, + identifier, + apiKey: AuthService.fixedEncryption(apiKey), + deletedAt: null, + }, + update: { + organizationId: org, + name: data.name, + internalId: data.id, + identifier, + apiKey: AuthService.fixedEncryption(apiKey), + deletedAt: null, + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.service.ts b/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fd888b8672b4aac0f1f8aba6feb8f6619283441 --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/third-party/third-party.service.ts @@ -0,0 +1,28 @@ +import { Injectable } from '@nestjs/common'; +import { ThirdPartyRepository } from '@gitroom/nestjs-libraries/database/prisma/third-party/third-party.repository'; + +@Injectable() +export class ThirdPartyService { + constructor(private _thirdPartyRepository: ThirdPartyRepository) {} + + getAllThirdPartiesByOrganization(org: string) { + return this._thirdPartyRepository.getAllThirdPartiesByOrganization(org); + } + + deleteIntegration(org: string, id: string) { + return this._thirdPartyRepository.deleteIntegration(org, id); + } + + getIntegrationById(org: string, id: string) { + return this._thirdPartyRepository.getIntegrationById(org, id); + } + + saveIntegration( + org: string, + identifier: string, + apiKey: string, + data: { name: string; username: string; id: string } + ) { + return this._thirdPartyRepository.saveIntegration(org, identifier, apiKey, data); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/users/users.repository.ts b/libraries/nestjs-libraries/src/database/prisma/users/users.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..38a03c7611bed4b8252b9c06b02e537ee527c8dc --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/users/users.repository.ts @@ -0,0 +1,177 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { Provider } from '@prisma/client'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; +import { UserDetailDto } from '@gitroom/nestjs-libraries/dtos/users/user.details.dto'; +import { EmailNotificationsDto } from '@gitroom/nestjs-libraries/dtos/users/email-notifications.dto'; + +@Injectable() +export class UsersRepository { + constructor(private _user: PrismaRepository<'user'>) {} + + getImpersonateUser(name: string) { + return this._user.model.user.findMany({ + where: { + OR: [ + { + name: { + contains: name, + }, + }, + { + email: { + contains: name, + }, + }, + { + id: { + contains: name, + }, + }, + ], + }, + select: { + id: true, + name: true, + email: true, + }, + take: 10, + }); + } + + getUserById(id: string) { + return this._user.model.user.findFirst({ + where: { + id, + }, + }); + } + + getUserByEmail(email: string) { + return this._user.model.user.findFirst({ + where: { + email, + providerName: Provider.LOCAL, + }, + include: { + picture: { + select: { + id: true, + path: true, + }, + }, + }, + }); + } + + activateUser(id: string) { + return this._user.model.user.update({ + where: { + id, + }, + data: { + activated: true, + }, + }); + } + + getUserByProvider(providerId: string, provider: Provider) { + return this._user.model.user.findFirst({ + where: { + providerId, + providerName: provider, + }, + }); + } + + updatePassword(id: string, password: string) { + return this._user.model.user.update({ + where: { + id, + providerName: Provider.LOCAL, + }, + data: { + password: AuthService.hashPassword(password), + }, + }); + } + + changeAudienceSize(userId: string, audience: number) { + return this._user.model.user.update({ + where: { + id: userId, + }, + data: { + audience, + }, + }); + } + + async getPersonal(userId: string) { + const user = await this._user.model.user.findUnique({ + where: { + id: userId, + }, + select: { + id: true, + name: true, + bio: true, + picture: { + select: { + id: true, + path: true, + }, + }, + }, + }); + + return user; + } + + async changePersonal(userId: string, body: UserDetailDto) { + await this._user.model.user.update({ + where: { + id: userId, + }, + data: { + name: body.fullname, + bio: body.bio, + picture: body.picture + ? { + connect: { + id: body.picture.id, + }, + } + : { + disconnect: true, + }, + }, + }); + } + + async getEmailNotifications(userId: string) { + return this._user.model.user.findUnique({ + where: { + id: userId, + }, + select: { + sendSuccessEmails: true, + sendFailureEmails: true, + sendStreakEmails: true, + }, + }); + } + + async updateEmailNotifications(userId: string, body: EmailNotificationsDto) { + await this._user.model.user.update({ + where: { + id: userId, + }, + data: { + sendSuccessEmails: body.sendSuccessEmails, + sendFailureEmails: body.sendFailureEmails, + sendStreakEmails: body.sendStreakEmails, + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/users/users.service.ts b/libraries/nestjs-libraries/src/database/prisma/users/users.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..863949cbc2ee3ab1e5d5b91273341d61ca0ca33f --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/users/users.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { UsersRepository } from '@gitroom/nestjs-libraries/database/prisma/users/users.repository'; +import { Provider } from '@prisma/client'; +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'; + +@Injectable() +export class UsersService { + constructor( + private _usersRepository: UsersRepository, + private _organizationRepository: OrganizationRepository + ) {} + + getUserByEmail(email: string) { + return this._usersRepository.getUserByEmail(email); + } + + getUserById(id: string) { + return this._usersRepository.getUserById(id); + } + + getImpersonateUser(name: string) { + return this._organizationRepository.getImpersonateUser(name); + } + + getUserByProvider(providerId: string, provider: Provider) { + return this._usersRepository.getUserByProvider(providerId, provider); + } + + activateUser(id: string) { + return this._usersRepository.activateUser(id); + } + + updatePassword(id: string, password: string) { + return this._usersRepository.updatePassword(id, password); + } + + getPersonal(userId: string) { + return this._usersRepository.getPersonal(userId); + } + + changePersonal(userId: string, body: UserDetailDto) { + return this._usersRepository.changePersonal(userId, body); + } + + getEmailNotifications(userId: string) { + return this._usersRepository.getEmailNotifications(userId); + } + + updateEmailNotifications(userId: string, body: EmailNotificationsDto) { + return this._usersRepository.updateEmailNotifications(userId, body); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.repository.ts b/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.repository.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc1b025c7f722226190fbed2ab9a4f32d4608c5c --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.repository.ts @@ -0,0 +1,87 @@ +import { PrismaRepository } from '@gitroom/nestjs-libraries/database/prisma/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { WebhooksDto } from '@gitroom/nestjs-libraries/dtos/webhooks/webhooks.dto'; +import { v4 as uuidv4 } from 'uuid'; + +@Injectable() +export class WebhooksRepository { + constructor(private _webhooks: PrismaRepository<'webhooks'>) {} + + getTotal(orgId: string) { + return this._webhooks.model.webhooks.count({ + where: { + organizationId: orgId, + deletedAt: null, + }, + }); + } + + getWebhooks(orgId: string) { + return this._webhooks.model.webhooks.findMany({ + where: { + organizationId: orgId, + deletedAt: null, + }, + include: { + integrations: { + select: { + integration: { + select: { + id: true, + picture: true, + name: true, + }, + }, + }, + }, + }, + }); + } + + deleteWebhook(orgId: string, id: string) { + return this._webhooks.model.webhooks.update({ + where: { + id, + organizationId: orgId, + }, + data: { + deletedAt: new Date(), + }, + }); + } + + async createWebhook(orgId: string, body: WebhooksDto) { + const { id } = await this._webhooks.model.webhooks.upsert({ + where: { + id: body.id || uuidv4(), + organizationId: orgId, + }, + create: { + organizationId: orgId, + url: body.url, + name: body.name, + }, + update: { + url: body.url, + name: body.name, + }, + }); + + await this._webhooks.model.webhooks.update({ + where: { + id, + organizationId: orgId, + }, + data: { + integrations: { + deleteMany: {}, + create: body.integrations.map((integration) => ({ + integrationId: integration.id, + })), + }, + }, + }); + + return { id }; + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.service.ts b/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa279e0aca44c00a77c75679033df13ac588628c --- /dev/null +++ b/libraries/nestjs-libraries/src/database/prisma/webhooks/webhooks.service.ts @@ -0,0 +1,24 @@ +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'; + +@Injectable() +export class WebhooksService { + constructor(private _webhooksRepository: WebhooksRepository) {} + + getTotal(orgId: string) { + return this._webhooksRepository.getTotal(orgId); + } + + getWebhooks(orgId: string) { + return this._webhooksRepository.getWebhooks(orgId); + } + + createWebhook(orgId: string, body: WebhooksDto) { + return this._webhooksRepository.createWebhook(orgId, body); + } + + deleteWebhook(orgId: string, id: string) { + return this._webhooksRepository.deleteWebhook(orgId, id); + } +} diff --git a/libraries/nestjs-libraries/src/dtos/agencies/create.agency.dto.ts b/libraries/nestjs-libraries/src/dtos/agencies/create.agency.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..33862336c18bdaddd2bb976bafa8ceedb307d395 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/agencies/create.agency.dto.ts @@ -0,0 +1,96 @@ +import { + ArrayMaxSize, + ArrayMinSize, + IsDefined, + IsIn, + IsOptional, + IsString, + IsUrl, + MinLength, + ValidateIf, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateAgencyLogoDto { + @IsString() + @IsDefined() + id: string; + + path: string; +} +export class CreateAgencyDto { + @IsString() + @MinLength(3) + name: string; + + @IsUrl() + @IsDefined() + website: string; + + @IsUrl() + @ValidateIf((o) => o.facebook) + facebook: string; + + @IsString() + @IsOptional() + instagram: string; + + @IsString() + @IsOptional() + twitter: string; + + @IsUrl() + @ValidateIf((o) => o.linkedIn) + linkedIn: string; + + @IsUrl() + @ValidateIf((o) => o.youtube) + youtube: string; + + @IsString() + @IsOptional() + tiktok: string; + + @Type(() => CreateAgencyLogoDto) + logo: CreateAgencyLogoDto; + + @IsString() + shortDescription: string; + + @IsString() + description: string; + + @IsString({ + each: true, + }) + @ArrayMinSize(1) + @ArrayMaxSize(3) + @IsIn( + [ + 'Real Estate', + 'Fashion', + 'Health and Fitness', + 'Beauty', + 'Travel', + 'Food', + 'Tech', + 'Gaming', + 'Parenting', + 'Education', + 'Business', + 'Finance', + 'DIY', + 'Pets', + 'Lifestyle', + 'Sports', + 'Entertainment', + 'Art', + 'Photography', + 'Sustainability', + ], + { + each: true, + } + ) + niches: string[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/analytics/stars.list.dto.ts b/libraries/nestjs-libraries/src/dtos/analytics/stars.list.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..660f854f27cdc20c69f7d9d937c992ecb37663f6 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/analytics/stars.list.dto.ts @@ -0,0 +1,15 @@ +import { IsDefined, IsIn, IsNumber, IsOptional } from 'class-validator'; + +export class StarsListDto { + @IsNumber() + @IsDefined() + page: number; + + @IsOptional() + @IsIn(['login', 'totalStars', 'stars', 'date', 'forks', 'totalForks']) + key: 'login' | 'date' | 'stars' | 'totalStars'; + + @IsOptional() + @IsIn(['desc', 'asc']) + state: 'desc' | 'asc'; +} diff --git a/libraries/nestjs-libraries/src/dtos/announcements/announcements.dto.ts b/libraries/nestjs-libraries/src/dtos/announcements/announcements.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac02930db770bbc552afb09b189a2070bd39df40 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/announcements/announcements.dto.ts @@ -0,0 +1,16 @@ +import { IsDefined, IsIn, IsOptional, IsString } from 'class-validator'; + +export class AnnouncementDto { + @IsString() + @IsDefined() + title: string; + + @IsString() + @IsDefined() + description: string; + + @IsOptional() + @IsString() + @IsIn(['INFO', 'WARNING', 'ERROR']) + color?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts b/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..42fad6f04a79d22208d2ec920440fad6596261df --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/auth/create.org.user.dto.ts @@ -0,0 +1,40 @@ +import { + IsDefined, + IsEmail, + IsString, + MaxLength, + MinLength, + ValidateIf, +} from 'class-validator'; +import { Provider } from '@prisma/client'; + +export class CreateOrgUserDto { + @IsString() + @MinLength(3) + @MaxLength(64) + @IsDefined() + @ValidateIf((o) => !o.providerToken) + password: string; + + @IsString() + @IsDefined() + provider: Provider; + + @IsString() + @IsDefined() + @ValidateIf((o) => !o.password) + providerToken: string; + + @IsEmail() + @IsDefined() + @ValidateIf((o) => !o.providerToken) + email: string; + + @IsString() + @IsDefined() + @MinLength(3) + @MaxLength(128) + company: string; + + datafast_visitor_id: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/auth/forgot-return.password.dto.ts b/libraries/nestjs-libraries/src/dtos/auth/forgot-return.password.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d7a93de90a753cd519a9adc8d0bbbeac972ed3b --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/auth/forgot-return.password.dto.ts @@ -0,0 +1,28 @@ +import { + IsDefined, + IsIn, + IsString, + MinLength, + ValidateIf, +} from 'class-validator'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; + +export class ForgotReturnPasswordDto { + @IsString() + @IsDefined() + @MinLength(3) + password: string; + + @IsString() + @IsDefined() + @IsIn([makeId(10)], { + message: 'Passwords do not match', + }) + @ValidateIf((o) => o.password !== o.repeatPassword) + repeatPassword: string; + + @IsString() + @IsDefined() + @MinLength(5) + token: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/auth/forgot.password.dto.ts b/libraries/nestjs-libraries/src/dtos/auth/forgot.password.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e6fedc9f99fab3aa1d0d1574d2a1e3de4cdf6fb --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/auth/forgot.password.dto.ts @@ -0,0 +1,8 @@ +import { IsDefined, IsEmail, IsString } from 'class-validator'; + +export class ForgotPasswordDto { + @IsString() + @IsDefined() + @IsEmail() + email: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/auth/login.user.dto.ts b/libraries/nestjs-libraries/src/dtos/auth/login.user.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7bb8b9cf00dfa8c3a790f886d042f2552082020 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/auth/login.user.dto.ts @@ -0,0 +1,31 @@ +import { + IsDefined, + IsEmail, + IsString, + MinLength, + ValidateIf, +} from 'class-validator'; +import { Provider } from '@prisma/client'; + +export class LoginUserDto { + @IsString() + @IsDefined() + @ValidateIf((o) => !o.providerToken) + @MinLength(3) + password: string; + + @IsString() + @IsDefined() + provider: Provider; + + @IsString() + @IsDefined() + @ValidateIf((o) => !o.password) + providerToken: string; + + @IsEmail() + @IsDefined() + email: string; + + datafast_visitor_id: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/auth/resend-activation.dto.ts b/libraries/nestjs-libraries/src/dtos/auth/resend-activation.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..5002acf29db913364bb6825fd008bffc089c6778 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/auth/resend-activation.dto.ts @@ -0,0 +1,9 @@ +import { IsDefined, IsEmail, IsString } from 'class-validator'; + +export class ResendActivationDto { + @IsString() + @IsDefined() + @IsEmail() + email: string; +} + diff --git a/libraries/nestjs-libraries/src/dtos/autopost/autopost.dto.ts b/libraries/nestjs-libraries/src/dtos/autopost/autopost.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..d2850db582314add9868125ac43aaf69d921ca1e --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/autopost/autopost.dto.ts @@ -0,0 +1,64 @@ +import { + IsArray, + IsBoolean, + IsDefined, + IsOptional, + IsString, + IsUrl, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsSafeWebhookUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; + +export class Integrations { + @IsString() + @IsDefined() + id: string; +} + +export class AutopostDto { + @IsString() + @IsDefined() + title: string; + + @IsString() + @IsOptional() + content: string; + + @IsString() + @IsOptional() + lastUrl: string; + + @IsBoolean() + @IsDefined() + onSlot: boolean; + + @IsBoolean() + @IsDefined() + syncLast: boolean; + + @IsUrl() + @IsDefined() + @IsSafeWebhookUrl({ + message: + 'Autopost URL must be a public HTTPS URL and cannot point to internal network addresses', + }) + url: string; + + @IsBoolean() + @IsDefined() + active: boolean; + + @IsBoolean() + @IsDefined() + addPicture: boolean; + + @IsBoolean() + @IsDefined() + generateContent: boolean; + + @IsArray() + @Type(() => Integrations) + @ValidateNested({ each: true }) + integrations: Integrations[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/billing/billing.subscribe.dto.ts b/libraries/nestjs-libraries/src/dtos/billing/billing.subscribe.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1247f067dd99e840550d9994fda41626e6846b0 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/billing/billing.subscribe.dto.ts @@ -0,0 +1,16 @@ +import { IsIn } from 'class-validator'; + +export class BillingSubscribeDto { + @IsIn(['MONTHLY', 'YEARLY']) + period: 'MONTHLY' | 'YEARLY'; + + @IsIn(['STANDARD', 'PRO', 'TEAM', 'ULTIMATE']) + billing: 'STANDARD' | 'PRO' | 'TEAM' | 'ULTIMATE'; + + utm: string; + + dub: string; + + datafast_session_id: string; + datafast_visitor_id: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/comments/add.comment.dto.ts b/libraries/nestjs-libraries/src/dtos/comments/add.comment.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..df7114cbfbac2572c1417f15246e5e19767f93a1 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/comments/add.comment.dto.ts @@ -0,0 +1,4 @@ +export class AddCommentDto { + content: string; + date: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/generator/create.generated.posts.dto.ts b/libraries/nestjs-libraries/src/dtos/generator/create.generated.posts.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..da639677b63f2b8d997dfa505a0a6e3b320ac2d9 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/generator/create.generated.posts.dto.ts @@ -0,0 +1,52 @@ +import { + ArrayMinSize, + IsArray, + IsDefined, + IsNumber, + IsString, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +class InnerPost { + @IsString() + @IsDefined() + post: string; +} + +class PostGroup { + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => InnerPost) + @IsDefined() + list: InnerPost[]; +} + +export class CreateGeneratedPostsDto { + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => PostGroup) + @IsDefined() + posts: PostGroup[]; + + @IsNumber() + @IsDefined() + week: number; + + @IsNumber() + @IsDefined() + year: number; + + @IsString() + @IsDefined() + @ValidateIf((o) => !o.url) + url: string; + + @IsString() + @IsDefined() + @ValidateIf((o) => !o.url) + postId: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/generator/generator.dto.ts b/libraries/nestjs-libraries/src/dtos/generator/generator.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..19fab1810e56450868daaa3fa9031f5a8fd71866 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/generator/generator.dto.ts @@ -0,0 +1,18 @@ +import { IsBoolean, IsIn, IsString, MinLength } from 'class-validator'; + +export class GeneratorDto { + @IsString() + @MinLength(10) + research: string; + + @IsBoolean() + isPicture: boolean; + + @IsString() + @IsIn(['one_short', 'one_long', 'thread_short', 'thread_long']) + format: 'one_short' | 'one_long' | 'thread_short' | 'thread_long'; + + @IsString() + @IsIn(['personal', 'company']) + tone: 'personal' | 'company'; +} diff --git a/libraries/nestjs-libraries/src/dtos/integrations/api.key.dto.ts b/libraries/nestjs-libraries/src/dtos/integrations/api.key.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..03b83887c4b0b53a4b2b63ebfda435eb6a9483a9 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/integrations/api.key.dto.ts @@ -0,0 +1,9 @@ +import { IsString, MinLength } from 'class-validator'; + +export class ApiKeyDto { + @IsString() + @MinLength(4, { + message: 'Must be at least 4 characters', + }) + api: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/integrations/connect.integration.dto.ts b/libraries/nestjs-libraries/src/dtos/integrations/connect.integration.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..a74bfb5e361e4689db02210f12812e0a06ff6024 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/integrations/connect.integration.dto.ts @@ -0,0 +1,19 @@ +import { IsDefined, IsOptional, IsString } from 'class-validator'; + +export class ConnectIntegrationDto { + @IsString() + @IsDefined() + state: string; + + @IsString() + @IsDefined() + code: string; + + @IsString() + @IsDefined() + timezone: string; + + @IsString() + @IsOptional() + refresh?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/integrations/integration.function.dto.ts b/libraries/nestjs-libraries/src/dtos/integrations/integration.function.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..b96c940381e9dc9a135ea8d8075e79a710b8f821 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/integrations/integration.function.dto.ts @@ -0,0 +1,13 @@ +import { IsDefined, IsString } from 'class-validator'; + +export class IntegrationFunctionDto { + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsDefined() + id: string; + + data: any; +} diff --git a/libraries/nestjs-libraries/src/dtos/integrations/integration.time.dto.ts b/libraries/nestjs-libraries/src/dtos/integrations/integration.time.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..918df3fd4d89233ad14fe418789f1fc76e341dcf --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/integrations/integration.time.dto.ts @@ -0,0 +1,15 @@ +import { IsArray, IsDefined, IsNumber, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class IntegrationValidateTimeDto { + @IsDefined() + @IsNumber() + time: number; +} +export class IntegrationTimeDto { + @Type(() => IntegrationValidateTimeDto) + @IsArray() + @IsDefined() + @ValidateNested({ each: true }) + time: IntegrationValidateTimeDto[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/media/media.dto.ts b/libraries/nestjs-libraries/src/dtos/media/media.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d0ab7fc24fba3daf527e9286fa37253d8df70a6 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/media/media.dto.ts @@ -0,0 +1,22 @@ +import { IsDefined, IsString, IsUrl, ValidateIf, Validate } from 'class-validator'; +import { ValidUrlExtension, ValidUrlPath } from '@gitroom/helpers/utils/valid.url.path'; + +export class MediaDto { + @IsString() + @IsDefined() + id: string; + + @IsString() + @IsDefined() + @Validate(ValidUrlPath) + @Validate(ValidUrlExtension) + path: string; + + @ValidateIf((o) => o.alt) + @IsString() + alt?: string; + + @ValidateIf((o) => o.thumbnail) + @IsUrl() + thumbnail?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/media/save.media.information.dto.ts b/libraries/nestjs-libraries/src/dtos/media/save.media.information.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5ffb44664988231261eaa9de347d69230cb3632 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/media/save.media.information.dto.ts @@ -0,0 +1,17 @@ +import { IsNumber, IsOptional, IsString, IsUrl, ValidateIf } from 'class-validator'; + +export class SaveMediaInformationDto { + @IsString() + id: string; + + @IsString() + alt: string; + + @IsUrl() + @ValidateIf((o) => !!o.thumbnail) + thumbnail: string; + + @IsNumber() + @ValidateIf((o) => !!o.thumbnailTimestamp) + thumbnailTimestamp: number; +} diff --git a/libraries/nestjs-libraries/src/dtos/media/upload.dto.ts b/libraries/nestjs-libraries/src/dtos/media/upload.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..1acac9b0bdcdbdb510a7d7d6adc5ff8a1b998e96 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/media/upload.dto.ts @@ -0,0 +1,14 @@ +import { IsDefined, IsString, Validate } from 'class-validator'; +import { ValidUrlExtension } from '@gitroom/helpers/utils/valid.url.path'; +import { IsSafeWebhookUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; + +export class UploadDto { + @IsString() + @IsDefined() + @Validate(ValidUrlExtension) + @IsSafeWebhookUrl({ + message: + 'URL must be a public HTTPS URL and cannot point to internal network addresses', + }) + url: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/notifications/get.notifications.dto.ts b/libraries/nestjs-libraries/src/dtos/notifications/get.notifications.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7305bc5f309fbb971cf70e4797a96823b82da39 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/notifications/get.notifications.dto.ts @@ -0,0 +1,10 @@ +import { IsOptional, IsNumber, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class GetNotificationsDto { + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => parseInt(value, 10)) + page?: number = 0; +} diff --git a/libraries/nestjs-libraries/src/dtos/oauth/authorize-oauth.dto.ts b/libraries/nestjs-libraries/src/dtos/oauth/authorize-oauth.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..137257da8246d32d3663ecf3ad3f64dee522acb2 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/oauth/authorize-oauth.dto.ts @@ -0,0 +1,31 @@ +import { IsDefined, IsIn, IsOptional, IsString } from 'class-validator'; + +export class AuthorizeOAuthQueryDto { + @IsString() + @IsDefined() + client_id: string; + + @IsString() + @IsDefined() + @IsIn(['code']) + response_type: string; + + @IsString() + @IsOptional() + state?: string; +} + +export class ApproveOAuthDto { + @IsString() + @IsDefined() + client_id: string; + + @IsString() + @IsOptional() + state?: string; + + @IsString() + @IsDefined() + @IsIn(['approve', 'deny']) + action: 'approve' | 'deny'; +} diff --git a/libraries/nestjs-libraries/src/dtos/oauth/create-oauth-app.dto.ts b/libraries/nestjs-libraries/src/dtos/oauth/create-oauth-app.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9fe002ad2b5f23355291e6de5ad5e9cd764440c --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/oauth/create-oauth-app.dto.ts @@ -0,0 +1,22 @@ +import { IsDefined, IsOptional, IsString, IsUrl, MaxLength } from 'class-validator'; + +export class CreateOAuthAppDto { + @IsString() + @IsDefined() + @MaxLength(100) + name: string; + + @IsString() + @IsOptional() + @MaxLength(500) + description?: string; + + @IsString() + @IsOptional() + pictureId?: string; + + @IsString() + @IsDefined() + @IsUrl({ require_tld: false }) + redirectUrl: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/oauth/token-exchange.dto.ts b/libraries/nestjs-libraries/src/dtos/oauth/token-exchange.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a436eed398a3e67cecb85cd8470a91fa1cc7d34 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/oauth/token-exchange.dto.ts @@ -0,0 +1,19 @@ +import { IsDefined, IsString } from 'class-validator'; + +export class TokenExchangeDto { + @IsString() + @IsDefined() + grant_type: string; + + @IsString() + @IsDefined() + code: string; + + @IsString() + @IsDefined() + client_id: string; + + @IsString() + @IsDefined() + client_secret: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/oauth/update-oauth-app.dto.ts b/libraries/nestjs-libraries/src/dtos/oauth/update-oauth-app.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2a537a7e4e920404ef91f59dbd01bcea69e45d1 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/oauth/update-oauth-app.dto.ts @@ -0,0 +1,22 @@ +import { IsOptional, IsString, IsUrl, MaxLength } from 'class-validator'; + +export class UpdateOAuthAppDto { + @IsString() + @IsOptional() + @MaxLength(100) + name?: string; + + @IsString() + @IsOptional() + @MaxLength(500) + description?: string; + + @IsString() + @IsOptional() + pictureId?: string; + + @IsString() + @IsOptional() + @IsUrl({ require_tld: false }) + redirectUrl?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/plugs/plug.dto.ts b/libraries/nestjs-libraries/src/dtos/plugs/plug.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..9422c3d3c663416bbe08ddede4e468664b1f0f20 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/plugs/plug.dto.ts @@ -0,0 +1,23 @@ +import { IsDefined, IsString, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class FieldsDto { + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsDefined() + value: string; +} + +export class PlugDto { + @IsString() + @IsDefined() + func: string; + + @Type(() => FieldsDto) + @ValidateNested({ each: true }) + @IsDefined() + fields: FieldsDto[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/change.post.status.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/change.post.status.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..466502bfc374c0434024aef62b43491a2125493a --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/change.post.status.dto.ts @@ -0,0 +1,6 @@ +import { IsIn } from 'class-validator'; + +export class ChangePostStatusDto { + @IsIn(['draft', 'schedule']) + status: 'draft' | 'schedule'; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..89c86fa34af7fab25032c5941e70bcf17b317079 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts @@ -0,0 +1,125 @@ +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsDateString, + IsDefined, + IsIn, + IsNumber, + IsOptional, + IsString, + Validate, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { Transform, Type } from 'class-transformer'; +import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto'; +import { + allProviders, + type AllProvidersSettings, + EmptySettings, +} from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings'; +import { ValidContent } from '@gitroom/helpers/utils/valid.images'; +import { sanitizePostContent } from '@gitroom/helpers/utils/sanitize.post.content'; + +export class Integration { + @IsDefined() + @IsString() + id: string; +} + +export class PostContent { + @IsDefined() + @IsString() + @Validate(ValidContent) + @Transform(({ value }) => sanitizePostContent(value)) + content: string; + + @IsOptional() + @IsString() + id: string; + + @IsOptional() + @IsNumber() + delay: number; + + @IsArray() + @Type(() => MediaDto) + @ValidateNested({ each: true }) + image: MediaDto[]; +} + +export class Post { + type?: string; + + @IsDefined() + @Type(() => Integration) + @ValidateNested() + integration: Integration; + + @IsDefined() + @ArrayMinSize(1) + @IsArray() + @Type(() => PostContent) + @ValidateNested({ each: true }) + value: PostContent[]; + + @IsOptional() + @IsString() + group: string; + + @ValidateIf((o) => o.type !== 'draft') + @ValidateNested() + @Type(() => EmptySettings, { + keepDiscriminatorProperty: true, + discriminator: { + property: '__type', + subTypes: allProviders(EmptySettings), + }, + }) + settings: AllProvidersSettings; +} + +class Tags { + @IsDefined() + @IsString() + value: string; + + @IsDefined() + @IsString() + label: string; +} + +export class CreatePostDto { + @IsDefined() + @IsIn(['draft', 'schedule', 'now', 'update']) + type: 'draft' | 'schedule' | 'now' | 'update'; + + @IsOptional() + @IsString() + order?: string; + + @IsDefined() + @IsBoolean() + shortLink: boolean; + + @IsOptional() + @IsNumber() + inter?: number; + + @IsDefined() + @IsDateString() + date: string; + + @IsArray() + @IsDefined() + @ValidateNested({ each: true }) + tags: Tags[]; + + @IsDefined() + @Type(() => Post) + @IsArray() + @ValidateNested({ each: true }) + @ArrayMinSize(1) + posts: Post[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/create.tag.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/create.tag.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..a499e52ae6cea971ae2345365cc981d1ca75b18e --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/create.tag.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class CreateTagDto { + @IsString() + name: string; + + @IsString() + color: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/get.posts.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/get.posts.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..948078188f06550cfd0a9fc3bf88f232e1eb6550 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/get.posts.dto.ts @@ -0,0 +1,17 @@ +import { + IsOptional, + IsString, + IsDateString, +} from 'class-validator'; + +export class GetPostsDto { + @IsDateString() + startDate: string; + + @IsDateString() + endDate: string; + + @IsOptional() + @IsString() + customer: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/get.posts.list.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/get.posts.list.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..70e764e8d4d10c48cbfa04a7b4b9025e0235c740 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/get.posts.list.dto.ts @@ -0,0 +1,34 @@ +import { + IsOptional, + IsString, + IsNumber, + Min, + Max, + IsIn, +} from 'class-validator'; +import { Transform } from 'class-transformer'; + +export type PostListStateFilter = 'all' | 'scheduled' | 'draft' | 'published'; + +export class GetPostsListDto { + @IsOptional() + @IsNumber() + @Min(0) + @Transform(({ value }) => parseInt(value, 10)) + page?: number = 0; + + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + @Transform(({ value }) => parseInt(value, 10)) + limit?: number = 20; + + @IsOptional() + @IsString() + customer?: string; + + @IsOptional() + @IsIn(['all', 'scheduled', 'draft', 'published']) + state?: PostListStateFilter = 'all'; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b651158029f15ca0bfb8de0b2a9f5788d27a38e --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/all.providers.settings.ts @@ -0,0 +1,114 @@ +import { RedditSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/reddit.dto'; +import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/pinterest.dto'; +import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto'; +import { TikTokDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tiktok.dto'; +import { XDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/x.dto'; +import { LemmySettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/lemmy.dto'; +import { DribbbleDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dribbble.dto'; +import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto'; +import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto'; +import { KickDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/kick.dto'; +import { TwitchDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/twitch.dto'; +import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto'; +import { LinkedinDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/linkedin.dto'; +import { IsIn } from 'class-validator'; +import { MediumSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/medium.settings.dto'; +import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto'; +import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto'; +import { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/wordpress.dto'; +import { ListmonkDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/listmonk.dto'; +import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/gmb.settings.dto'; +import { FarcasterDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/farcaster.dto'; +import { FacebookDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/facebook.dto'; +import { MoltbookDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/moltbook.dto'; +import { SkoolDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/skool.dto'; +import { WhopDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/whop.dto'; +import { MeweDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/mewe.dto'; +import { TumblrDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tumblr.dto'; + +export type ProviderExtension = { __type: T } & M; +export type AllProvidersSettings = + | ProviderExtension<'reddit', RedditSettingsDto> + | ProviderExtension<'lemmy', LemmySettingsDto> + | ProviderExtension<'youtube', YoutubeSettingsDto> + | ProviderExtension<'pinterest', PinterestSettingsDto> + | ProviderExtension<'dribbble', DribbbleDto> + | ProviderExtension<'tiktok', TikTokDto> + | ProviderExtension<'discord', DiscordDto> + | ProviderExtension<'slack', SlackDto> + | ProviderExtension<'kick', KickDto> + | ProviderExtension<'twitch', TwitchDto> + | ProviderExtension<'x', XDto> + | ProviderExtension<'linkedin', LinkedinDto> + | ProviderExtension<'linkedin-page', LinkedinDto> + | ProviderExtension<'instagram', InstagramDto> + | ProviderExtension<'instagram-standalone', InstagramDto> + | ProviderExtension<'medium', MediumSettingsDto> + | ProviderExtension<'devto', DevToSettingsDto> + | ProviderExtension<'hashnode', HashnodeSettingsDto> + | ProviderExtension<'wordpress', WordpressDto> + | ProviderExtension<'listmonk', ListmonkDto> + | ProviderExtension<'gmb', GmbSettingsDto> + | ProviderExtension<'facebook', FacebookDto> + | ProviderExtension<'wrapcast', FarcasterDto> + | ProviderExtension<'threads', None> + | ProviderExtension<'mastodon', None> + | ProviderExtension<'bluesky', None> + | ProviderExtension<'telegram', None> + | ProviderExtension<'nostr', None> + | ProviderExtension<'moltbook', MoltbookDto> + | ProviderExtension<'vk', None> + | ProviderExtension<'skool', SkoolDto> + | ProviderExtension<'mewe', MeweDto> + | ProviderExtension<'tumblr', TumblrDto> + | ProviderExtension<'whop', WhopDto>; + +type None = NonNullable; + +export const allProviders = (setEmpty?: any) => { + return [ + { value: RedditSettingsDto, name: 'reddit' }, + { value: LemmySettingsDto, name: 'lemmy' }, + { value: YoutubeSettingsDto, name: 'youtube' }, + { value: PinterestSettingsDto, name: 'pinterest' }, + { value: DribbbleDto, name: 'dribbble' }, + { value: TikTokDto, name: 'tiktok' }, + { value: DiscordDto, name: 'discord' }, + { value: SlackDto, name: 'slack' }, + { value: KickDto, name: 'kick' }, + { value: TwitchDto, name: 'twitch' }, + { value: XDto, name: 'x' }, + { value: LinkedinDto, name: 'linkedin' }, + { value: LinkedinDto, name: 'linkedin-page' }, + { value: InstagramDto, name: 'instagram' }, + { value: InstagramDto, name: 'instagram-standalone' }, + { value: MediumSettingsDto, name: 'medium' }, + { value: DevToSettingsDto, name: 'devto' }, + { value: WordpressDto, name: 'wordpress' }, + { value: HashnodeSettingsDto, name: 'hashnode' }, + { value: ListmonkDto, name: 'listmonk' }, + { value: GmbSettingsDto, name: 'gmb' }, + { value: FarcasterDto, name: 'wrapcast' }, + { value: FacebookDto, name: 'facebook' }, + { value: setEmpty, name: 'threads' }, + { value: setEmpty, name: 'mastodon' }, + { value: setEmpty, name: 'bluesky' }, + { value: setEmpty, name: 'telegram' }, + { value: setEmpty, name: 'nostr' }, + { value: setEmpty, name: 'vk' }, + { value: MoltbookDto, name: 'moltbook' }, + { value: SkoolDto, name: 'skool' }, + { value: WhopDto, name: 'whop' }, + { value: MeweDto, name: 'mewe' }, + { value: TumblrDto, name: 'tumblr' }, + ].filter((f) => f.value); +}; + +export class EmptySettings { + @IsIn(allProviders(EmptySettings).map((p) => p.name), { + message: `"__type" must be ${allProviders(EmptySettings) + .map((p) => p.name) + .join(', ')}`, + }) + __type: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.settings.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1e5813b4ed54b22675ae50e029191268194eec6 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.settings.dto.ts @@ -0,0 +1,47 @@ +import { + ArrayMaxSize, + IsArray, + IsDefined, + IsOptional, + IsString, + Matches, + MinLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto'; +import { Type } from 'class-transformer'; +import { DevToTagsSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.tags.settings.dto'; + +export class DevToSettingsDto { + @IsString() + @MinLength(2) + @IsDefined() + title: string; + + @IsOptional() + @ValidateNested() + @Type(() => MediaDto) + main_image?: MediaDto; + + @IsOptional() + @IsString() + @ValidateIf((o) => o.canonical && o.canonical.indexOf('(post:') === -1) + @Matches( + /^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/, + { + message: 'Invalid URL', + } + ) + canonical?: string; + + @IsString() + @IsOptional() + organization?: string; + + @IsArray() + @ArrayMaxSize(4) + @Type(() => DevToTagsSettingsDto) + @ValidateNested({ each: true }) + tags: DevToTagsSettingsDto[] = []; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.tags.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.tags.settings.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..74481097ea878178c7788ce6de2673d8bbce9e68 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dev.to.tags.settings.dto.ts @@ -0,0 +1,9 @@ +import { IsNumber, IsString } from 'class-validator'; + +export class DevToTagsSettingsDto { + @IsNumber() + value: number; + + @IsString() + label: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/discord.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/discord.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..23c40e95e35a46bcc8d385fb37ab07e656a424f9 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/discord.dto.ts @@ -0,0 +1,12 @@ +import { IsDefined, IsString, MinLength } from 'class-validator'; +import { JSONSchema } from 'class-validator-jsonschema'; + +export class DiscordDto { + @MinLength(1) + @IsDefined() + @IsString() + @JSONSchema({ + description: 'Channel must be an id', + }) + channel: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dribbble.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dribbble.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..1cbd66b8d2e232b9839de68757e11caaae5308f7 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/dribbble.dto.ts @@ -0,0 +1,21 @@ +import { + IsDefined, + IsOptional, + IsString, + IsUrl, + MinLength, +} from 'class-validator'; + +export class DribbbleDto { + @IsString() + @IsDefined() + @MinLength(1, { + message: 'Title is required', + }) + title: string; + + @IsString() + @IsOptional() + @IsUrl() + team: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/facebook.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/facebook.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..f913a95b04024b128e2dd649f584e5bcf8f05c4f --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/facebook.dto.ts @@ -0,0 +1,111 @@ +import { IsIn, IsOptional, IsString, ValidateIf, IsUrl } from 'class-validator'; + +// Maximum characters Facebook allows on a background ("text format") post. +export const FACEBOOK_PRESET_MAX_CHARS = 130; + +export interface FacebookPreset { + id: string; + name: string; +} + +// Curated catalog of Facebook text-post background presets. +// +// Facebook exposes no documented Graph API edge to enumerate a page's +// `text_format_preset_id` options, so the list has to be hardcoded. These IDs +// appear to be global (same across pages). Source: Publer's documented +// Facebook background list. Background posts are text-only (no media), Pages +// only, and capped at ~130 characters. +export const FACEBOOK_PRESETS: FacebookPreset[] = [ + { id: '106018623298955', name: 'Solid purple' }, + { id: '365653833956649', name: 'Pink tropical plants' }, + { id: '618093735238824', name: 'Brown illustration' }, + { id: '191761991491375', name: '3D hearts' }, + { id: '2193627793985415', name: '3D heart-eyes emojis' }, + { id: '200521337465306', name: '3D flame emojis' }, + { id: '1821844087883360', name: 'Walking yellow illustration' }, + { id: '177465482945164', name: 'Light purple 3D cube pattern' }, + { id: '160419724814650', name: 'Orange with pink illustration' }, + { id: '248623902401250', name: '3D smiling emoji' }, + { id: '240401816771706', name: '3D rose emojis' }, + { id: '1868855943417360', name: '3D crying-laughter emoji' }, + { id: '255989551804163', name: 'Eye pink illustration' }, + { id: '1792915444087912', name: 'Illustration' }, + { id: '1654916007940525', name: 'Light grey illustration' }, + { id: '1679248482160767', name: 'Light blue illustration' }, + { id: '518948401838663', name: 'Pink heart pattern on pink' }, + { id: '423339708139719', name: 'Illustration' }, + { id: '204187940028597', name: 'Solid red' }, + { id: '621731364695726', name: 'Solid red' }, + { id: '518596398537417', name: 'Red illustration' }, + { id: '134273813910336', name: 'Tree red illustration' }, + { id: '217321755510854', name: 'Pink and purple hearts on pink' }, + { id: '323371698179784', name: 'Sunset red illustration' }, + { id: '901751159967576', name: 'Gradient, dark orange-red' }, + { id: '552118025129095', name: 'Brown illustration' }, + { id: '263789377694911', name: 'Apple red illustration' }, + { id: '606643333067842', name: 'Tulip light-orange illustration' }, + { id: '458988134561491', name: 'Cat dark-orange illustration' }, + { id: '548109108916650', name: 'Unicorn red illustration' }, + { id: '175493843120364', name: 'Pink and yellow gradient' }, + { id: '338976169966519', name: 'Stairs beige illustration' }, + { id: '206513879997925', name: 'Spiral beige illustration' }, + { id: '168373304017982', name: 'Cube beige illustration' }, + { id: '1271157196337260', name: 'Solid red' }, + { id: '174496469882866', name: 'Lemon yellow illustration' }, + { id: '862667370603267', name: 'Egg light-yellow illustration' }, + { id: '127541261450947', name: 'Ball green illustration' }, + { id: '218067308976029', name: 'Light grey illustration' }, + { id: '688479024672716', name: 'Gradient, teal light-green' }, + { id: '238863426886624', name: 'Cat light-blue illustration' }, + { id: '301029513638534', name: 'Solid teal' }, + { id: '154977255088164', name: 'Solid teal' }, + { id: '1941912679424590', name: 'Gradient, grey dark-grey' }, + { id: '396343990807392', name: 'Flower teal illustration' }, + { id: '143093446467972', name: 'Blue clouds on dark blue' }, + { id: '161409924510923', name: 'Rocket ship makes heart in sky' }, + { id: '145893972683590', name: 'Solid dark purple' }, + { id: '217761075370932', name: 'Solid blue' }, + { id: '931584293685988', name: 'Wave blue illustration' }, + { id: '148862695775447', name: 'Pink and purple hearts on purple' }, + { id: '100114277230063', name: 'Deep sea blue illustration' }, + { id: '558836317844129', name: 'Spiral purple illustration' }, + { id: '172497526576609', name: 'Watermelon light-purple illustration' }, + { id: '433967226963128', name: 'Solid purple' }, + { id: '197865920864520', name: 'Donut light-purple illustration' }, + { id: '643122496026756', name: 'Pink illustration' }, + { id: '762009070855346', name: 'Balloon light-grey illustration' }, + { id: '228164237768720', name: 'Grey heart pattern on black' }, + { id: '146487026137131', name: 'Rain black illustration' }, + { id: '221828835275596', name: 'Glasses light-grey illustration' }, + { id: '1903718606535395', name: 'Solid red' }, + { id: '1881421442117417', name: 'Solid black' }, + { id: '249307305544279', name: 'Gradient, red-blue' }, + { id: '1777259169190672', name: 'Gradient, purple-magenta' }, + { id: '303063890126415', name: 'Yellow/orange/pink gradient' }, + { id: '122708641613922', name: 'Gradient, dark-grey-black' }, + { id: '319468561816672', name: 'Dark blue illustration' }, + { id: '121945541697934', name: 'Pink illustration' }, + { id: '288211338285858', name: 'Blue illustration' }, + { id: '446330032368780', name: 'Gradient, red' }, + { id: '219266485227663', name: 'Solid magenta' }, + { id: '1289741387813798', name: 'Solid dark red' }, + { id: '1365883126823705', name: 'Solid blue' }, +]; + +export class FacebookDto { + @IsOptional() + @ValidateIf(p => p.url) + @IsUrl() + url?: string; + + @IsIn(['post', 'story']) + @IsOptional() + post_type?: 'post' | 'story'; + + // Optional Facebook background preset for text-only posts. Kept permissive + // (@IsString rather than @IsIn) so existing posts without the field still + // validate and future preset drift on Facebook's side doesn't hard-fail. + @IsOptional() + @IsString() + text_format_preset_id?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/farcaster.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/farcaster.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..eaa714cb1428307953cc686be8d7f086cf95107f --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/farcaster.dto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsString, ValidateNested } from 'class-validator'; + +export class FarcasterId { + @IsString() + id: string; +} +export class FarcasterValue { + @ValidateNested() + @Type(() => FarcasterId) + value: FarcasterId; +} +export class FarcasterDto { + @ValidateNested({ each: true }) + @Type(() => FarcasterValue) + subreddit: FarcasterValue[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/gmb.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/gmb.settings.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4fb5e9dc37595cf5f8f756f7f005e4a8227e50e --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/gmb.settings.dto.ts @@ -0,0 +1,69 @@ +import { IsOptional, IsString, IsIn, IsUrl, ValidateIf } from 'class-validator'; + +export class GmbSettingsDto { + @IsOptional() + @IsIn(['STANDARD', 'EVENT', 'OFFER']) + topicType?: 'STANDARD' | 'EVENT' | 'OFFER'; + + @IsOptional() + @IsIn([ + 'NONE', + 'BOOK', + 'ORDER', + 'SHOP', + 'LEARN_MORE', + 'SIGN_UP', + 'GET_OFFER', + 'CALL', + ]) + callToActionType?: + | 'NONE' + | 'BOOK' + | 'ORDER' + | 'SHOP' + | 'LEARN_MORE' + | 'SIGN_UP' + | 'GET_OFFER' + | 'CALL'; + + @IsOptional() + @ValidateIf((o) => o.callToActionType) + @IsUrl() + callToActionUrl?: string; + + // Event-specific fields + @IsOptional() + @ValidateIf((o) => o.topicType === 'EVENT') + @IsString() + eventTitle?: string; + + @IsOptional() + @IsString() + eventStartDate?: string; + + @IsOptional() + @IsString() + eventEndDate?: string; + + @IsOptional() + @IsString() + eventStartTime?: string; + + @IsOptional() + @IsString() + eventEndTime?: string; + + // Offer-specific fields + @IsOptional() + @IsString() + offerCouponCode?: string; + + @IsOptional() + @ValidateIf((o) => o.offerRedeemUrl) + @IsUrl() + offerRedeemUrl?: string; + + @IsOptional() + @IsString() + offerTerms?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/hashnode.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/hashnode.settings.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d772401a8f471f42248ec313c446803a5903c9a --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/hashnode.settings.dto.ts @@ -0,0 +1,59 @@ +import { + ArrayMinSize, + IsArray, + IsDefined, + IsOptional, + IsString, + Matches, + MinLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto'; + +export class HashnodeTagsSettings { + @IsString() + value: string; + + @IsString() + label: string; +} + +export class HashnodeSettingsDto { + @IsString() + @MinLength(6) + @IsDefined() + title: string; + + @IsString() + @MinLength(2) + @IsOptional() + subtitle: string; + + @IsOptional() + @ValidateNested() + @Type(() => MediaDto) + main_image?: MediaDto; + + @IsOptional() + @IsString() + @ValidateIf((o) => o.canonical && o.canonical.indexOf('(post:') === -1) + @Matches( + /^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/, + { + message: 'Invalid URL', + } + ) + canonical?: string; + + @IsString() + @IsDefined() + publication?: string; + + @IsArray() + @ArrayMinSize(1) + @Type(() => HashnodeTagsSettings) + @ValidateNested({ each: true }) + tags: HashnodeTagsSettings[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/instagram.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/instagram.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..38e2500d3810a8180649407fe45cbb6151186128 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/instagram.dto.ts @@ -0,0 +1,73 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsDefined, + IsIn, + IsNumber, + IsString, + Max, + Min, + ValidateNested, + IsOptional, +} from 'class-validator'; + +export class Collaborators { + @IsDefined() + @IsString() + label: string; +} + +export class InstagramAudio { + @IsDefined() + @IsString() + id: string; + + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + artist?: string; + + @IsOptional() + @IsString() + image?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + audio_volume?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + video_volume?: number; +} +export class InstagramDto { + @IsIn(['post', 'story']) + @IsDefined() + post_type: 'post' | 'story'; + + @IsOptional() + is_trial_reel?: boolean; + + @IsIn(['MANUAL', 'SS_PERFORMANCE']) + @IsOptional() + graduation_strategy?: 'MANUAL' | 'SS_PERFORMANCE'; + + @Type(() => Collaborators) + @ValidateNested({ each: true }) + @IsArray() + @IsOptional() + collaborators: Collaborators[]; + + @Type(() => InstagramAudio) + @ValidateNested() + @IsOptional() + audio?: InstagramAudio; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/kick.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/kick.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..caf6959f0cfe5a017ba0c4cb7f6b2c8b285ef648 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/kick.dto.ts @@ -0,0 +1 @@ +export class KickDto {} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/lemmy.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/lemmy.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..7fb064cfce024f1e5e8efedd72432ebdb15dd3a8 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/lemmy.dto.ts @@ -0,0 +1,46 @@ +import { + ArrayMinSize, + IsDefined, + IsOptional, + IsString, + IsUrl, + MinLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class LemmySettingsDtoInner { + @IsString() + @MinLength(2) + @IsDefined() + subreddit: string; + + @IsString() + @IsDefined() + id: string; + + @IsString() + @MinLength(2) + @IsDefined() + title: string; + + @ValidateIf((o) => o.url) + @IsOptional() + @IsUrl() + url: string; +} + +export class LemmySettingsValueDto { + @Type(() => LemmySettingsDtoInner) + @IsDefined() + @ValidateNested() + value: LemmySettingsDtoInner; +} + +export class LemmySettingsDto { + @Type(() => LemmySettingsValueDto) + @ValidateNested({ each: true }) + @ArrayMinSize(1) + subreddit: LemmySettingsValueDto[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/linkedin.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/linkedin.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5e150e6f68892fd80a16f7edeb0a2f0680bbbff --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/linkedin.dto.ts @@ -0,0 +1,11 @@ +import { IsBoolean, IsOptional, IsString } from 'class-validator'; + +export class LinkedinDto { + @IsBoolean() + @IsOptional() + post_as_images_carousel: boolean; + + @IsString() + @IsOptional() + carousel_name?: string; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/listmonk.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/listmonk.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..978bf6e5ae05420b5b8e426f0b9836b902f9fa84 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/listmonk.dto.ts @@ -0,0 +1,24 @@ +import { IsOptional, IsString, MinLength } from 'class-validator'; +import { JSONSchema } from 'class-validator-jsonschema'; + +export class ListmonkDto { + @IsString() + @MinLength(1) + subject: string; + + @IsString() + preview: string; + + @IsString() + @JSONSchema({ + description: 'List must be an id', + }) + list: string; + + @IsString() + @IsOptional() + @JSONSchema({ + description: 'Template must be an id', + }) + template: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/medium.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/medium.settings.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdecf77ea39260d63258fad3447150410e3cb600 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/medium.settings.dto.ts @@ -0,0 +1,54 @@ +import { + ArrayMaxSize, + IsArray, + IsDefined, + IsOptional, + IsString, + Matches, + MinLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class MediumTagsSettings { + @IsString() + value: string; + + @IsString() + label: string; +} + +export class MediumSettingsDto { + @IsString() + @MinLength(2) + @IsDefined() + title: string; + + @IsString() + @MinLength(2) + @IsDefined() + subtitle: string; + + @IsOptional() + @IsString() + @ValidateIf((o) => o.canonical && o.canonical.indexOf('(post:') === -1) + @Matches( + /^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/, + { + message: 'Invalid URL', + } + ) + canonical?: string; + + @IsString() + @IsOptional() + publication?: string; + + @IsArray() + @ArrayMaxSize(4) + @IsOptional() + @ValidateNested({ each: true }) + @Type(p => MediumTagsSettings) + tags: MediumTagsSettings[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/mewe.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/mewe.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca4d964702ca9106c3f9dbb27d42f1b50740de41 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/mewe.dto.ts @@ -0,0 +1,19 @@ +import { IsIn, IsOptional, IsString, MinLength, ValidateIf } from 'class-validator'; +import { JSONSchema } from 'class-validator-jsonschema'; + +export class MeweDto { + @IsIn(['timeline', 'group']) + @JSONSchema({ + description: 'Where to post: timeline or group', + }) + postType: 'timeline' | 'group'; + + @ValidateIf((o) => o.postType === 'group') + @MinLength(1) + @IsString() + @JSONSchema({ + description: 'Group must be an id', + }) + @IsOptional() + group?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/moltbook.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/moltbook.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..2d697797154abe0ea27c32a5cd21910d204be96e --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/moltbook.dto.ts @@ -0,0 +1,8 @@ +import { IsDefined, IsString, MinLength } from 'class-validator'; + +export class MoltbookDto { + @MinLength(1) + @IsDefined() + @IsString() + submolt: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..aed79c805513a79ff9d5ffe75b79df13559813f9 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/pinterest.dto.ts @@ -0,0 +1,34 @@ +import { + IsDefined, IsOptional, IsString, IsUrl, MaxLength, MinLength, ValidateIf +} from 'class-validator'; +import { JSONSchema } from 'class-validator-jsonschema'; + +export class PinterestSettingsDto { + @IsString() + @ValidateIf((o) => !!o.title) + @MaxLength(100) + title: string; + + @IsString() + @ValidateIf((o) => !!o.link) + @IsUrl() + link: string; + + @IsString() + @ValidateIf((o) => !!o.dominant_color) + dominant_color: string; + + @IsDefined({ + message: 'Board is required', + }) + @IsString({ + message: 'Board is required', + }) + @MinLength(1, { + message: 'Board is required', + }) + @JSONSchema({ + description: 'board must be an id', + }) + board: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a8f2a13feda4c777694490a4a58b0f08e6197ef --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/reddit.dto.ts @@ -0,0 +1,81 @@ +import { + ArrayMinSize, + IsBoolean, + IsDefined, + IsString, + IsUrl, + Matches, + MinLength, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { JSONSchema } from 'class-validator-jsonschema'; + +export class RedditFlairDto { + @IsString() + @IsDefined() + id: string; + + @IsString() + @IsDefined() + name: string; +} + +export class RedditSettingsDtoInner { + @IsString() + @MinLength(2) + @IsDefined() + @JSONSchema({ + description: 'Subreddit must start with /r', + }) + subreddit: string; + + @IsString() + @MinLength(2) + @IsDefined() + title: string; + + @IsString() + @MinLength(2) + @IsDefined() + @JSONSchema({ + description: 'Must be any of link, self (normal post), image, video, videogif', + }) + type: string; + + @IsUrl() + @IsDefined() + @ValidateIf((o) => o.type === 'link' && o?.url?.indexOf('(post:') === -1) + @Matches( + /^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/, + { + message: 'Invalid URL', + } + ) + url: string; + + @IsBoolean() + @IsDefined() + is_flair_required: boolean; + + @ValidateIf((e) => e.is_flair_required) + @IsDefined() + @ValidateNested() + @Type(() => RedditFlairDto) + flair: RedditFlairDto; +} + +export class RedditSettingsValueDto { + @Type(() => RedditSettingsDtoInner) + @IsDefined() + @ValidateNested() + value: RedditSettingsDtoInner; +} + +export class RedditSettingsDto { + @Type(() => RedditSettingsValueDto) + @ValidateNested({ each: true }) + @ArrayMinSize(1) + subreddit: RedditSettingsValueDto[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/skool.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/skool.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..84eac00573858cab1f52b26d3b1290588265a5b0 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/skool.dto.ts @@ -0,0 +1,28 @@ +import { IsDefined, IsString, MinLength } from 'class-validator'; +import { JSONSchema } from 'class-validator-jsonschema'; + +export class SkoolDto { + @MinLength(1) + @IsDefined() + @IsString() + @JSONSchema({ + description: 'Group must be an id', + }) + group: string; + + @MinLength(1) + @IsDefined() + @IsString() + @JSONSchema({ + description: 'Label must be an id', + }) + label: string; + + @MinLength(1) + @IsDefined() + @IsString() + @JSONSchema({ + description: 'Title of the post', + }) + title: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/slack.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/slack.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..355718c8bf3789ae5ee8cfd9bf55d18be4a4e3ba --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/slack.dto.ts @@ -0,0 +1,12 @@ +import { IsDefined, IsString, MinLength } from 'class-validator'; +import { JSONSchema } from 'class-validator-jsonschema'; + +export class SlackDto { + @MinLength(1) + @IsDefined() + @IsString() + @JSONSchema({ + description: 'Channel must be an id', + }) + channel: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..c5fc64704561e2d35e8ef0b0c7d826c9d4a59da4 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tiktok.dto.ts @@ -0,0 +1,101 @@ +import { + IsBoolean, ValidateIf, IsIn, IsString, MaxLength, IsOptional +} from 'class-validator'; +import { JSONSchema } from 'class-validator-jsonschema'; + +// TikTok only honors most of these settings on a DIRECT_POST. With +// content_posting_method=UPLOAD the media lands in the user's TikTok inbox as a +// draft, and TikTok's inbox/upload endpoints accept nothing but the title / +// description - every other field below is silently discarded. +// video_made_with_ai / duet / stitch are additionally video-only: TikTok's photo +// post_info has no is_aigc, disable_duet or disable_stitch field. +// Fields stay required here (existing clients depend on it); the constraints are +// documented, not enforced. +export class TikTokDto { + @ValidateIf((p) => p.title) + @MaxLength(90) + @JSONSchema({ + description: + 'Used as the title of the post. The only setting TikTok keeps when content_posting_method=UPLOAD.', + }) + title: string; + + @IsIn([ + 'PUBLIC_TO_EVERYONE', + 'MUTUAL_FOLLOW_FRIENDS', + 'FOLLOWER_OF_CREATOR', + 'SELF_ONLY', + ]) + @IsString() + @JSONSchema({ + description: + 'Applied only when content_posting_method=DIRECT_POST. Ignored by TikTok on UPLOAD.', + }) + privacy_level: + | 'PUBLIC_TO_EVERYONE' + | 'MUTUAL_FOLLOW_FRIENDS' + | 'FOLLOWER_OF_CREATOR' + | 'SELF_ONLY'; + + @IsBoolean() + @JSONSchema({ + description: + 'Video posts only, and only when content_posting_method=DIRECT_POST. TikTok has no duet setting for photo posts.', + }) + duet: boolean; + + @IsBoolean() + @JSONSchema({ + description: + 'Video posts only, and only when content_posting_method=DIRECT_POST. TikTok has no stitch setting for photo posts.', + }) + stitch: boolean; + + @IsBoolean() + @JSONSchema({ + description: + 'Applied only when content_posting_method=DIRECT_POST. Ignored by TikTok on UPLOAD.', + }) + comment: boolean; + + @IsIn(['yes', 'no']) + @JSONSchema({ + description: + 'Photo posts only, and only when content_posting_method=DIRECT_POST. Ignored by TikTok on UPLOAD.', + }) + autoAddMusic: 'yes' | 'no'; + + @IsBoolean() + @JSONSchema({ + description: + 'Applied only when content_posting_method=DIRECT_POST. Ignored by TikTok on UPLOAD.', + }) + brand_content_toggle: boolean; + + @IsBoolean() + @IsOptional() + @JSONSchema({ + description: + 'Labels the post as AI generated. Video posts only, and only when content_posting_method=DIRECT_POST. TikTok has no AI-generated label for photo posts, and discards it on UPLOAD.', + }) + video_made_with_ai: boolean; + + @IsBoolean() + @JSONSchema({ + description: + 'Applied only when content_posting_method=DIRECT_POST. Ignored by TikTok on UPLOAD.', + }) + brand_organic_toggle: boolean; + + @IsIn(['DIRECT_POST', 'UPLOAD']) + @IsString() + @JSONSchema({ + description: + 'Required. Use "DIRECT_POST" to actually publish the post to TikTok. ' + + '"UPLOAD" does NOT publish: it only sends the media to the user\'s TikTok app inbox, ' + + 'where they must manually finish and publish it within 24 hours or it is discarded, ' + + 'and it makes TikTok ignore every other setting here. ' + + 'Only use "UPLOAD" when the user explicitly asks to review or edit the post inside the TikTok app before publishing.', + }) + content_posting_method: 'DIRECT_POST' | 'UPLOAD'; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tumblr.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tumblr.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..2abf2811adae572c2aa601d2b4e0110ba6bc206d --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/tumblr.dto.ts @@ -0,0 +1,40 @@ +import { + IsOptional, + IsString, + Matches, + MaxLength, + MinLength, + ValidateIf, +} from 'class-validator'; + +const optionalUrlRegex = + /^(|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})$/; + +export class TumblrDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(4096) + title?: string; + + @IsOptional() + @IsString() + @ValidateIf((o) => o.link) + @Matches(optionalUrlRegex, { + message: 'Invalid URL', + }) + link?: string; + + @IsOptional() + @IsString() + @ValidateIf((o) => o.sourceUrl) + @Matches(optionalUrlRegex, { + message: 'Invalid URL', + }) + sourceUrl?: string; + + @IsOptional() + @IsString() + @MaxLength(4096) + tags?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/twitch.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/twitch.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..23dfea12fac7b5c8668804c138e4a654db1b4b84 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/twitch.dto.ts @@ -0,0 +1,11 @@ +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export class TwitchDto { + @IsIn(['message', 'announcement']) + @IsOptional() + messageType?: 'message' | 'announcement'; + + @IsIn(['primary', 'blue', 'green', 'orange', 'purple']) + @IsOptional() + announcementColor?: 'primary' | 'blue' | 'green' | 'orange' | 'purple'; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/whop.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/whop.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7d5ae5e490868916060be8073a936528defafcf --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/whop.dto.ts @@ -0,0 +1,24 @@ +import { IsDefined, IsOptional, IsString, MinLength } from 'class-validator'; +import { JSONSchema } from 'class-validator-jsonschema'; + +export class WhopDto { + @MinLength(1) + @IsDefined() + @IsString() + @JSONSchema({ + description: 'Company ID', + }) + company: string; + + @MinLength(1) + @IsDefined() + @IsString() + @JSONSchema({ + description: 'Experience ID for the Whop forum', + }) + experience: string; + + @IsOptional() + @IsString() + title?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/wordpress.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/wordpress.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..e159be48e3b40aa2b6e28ddda1a99c2ab02c04c3 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/wordpress.dto.ts @@ -0,0 +1,43 @@ +import { + IsArray, + IsDefined, + IsIn, + IsNumber, + IsOptional, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; +import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto'; +import { Type } from 'class-transformer'; + +export class WordpressDto { + @IsString() + @MinLength(2) + @IsDefined() + title: string; + + @IsOptional() + @ValidateNested() + @Type(() => MediaDto) + main_image?: MediaDto; + + @IsString() + @IsDefined() + type: string; + + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + categories?: number[]; + + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + tags?: number[]; + + @IsOptional() + @IsString() + @IsIn(['publish', 'draft', 'pending', 'private']) + status?: string; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/x.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/x.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a2214c8eea56280d113c5e108ff49d72f14bb14 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/x.dto.ts @@ -0,0 +1,26 @@ +import { IsBoolean, IsIn, IsOptional, Matches } from 'class-validator'; + +export class XDto { + @IsOptional() + @Matches(/^(https:\/\/x\.com\/i\/communities\/\d+)?$/, { + message: + 'Invalid X community URL. It should be in the format: https://x.com/i/communities/1493446837214187523', + }) + community?: string; + + @IsIn(['everyone', 'following', 'mentionedUsers', 'subscribers', 'verified']) + who_can_reply_post: + | 'everyone' + | 'following' + | 'mentionedUsers' + | 'subscribers' + | 'verified'; + + @IsOptional() + @IsBoolean() + made_with_ai?: boolean; + + @IsOptional() + @IsBoolean() + paid_partnership?: boolean; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/providers-settings/youtube.settings.dto.ts b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/youtube.settings.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..60f1bfa140b3f08c42d3703f120c5421e40aaad3 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/providers-settings/youtube.settings.dto.ts @@ -0,0 +1,92 @@ +import { + IsArray, + IsDefined, + IsIn, + IsOptional, + IsString, + MaxLength, + MinLength, + registerDecorator, + ValidateNested, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto'; +import { Type } from 'class-transformer'; + +// YouTube caps the combined length of all tags at 500 characters. +// Tags containing whitespace are wrapped in quotes by YouTube, which adds +// two extra characters per tag toward that limit. +export const YOUTUBE_TAGS_MAX_LENGTH = 500; + +export function getYoutubeTagsLength(tags: YoutubeTagsSettings[]): number { + return (tags ?? []).reduce((total, tag) => { + const label = tag?.label ?? ''; + return total + label.length + (/\s/.test(label) ? 2 : 0); + }, 0); +} + +@ValidatorConstraint({ name: 'IsYoutubeTagsLength', async: false }) +export class IsYoutubeTagsLengthConstraint + implements ValidatorConstraintInterface +{ + validate(value: unknown, _args: ValidationArguments): boolean { + if (!Array.isArray(value)) { + return true; + } + return getYoutubeTagsLength(value) <= YOUTUBE_TAGS_MAX_LENGTH; + } + + defaultMessage(_args: ValidationArguments): string { + return `The maximum allowed is ${YOUTUBE_TAGS_MAX_LENGTH} characters in total for all tags.`; + } +} + +export function IsYoutubeTagsLength(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + validator: IsYoutubeTagsLengthConstraint, + }); + }; +} + +export class YoutubeTagsSettings { + @IsString() + value: string; + + @IsString() + label: string; +} + +export class YoutubeSettingsDto { + @IsString() + @MinLength(2) + @MaxLength(100) + @IsDefined() + title: string; + + @IsIn(['public', 'private', 'unlisted']) + @IsDefined() + type: string; + + @IsIn(['yes', 'no']) + @IsOptional() + selfDeclaredMadeForKids: 'no' | 'yes'; + + @IsOptional() + @ValidateNested() + @Type(() => MediaDto) + thumbnail?: MediaDto; + + @IsArray() + @IsOptional() + @ValidateNested({ each: true }) + @IsYoutubeTagsLength() + @Type(() => YoutubeTagsSettings) + tags: YoutubeTagsSettings[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/posts/transformers/integration.settings.transformer.ts b/libraries/nestjs-libraries/src/dtos/posts/transformers/integration.settings.transformer.ts new file mode 100644 index 0000000000000000000000000000000000000000..194a6a68d3029ba04ad8b52dee49687ab9be493e --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/posts/transformers/integration.settings.transformer.ts @@ -0,0 +1,41 @@ +import { Transform, Type } from 'class-transformer'; +import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class IntegrationSettingsTransformer { + constructor(private integrationService: IntegrationService) {} + + async transformPost(post: any, orgId: string) { + if (!post.integration?.id || !post.settings) { + return post; + } + + try { + // Get the integration from the database + const integration = await this.integrationService.getIntegrationById( + orgId, + post.integration.id + ); + + if (integration?.providerIdentifier) { + // Set the __type field based on the provider identifier + post.settings.__type = integration.providerIdentifier; + } + } catch (error) { + // If there's an error fetching the integration, we'll let validation handle it + console.error('Error fetching integration for settings transform:', error); + } + + return post; + } +} + +// Custom property transformer for individual Post objects +export const TransformIntegrationSettings = (orgId: string) => { + return Transform(({ value, obj }) => { + // This will be handled by the service layer instead of transformer + // since we need async database access + return value; + }); +}; \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/dtos/sets/sets.dto.ts b/libraries/nestjs-libraries/src/dtos/sets/sets.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..71a19042d66284ba0a310ff17263001c3741a953 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/sets/sets.dto.ts @@ -0,0 +1,29 @@ +import { IsDefined, IsOptional, IsString } from 'class-validator'; + +export class SetsDto { + @IsOptional() + @IsString() + id?: string; + + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsDefined() + content: string; +} + +export class UpdateSetsDto { + @IsString() + @IsDefined() + id: string; + + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsDefined() + content: string; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/dtos/settings/add.team.member.dto.ts b/libraries/nestjs-libraries/src/dtos/settings/add.team.member.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b5b4cbac2b9a98e45099c1a1819cfd9915224ba --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/settings/add.team.member.dto.ts @@ -0,0 +1,23 @@ +import { + IsBoolean, + IsDefined, + IsEmail, + IsIn, + IsString, + ValidateIf, +} from 'class-validator'; + +export class AddTeamMemberDto { + @IsDefined() + @IsEmail() + @ValidateIf((o) => o.sendEmail) + email: string; + + @IsString() + @IsIn(['USER', 'ADMIN']) + role: string; + + @IsDefined() + @IsBoolean() + sendEmail: boolean; +} diff --git a/libraries/nestjs-libraries/src/dtos/settings/shortlink-preference.dto.ts b/libraries/nestjs-libraries/src/dtos/settings/shortlink-preference.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..0cc17941d804e478a6b73a6c56e8eb2c3b6e6373 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/settings/shortlink-preference.dto.ts @@ -0,0 +1,8 @@ +import { IsEnum } from 'class-validator'; +import { ShortLinkPreference } from '@prisma/client'; + +export class ShortlinkPreferenceDto { + @IsEnum(ShortLinkPreference) + shortlink: ShortLinkPreference; +} + diff --git a/libraries/nestjs-libraries/src/dtos/signature/signature.dto.ts b/libraries/nestjs-libraries/src/dtos/signature/signature.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b69cf379a911edacb6bf9d9c124ed6845ce6bd2 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/signature/signature.dto.ts @@ -0,0 +1,11 @@ +import { IsBoolean, IsDefined, IsString } from 'class-validator'; + +export class SignatureDto { + @IsString() + @IsDefined() + content: string; + + @IsBoolean() + @IsDefined() + autoAdd: boolean; +} diff --git a/libraries/nestjs-libraries/src/dtos/third-party/import-media.dto.ts b/libraries/nestjs-libraries/src/dtos/third-party/import-media.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..501f696ffeef991fdcc745e2b6adadadafdbb5a5 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/third-party/import-media.dto.ts @@ -0,0 +1,30 @@ +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsDefined, + IsString, + ValidateNested, +} from 'class-validator'; +import { IsSafeWebhookUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; + +export class ImportMediaItemDto { + @IsString() + @IsDefined() + @IsSafeWebhookUrl({ + message: + 'URL must be a public HTTPS URL and cannot point to internal network addresses', + }) + url: string; + + @IsString() + @IsDefined() + name: string; +} + +export class ImportMediaDto { + @ValidateNested({ each: true }) + @Type(() => ImportMediaItemDto) + @ArrayMinSize(1) + @IsDefined() + items: ImportMediaItemDto[]; +} diff --git a/libraries/nestjs-libraries/src/dtos/users/email-notifications.dto.ts b/libraries/nestjs-libraries/src/dtos/users/email-notifications.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..e596fc7b90d87b318f9d4762e56937437651279f --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/users/email-notifications.dto.ts @@ -0,0 +1,13 @@ +import { IsBoolean } from 'class-validator'; + +export class EmailNotificationsDto { + @IsBoolean() + sendSuccessEmails: boolean; + + @IsBoolean() + sendFailureEmails: boolean; + + @IsBoolean() + sendStreakEmails: boolean; +} + diff --git a/libraries/nestjs-libraries/src/dtos/users/user.details.dto.ts b/libraries/nestjs-libraries/src/dtos/users/user.details.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..4feec47509f224130ee67e5ade7afb7313a74fcf --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/users/user.details.dto.ts @@ -0,0 +1,21 @@ +import { MediaDto } from '@gitroom/nestjs-libraries/dtos/media/media.dto'; +import { + IsOptional, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class UserDetailDto { + @IsString() + @MinLength(3) + fullname: string; + + @IsString() + @IsOptional() + bio: string; + + @IsOptional() + @ValidateNested() + picture: MediaDto; +} diff --git a/libraries/nestjs-libraries/src/dtos/videos/video.dto.ts b/libraries/nestjs-libraries/src/dtos/videos/video.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..712fbc4b12ec35bd0776cb8ec96417b42431a8b6 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/videos/video.dto.ts @@ -0,0 +1,34 @@ +import { + IsIn, Validate, ValidationArguments, ValidatorConstraint, ValidatorConstraintInterface +} from 'class-validator'; +import { VideoAbstract } from '@gitroom/nestjs-libraries/videos/video.interface'; + +@ValidatorConstraint({ name: 'checkInRuntime', async: false }) +export class ValidIn implements ValidatorConstraintInterface { + private _load() { + return (Reflect.getMetadata('video', VideoAbstract) || []) + .filter((f: any) => f.available) + .map((p: any) => p.identifier); + } + + validate(text: string, args: ValidationArguments) { + // Check if the text is in the list of valid video types + const validTypes = this._load(); + return validTypes.includes(text); + } + + defaultMessage(args: ValidationArguments) { + // here you can provide default error message if validation failed + return 'type must be any of: ' + this._load().join(', '); + } +} + +export class VideoDto { + @Validate(ValidIn) + type: string; + + @IsIn(['vertical', 'horizontal']) + output: 'vertical' | 'horizontal'; + + customParams: any; +} diff --git a/libraries/nestjs-libraries/src/dtos/videos/video.function.dto.ts b/libraries/nestjs-libraries/src/dtos/videos/video.function.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..103d51b90568e3ac6a0d5eca2ecadb2f0e36f476 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/videos/video.function.dto.ts @@ -0,0 +1,11 @@ +import { IsString } from 'class-validator'; + +export class VideoFunctionDto { + @IsString() + identifier: string; + + @IsString() + functionName: string; + + params: any; +} diff --git a/libraries/nestjs-libraries/src/dtos/webhooks/ssrf.safe.dispatcher.ts b/libraries/nestjs-libraries/src/dtos/webhooks/ssrf.safe.dispatcher.ts new file mode 100644 index 0000000000000000000000000000000000000000..dfd69abee8bc4c2a33b94ee509aa3e4cdf505726 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/webhooks/ssrf.safe.dispatcher.ts @@ -0,0 +1,49 @@ +import { Agent } from 'undici'; +import dns from 'node:dns'; +import net from 'node:net'; +import { isBlockedIp } from './webhook.url.validator'; + +// Pins DNS resolution: every resolved IP is checked with `isBlockedIp` and +// the caller (undici) connects to that same set. Closes the TOCTOU window +// `isSafePublicHttpsUrl` alone leaves open (see GHSA-f7jj-p389-4w45). +export const ssrfSafeDispatcher = new Agent({ + connect: { + lookup(hostname, options, callback) { + if (net.isIP(hostname)) { + const family = net.isIP(hostname); + if (isBlockedIp(hostname)) { + return callback(new Error('Blocked IP'), '', 0); + } + return options && (options as any).all + ? callback(null, [{ address: hostname, family }] as any, family) + : callback(null, hostname, family); + } + + dns.lookup(hostname, options, (err, address: any, family: any) => { + if (err) return callback(err, '', 0); + if (Array.isArray(address)) { + for (const entry of address) { + if (isBlockedIp(entry.address)) { + return callback(new Error('Blocked IP'), '', 0); + } + } + return callback(null, address as any, 0); + } + if (isBlockedIp(address)) { + return callback(new Error('Blocked IP'), '', 0); + } + callback(null, address, family); + }); + }, + }, +}); + +// Self-hosters legitimately connect Postiz to WordPress/Mastodon/Lemmy/Listmonk +// instances that live on a private network (e.g. the same Docker network or VPC). +// Setting DISABLE_SSRF_PROTECTION=true opts those deployments out of the IP +// guard. It stays ON by default so the hosted product is protected. +export function getSsrfSafeDispatcher(): Agent | undefined { + return process.env.DISABLE_SSRF_PROTECTION === 'true' + ? undefined + : ssrfSafeDispatcher; +} diff --git a/libraries/nestjs-libraries/src/dtos/webhooks/webhook.url.validator.ts b/libraries/nestjs-libraries/src/dtos/webhooks/webhook.url.validator.ts new file mode 100644 index 0000000000000000000000000000000000000000..d79be0e6b8a0e074c20929af746c28c99382e456 --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/webhooks/webhook.url.validator.ts @@ -0,0 +1,130 @@ +import { + registerDecorator, + ValidationArguments, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { URL } from 'node:url'; +import dns from 'node:dns/promises'; +import net from 'node:net'; + +export function isBlockedIPv4(ip: string): boolean { + const [a, b] = ip.split('.').map(Number); + + if ([a, b].some((n) => Number.isNaN(n))) return true; + + return ( + a === 0 || // 0.0.0.0/8 + a === 10 || // 10.0.0.0/8 + a === 127 || // 127.0.0.0/8 + (a === 169 && b === 254) || // 169.254.0.0/16 + (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 + (a === 192 && b === 168) || // 192.168.0.0/16 + (a === 100 && b >= 64 && b <= 127) || // 100.64.0.0/10 + (a === 198 && (b === 18 || b === 19)) || // 198.18.0.0/15 + a >= 224 // multicast/reserved + ); +} + +export function isBlockedIPv6(ip: string): boolean { + const normalized = ip.toLowerCase(); + + return ( + normalized === '::1' || // loopback + normalized === '::' || // unspecified + normalized.startsWith('fe80:') || // link-local + normalized.startsWith('fc') || // unique local fc00::/7 + normalized.startsWith('fd') || // unique local fd00::/7 + normalized.startsWith('ff') // multicast + ); +} + +export function isBlockedIp(ip: string): boolean { + const version = net.isIP(ip); + if (version === 4) { + return isBlockedIPv4(ip); + } + if (version === 6) { + // IPv4-mapped IPv6 (::ffff:a.b.c.d) — extract and check as IPv4 + const mapped = ip.toLowerCase().match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (mapped) { + return isBlockedIPv4(mapped[1]); + } + return isBlockedIPv6(ip); + } + return true; +} + +export async function isSafePublicHttpsUrl(value: unknown): Promise { + if (typeof value !== 'string' || !value.trim()) { + return false; + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + return false; + } + + if (parsed.protocol !== 'https:') { + return false; + } + + if (!parsed.hostname) { + return false; + } + + const hostname = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + + if (hostname === 'localhost') { + return false; + } + + // If user supplied a literal IP directly, validate it immediately + const literalIpVersion = net.isIP(hostname); + if (literalIpVersion) { + return !isBlockedIp(hostname); + } + + try { + const records = await dns.lookup(hostname, { all: true }); + + if (!records.length) { + return false; + } + + for (const record of records) { + if (isBlockedIp(record.address)) { + return false; + } + } + + return true; + } catch { + return false; + } +} + +@ValidatorConstraint({ name: 'IsSafeWebhookUrl', async: true }) +export class IsSafeWebhookUrlConstraint implements ValidatorConstraintInterface { + async validate(value: unknown, _args: ValidationArguments): Promise { + return isSafePublicHttpsUrl(value); + } + + defaultMessage(_args: ValidationArguments): string { + return 'URL must be a public HTTPS URL and must not resolve to localhost, private, loopback, or link-local addresses'; + } +} + +export function IsSafeWebhookUrl(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + target: object.constructor, + propertyName, + options: validationOptions, + validator: IsSafeWebhookUrlConstraint, + }); + }; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/dtos/webhooks/webhooks.dto.ts b/libraries/nestjs-libraries/src/dtos/webhooks/webhooks.dto.ts new file mode 100644 index 0000000000000000000000000000000000000000..c64f1751b295879b5d8f9025800d09185d5c1ddd --- /dev/null +++ b/libraries/nestjs-libraries/src/dtos/webhooks/webhooks.dto.ts @@ -0,0 +1,64 @@ +import { IsDefined, IsOptional, IsString, IsUrl } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IsSafeWebhookUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; + +export class WebhooksIntegrationDto { + @IsString() + @IsDefined() + id: string; +} + +export class WebhooksDto { + id: string; + + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsUrl() + @IsDefined() + @IsSafeWebhookUrl({ + message: + 'Webhook URL must be a public HTTPS URL and cannot point to internal network addresses', + }) + url: string; + + @Type(() => WebhooksIntegrationDto) + @IsDefined() + integrations: WebhooksIntegrationDto[]; +} + +export class OnlyURL { + @IsString() + @IsUrl() + @IsDefined() + @IsSafeWebhookUrl({ + message: + 'URL must be a public HTTPS URL and cannot point to internal network addresses', + }) + url: string; +} + +export class UpdateDto { + @IsString() + @IsDefined() + id: string; + + @IsString() + @IsDefined() + name: string; + + @IsString() + @IsUrl() + @IsDefined() + @IsSafeWebhookUrl({ + message: + 'Webhook URL must be a public HTTPS URL and cannot point to internal network addresses', + }) + url: string; + + @Type(() => WebhooksIntegrationDto) + @IsDefined() + integrations: WebhooksIntegrationDto[]; +} diff --git a/libraries/nestjs-libraries/src/emails/email.interface.ts b/libraries/nestjs-libraries/src/emails/email.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5ebf824709e284aed9a6eafe3280c7cb986e9f6 --- /dev/null +++ b/libraries/nestjs-libraries/src/emails/email.interface.ts @@ -0,0 +1,12 @@ +export interface EmailInterface { + name: string; + validateEnvKeys: string[]; + sendEmail( + to: string, + subject: string, + html: string, + emailFromName: string, + emailFromAddress: string, + replyTo?: string + ): Promise; +} diff --git a/libraries/nestjs-libraries/src/emails/empty.provider.ts b/libraries/nestjs-libraries/src/emails/empty.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4fe4e263549b716c77456e3cdc270c2122775c0 --- /dev/null +++ b/libraries/nestjs-libraries/src/emails/empty.provider.ts @@ -0,0 +1,9 @@ +import { EmailInterface } from './email.interface'; + +export class EmptyProvider implements EmailInterface { + name = 'no provider'; + validateEnvKeys = []; + async sendEmail(to: string, subject: string, html: string) { + return `No email provider found, email was supposed to be sent to ${to} with subject: ${subject} and ${html}, html`; + } +} diff --git a/libraries/nestjs-libraries/src/emails/node.mailer.provider.ts b/libraries/nestjs-libraries/src/emails/node.mailer.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff34d3006304bda2b1074c9242b0de0868fd2073 --- /dev/null +++ b/libraries/nestjs-libraries/src/emails/node.mailer.provider.ts @@ -0,0 +1,40 @@ +import nodemailer from 'nodemailer'; +import { EmailInterface } from '@gitroom/nestjs-libraries/emails/email.interface'; + +const transporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + port: +process.env.EMAIL_PORT!, + secure: process.env.EMAIL_SECURE === 'true', + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASS, + }, +}); + +export class NodeMailerProvider implements EmailInterface { + name = 'nodemailer'; + validateEnvKeys = [ + 'EMAIL_HOST', + 'EMAIL_PORT', + 'EMAIL_SECURE', + 'EMAIL_USER', + 'EMAIL_PASS', + ]; + async sendEmail( + to: string, + subject: string, + html: string, + emailFromName: string, + emailFromAddress: string + ) { + const sends = await transporter.sendMail({ + from: `${emailFromName} <${emailFromAddress}>`, // sender address + to: to, // list of receivers + subject: subject, // Subject line + text: html, // plain text body + html: html, // html body + }); + + return sends; + } +} diff --git a/libraries/nestjs-libraries/src/emails/resend.provider.ts b/libraries/nestjs-libraries/src/emails/resend.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0519b0f1139cfacb9654917bbe33257514d094c --- /dev/null +++ b/libraries/nestjs-libraries/src/emails/resend.provider.ts @@ -0,0 +1,33 @@ +import { Resend } from 'resend'; +import { EmailInterface } from '@gitroom/nestjs-libraries/emails/email.interface'; + +const resend = new Resend(process.env.RESEND_API_KEY || 're_132'); + +export class ResendProvider implements EmailInterface { + name = 'resend'; + validateEnvKeys = ['RESEND_API_KEY']; + async sendEmail( + to: string, + subject: string, + html: string, + emailFromName: string, + emailFromAddress: string, + replyTo?: string + ) { + try { + const sends = await resend.emails.send({ + from: `${emailFromName} <${emailFromAddress}>`, + to, + subject, + html, + ...(replyTo && { reply_to: replyTo }), + }); + + return sends; + } catch (err) { + console.log(err); + } + + return { sent: false }; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/integration.manager.ts b/libraries/nestjs-libraries/src/integrations/integration.manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..9229e019a17ad4955da0f27b4e50ccf0cdc65790 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/integration.manager.ts @@ -0,0 +1,177 @@ +import 'reflect-metadata'; + +import { Injectable } from '@nestjs/common'; +import { XProvider } from '@gitroom/nestjs-libraries/integrations/social/x.provider'; +import { SocialProvider } from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider'; +import { RedditProvider } from '@gitroom/nestjs-libraries/integrations/social/reddit.provider'; +import { DevToProvider } from '@gitroom/nestjs-libraries/integrations/social/dev.to.provider'; +import { HashnodeProvider } from '@gitroom/nestjs-libraries/integrations/social/hashnode.provider'; +import { MediumProvider } from '@gitroom/nestjs-libraries/integrations/social/medium.provider'; +import { FacebookProvider } from '@gitroom/nestjs-libraries/integrations/social/facebook.provider'; +import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider'; +import { YoutubeProvider } from '@gitroom/nestjs-libraries/integrations/social/youtube.provider'; +import { TiktokProvider } from '@gitroom/nestjs-libraries/integrations/social/tiktok.provider'; +import { PinterestProvider } from '@gitroom/nestjs-libraries/integrations/social/pinterest.provider'; +import { DribbbleProvider } from '@gitroom/nestjs-libraries/integrations/social/dribbble.provider'; +import { LinkedinPageProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.page.provider'; +import { ThreadsProvider } from '@gitroom/nestjs-libraries/integrations/social/threads.provider'; +import { DiscordProvider } from '@gitroom/nestjs-libraries/integrations/social/discord.provider'; +import { SlackProvider } from '@gitroom/nestjs-libraries/integrations/social/slack.provider'; +import { MastodonProvider } from '@gitroom/nestjs-libraries/integrations/social/mastodon.provider'; +import { BlueskyProvider } from '@gitroom/nestjs-libraries/integrations/social/bluesky.provider'; +import { LemmyProvider } from '@gitroom/nestjs-libraries/integrations/social/lemmy.provider'; +import { InstagramStandaloneProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.standalone.provider'; +import { FarcasterProvider } from '@gitroom/nestjs-libraries/integrations/social/farcaster.provider'; +import { TelegramProvider } from '@gitroom/nestjs-libraries/integrations/social/telegram.provider'; +import { NostrProvider } from '@gitroom/nestjs-libraries/integrations/social/nostr.provider'; +import { VkProvider } from '@gitroom/nestjs-libraries/integrations/social/vk.provider'; +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 { KickProvider } from '@gitroom/nestjs-libraries/integrations/social/kick.provider'; +import { TwitchProvider } from '@gitroom/nestjs-libraries/integrations/social/twitch.provider'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { MoltbookProvider } from '@gitroom/nestjs-libraries/integrations/social/moltbook.provider'; +import { SkoolProvider } from '@gitroom/nestjs-libraries/integrations/social/skool.provider'; +import { WhopProvider } from '@gitroom/nestjs-libraries/integrations/social/whop.provider'; +import { MeweProvider } from '@gitroom/nestjs-libraries/integrations/social/mewe.provider'; +import { TumblrProvider } from '@gitroom/nestjs-libraries/integrations/social/tumblr.provider'; + +export const socialIntegrationList: Array = [ + new XProvider(), + new LinkedinProvider(), + new LinkedinPageProvider(), + new RedditProvider(), + new InstagramProvider(), + new InstagramStandaloneProvider(), + new FacebookProvider(), + new ThreadsProvider(), + new YoutubeProvider(), + new GmbProvider(), + new TiktokProvider(), + new PinterestProvider(), + new DribbbleProvider(), + new DiscordProvider(), + new SlackProvider(), + new KickProvider(), + new TwitchProvider(), + new MastodonProvider(), + new BlueskyProvider(), + new LemmyProvider(), + new FarcasterProvider(), + new TelegramProvider(), + new NostrProvider(), + new VkProvider(), + new MediumProvider(), + new DevToProvider(), + new HashnodeProvider(), + new WordpressProvider(), + new ListmonkProvider(), + new MoltbookProvider(), + new WhopProvider(), + new SkoolProvider(), + new MeweProvider(), + new TumblrProvider(), + // new MastodonCustomProvider(), +]; + +@Injectable() +export class IntegrationManager { + async getAllIntegrations() { + return { + social: await Promise.all( + socialIntegrationList.map(async (p) => ({ + name: p.name, + identifier: p.identifier, + toolTip: p.toolTip, + editor: p.editor, + isExternal: !!p.externalUrl, + isWeb3: !!p.isWeb3, + isChromeExtension: !!p.isChromeExtension, + ...(p.extensionCookies + ? { extensionCookies: p.extensionCookies } + : {}), + ...(p.customFields ? { customFields: await p.customFields() } : {}), + })) + ), + article: [] as any[], + }; + } + + getAllTools(): { + [key: string]: { + description: string; + dataSchema: any; + methodName: string; + }[]; + } { + return socialIntegrationList.reduce( + (all, current) => ({ + ...all, + [current.identifier]: + Reflect.getMetadata('custom:tool', current.constructor.prototype) || + [], + }), + {} + ); + } + + getAllRulesDescription(): { + [key: string]: string; + } { + return socialIntegrationList.reduce( + (all, current) => ({ + ...all, + [current.identifier]: + Reflect.getMetadata( + 'custom:rules:description', + current.constructor + ) || '', + }), + {} + ); + } + + getAllPlugs() { + return socialIntegrationList + .map((p) => { + return { + name: p.name, + identifier: p.identifier, + plugs: ( + Reflect.getMetadata('custom:plug', p.constructor.prototype) || [] + ) + .filter((f: any) => !f.disabled) + .map((p: any) => ({ + ...p, + fields: p.fields.map((c: any) => ({ + ...c, + validation: c?.validation?.toString(), + })), + })), + }; + }) + .filter((f) => f.plugs.length); + } + + getInternalPlugs(providerName: string) { + const p = socialIntegrationList.find((p) => p.identifier === providerName)!; + return { + internalPlugs: + ( + Reflect.getMetadata( + 'custom:internal_plug', + p.constructor.prototype + ) || [] + ).filter((f: any) => !f.disabled) || [], + }; + } + + getAllowedSocialsIntegrations() { + return socialIntegrationList.map((p) => p.identifier); + } + getSocialIntegration(integration: string): SocialProvider { + return socialIntegrationList.find((i) => i.identifier === integration)!; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/integration.missing.scopes.ts b/libraries/nestjs-libraries/src/integrations/integration.missing.scopes.ts new file mode 100644 index 0000000000000000000000000000000000000000..af9266c55eb8d1fdc31397667c5dbe13e6bd348c --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/integration.missing.scopes.ts @@ -0,0 +1,16 @@ +import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common'; +import { Response } from 'express'; +import { NotEnoughScopes } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { HttpStatusCode } from 'axios'; + +@Catch(NotEnoughScopes) +export class NotEnoughScopesFilter implements ExceptionFilter { + catch(exception: NotEnoughScopes, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + response + .status(HttpStatusCode.Conflict) + .json({ msg: exception.message }); + } +} diff --git a/libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts b/libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..0689bdc60360ae1bad00bec5a8b77d4f47edceef --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/refresh.integration.service.ts @@ -0,0 +1,118 @@ +import { forwardRef, Inject, Injectable } from '@nestjs/common'; +import { Integration } from '@prisma/client'; +import { IntegrationManager } from '@gitroom/nestjs-libraries/integrations/integration.manager'; +import { IntegrationService } from '@gitroom/nestjs-libraries/database/prisma/integrations/integration.service'; +import { + AuthTokenDetails, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { TemporalService } from 'nestjs-temporal-core'; + +@Injectable() +export class RefreshIntegrationService { + constructor( + private _integrationManager: IntegrationManager, + @Inject(forwardRef(() => IntegrationService)) + private _integrationService: IntegrationService, + private _temporalService: TemporalService + ) {} + async refresh(integration: Integration, cause = ''): Promise { + const socialProvider = this._integrationManager.getSocialIntegration( + integration.providerIdentifier + ); + + const refresh = await this.refreshProcess(integration, socialProvider, cause); + + if (!refresh) { + return false as const; + } + + await this._integrationService.createOrUpdateIntegration( + undefined, + !!socialProvider.oneTimeToken, + integration.organizationId, + integration.name, + integration.picture!, + 'social', + integration.internalId, + integration.providerIdentifier, + refresh.accessToken, + refresh.refreshToken, + refresh.expiresIn + ); + + return refresh; + } + + public async setBetweenSteps(integration: Integration, cause = '') { + await this._integrationService.setBetweenRefreshSteps(integration.id); + await this._integrationService.informAboutRefreshError( + integration.organizationId, + integration, + cause + ); + } + + public async startRefreshWorkflow(orgId: string, id: string, integration: SocialProvider) { + if (!integration.refreshCron) { + return false; + } + + return this._temporalService.client + .getRawClient() + ?.workflow.start(`refreshTokenWorkflow`, { + workflowId: `refresh_${id}`, + args: [{integrationId: id, organizationId: orgId}], + taskQueue: 'main', + workflowIdConflictPolicy: 'TERMINATE_EXISTING', + }); + } + + private async refreshProcess( + integration: Integration, + socialProvider: SocialProvider, + cause = '' + ): Promise { + const refresh: false | AuthTokenDetails = await socialProvider + .refreshToken(integration.refreshToken) + .catch((err) => false); + + if (!refresh || !refresh.accessToken) { + await this._integrationService.refreshNeeded( + integration.organizationId, + integration.id + ); + + await this._integrationService.informAboutRefreshError( + integration.organizationId, + integration, + cause + ); + + await this._integrationService.disconnectChannel( + integration.organizationId, + integration + ); + + return false; + } + + if ( + !socialProvider.reConnect || + integration.rootInternalId === integration.internalId + ) { + return refresh; + } + + const reConnect = await socialProvider.reConnect( + integration.rootInternalId, + integration.internalId, + refresh.accessToken + ); + + return { + ...refresh, + ...reConnect, + }; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social.abstract.ts b/libraries/nestjs-libraries/src/integrations/social.abstract.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0add74eda7627f95c5b7b637624c5fd1a6ef0d0 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social.abstract.ts @@ -0,0 +1,286 @@ +import { timer } from '@gitroom/helpers/utils/timer'; +import { Integration } from '@prisma/client'; +import { ApplicationFailure } from '@temporalio/activity'; +import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch'; +import { getSsrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import sharp from 'sharp'; + +export type ValidityMedia = { + path: string; + thumbnail?: string; +}; + +// Temporal serializes the whole ApplicationFailure (message + details) into the +// workflow history and ships it over gRPC, which has a hard frame limit (4MB by +// default). Provider error messages/bodies can be huge (full HTML error pages, +// base64 media echoed back, stack traces), so we cap every string that goes into +// the failure to keep the history small and avoid "GRPC Message too large". +const MAX_FAILURE_MESSAGE = 2_000; +const MAX_FAILURE_FIELD = 4_000; + +export function truncateForTemporal(value: any, max: number): string { + if (value === null || value === undefined) { + return ''; + } + + const str = typeof value === 'string' ? value : safeStringify(value); + if (str.length <= max) { + return str; + } + + return `${str.slice(0, max)}… [truncated ${str.length - max} chars]`; +} + +export class RefreshToken extends ApplicationFailure { + constructor(identifier: string, json: string, body: BodyInit, message = '') { + super( + truncateForTemporal(message, MAX_FAILURE_MESSAGE), + 'refresh_token', + true, + [ + { + identifier, + json: truncateForTemporal(json, MAX_FAILURE_FIELD), + body: truncateForTemporal(body, MAX_FAILURE_FIELD), + }, + ] + ); + } +} + +export class BadBody extends ApplicationFailure { + constructor(identifier: string, json: string, body: BodyInit, message = '') { + super(truncateForTemporal(message, MAX_FAILURE_MESSAGE), 'bad_body', true, [ + { + identifier, + json: truncateForTemporal(json, MAX_FAILURE_FIELD), + body: truncateForTemporal(body, MAX_FAILURE_FIELD), + }, + ]); + } +} + +export class NotEnoughScopes { + constructor( + public message = 'Not enough scopes, when choosing a provider, please add all the scopes' + ) {} +} + +function safeStringify(obj: any) { + const seen = new WeakSet(); + + return JSON.stringify(obj, (key, value) => { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + return value; + }); +} + +export abstract class SocialAbstract { + abstract identifier: string; + maxConcurrentJob = 1; + + public handleErrors( + body: string, + status: number + ): + | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | undefined { + return undefined; + } + + /** + * Server-side replacement for the old client-side `checkValidity`. + * Validates the media attached to a post (and its comments) against the + * provider rules. Returns `true` when valid, or an error message string. + * + * `posts` mirrors the client shape: the outer array is the main post followed + * by each comment, the inner array is the media items for that entry. + * + * Note: video-duration validations that used to run in the browser are not + * re-implemented here (no ffmpeg dependency). Image-dimension checks use sharp. + */ + async checkValidity( + posts: Array, + settings: any, + additionalSettings: any[] + ): Promise { + return true; + } + + protected assetBoolean(value: boolean | string) { + if (typeof value === 'string') { + return value.toLowerCase() === 'true'; + } + return value || false; + } + + /** Reads the pixel dimensions of an image via sharp (works for http or local paths). */ + protected async getImageDimensions( + path: string + ): Promise<{ width: number; height: number }> { + // Stored media paths are relative (e.g. "uploads/x.png"); resolve them to a + // fetchable URL the same way posts.service.updateMedia does. + const url = + path?.indexOf('http') === -1 + ? `${process.env.FRONTEND_URL}/${path}` + : path; + const { width = 0, height = 0 } = await sharp( + await readOrFetch(url) + ).metadata(); + return { width, height }; + } + + public async mention( + token: string, + d: { query: string }, + id: string, + integration: Integration + ): Promise< + | { id: string; label: string; image: string; doNotCache?: boolean }[] + | { none: true } + > { + return { none: true }; + } + + async runInConcurrent( + func: (...args: any[]) => Promise, + ignoreConcurrency?: boolean + ) { + let globalErr = {}; + let value: any; + try { + value = await func(); + } catch (err) { + const handle = this.handleErrors(safeStringify(err), 200); + value = { err: true, value: 'Unknown Error', ...(handle || {}) }; + globalErr = err; + } + + if (value && value?.err && value?.value) { + if (value.type === 'refresh-token') { + throw new RefreshToken( + '', + safeStringify({}), + {} as any, + value.value || '' + ); + } + throw new BadBody( + '', + safeStringify(globalErr), + {} as any, + value.value || '' + ); + } + + return value; + } + + async fetch( + url: string, + options: RequestInit = {}, + identifier = '', + totalRetries = 0, + ignoreConcurrency = false, + message = '' + ): Promise { + const request = await fetch(url, { + ...options, + // @ts-ignore - undici-only option, not in the lib.dom RequestInit type + dispatcher: (options as any).dispatcher ?? getSsrfSafeDispatcher(), + }); + + if (request.status === 200 || request.status === 201) { + return request; + } + + let json = '{}'; + try { + json = await request.text(); + } catch (err) { + json = '{}'; + } + + if (totalRetries > 2) { + // Include the platform's actual response body so the failure is + // diagnosable, instead of an empty '{}'. + throw new BadBody(identifier, json, options.body || '{}', message); + } + + const handleError = this.handleErrors(json || '{}', request.status); + + if ( + request.status === 429 || + (request.status === 500 && !handleError) || + json.includes('rate_limit_exceeded') || + json.includes('Rate limit') + ) { + await timer(5000); + return this.fetch( + url, + options, + identifier, + totalRetries + 1, + ignoreConcurrency, + handleError?.value || 'Unknown Error' + ); + } + + if (handleError?.type === 'retry') { + await timer(5000); + return this.fetch( + url, + options, + identifier, + totalRetries + 1, + ignoreConcurrency, + handleError?.value || 'Unknown Error' + ); + } + + if ( + (request.status === 401 && + (handleError?.type === 'refresh-token' || !handleError)) || + handleError?.type === 'refresh-token' + ) { + throw new RefreshToken( + identifier, + json, + options.body!, + handleError?.value + ); + } + + throw new BadBody( + identifier, + json, + options.body!, + handleError?.value || 'Unknown Error' + ); + } + + checkScopes(required: string[], got: string | string[]) { + if (Array.isArray(got)) { + if (!required.every((scope) => got.includes(scope))) { + throw new NotEnoughScopes(); + } + + return true; + } + + const newGot = decodeURIComponent(got); + + const splitType = newGot.indexOf(',') > -1 ? ',' : ' '; + const gotArray = newGot.split(splitType); + if (!required.every((scope) => gotArray.includes(scope))) { + throw new NotEnoughScopes(); + } + + return true; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..bdb794c88248c18eb492e6250581e3c7b16a2492 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/bluesky.provider.ts @@ -0,0 +1,619 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { + BadBody, + RefreshToken, + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { + BskyAgent, + RichText, + AppBskyEmbedVideo, + AppBskyVideoDefs, + AtpAgent, + BlobRef, +} from '@atproto/api'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; +import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; +import sharp from 'sharp'; +import { Plug } from '@gitroom/helpers/decorators/plug.decorator'; +import { timer } from '@gitroom/helpers/utils/timer'; +import axios from 'axios'; +import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; + +async function reduceImageBySize(url: string, maxSizeKB = 976) { + try { + // Fetch the image from the URL + const response = await axios.get(url, { responseType: 'arraybuffer' }); + let imageBuffer = Buffer.from(response.data); + + // Use sharp to get the metadata of the image + const metadata = await sharp(imageBuffer).metadata(); + let width = metadata.width!; + let height = metadata.height!; + + // Resize iteratively until the size is below the threshold + while (imageBuffer.length / 1024 > maxSizeKB) { + width = Math.floor(width * 0.9); // Reduce dimensions by 10% + height = Math.floor(height * 0.9); + + // Resize the image + const resizedBuffer = await sharp(imageBuffer) + .resize({ width, height }) + .toBuffer(); + + imageBuffer = resizedBuffer; + + if (width < 10 || height < 10) break; // Prevent overly small dimensions + } + + return { width, height, buffer: imageBuffer }; + } catch (error) { + console.error('Error processing image:', error); + throw error; + } +} + +async function uploadVideo( + agent: AtpAgent, + videoPath: string +): Promise { + const { data: serviceAuth } = await agent.com.atproto.server.getServiceAuth({ + aud: `did:web:${agent.dispatchUrl.host}`, + lxm: 'com.atproto.repo.uploadBlob', + exp: Date.now() / 1000 + 60 * 30, // 30 minutes + }); + + async function downloadVideo( + url: string + ): Promise<{ video: Buffer; size: number }> { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch video: ${response.statusText}`); + } + const arrayBuffer = await response.arrayBuffer(); + const video = Buffer.from(arrayBuffer); + const size = video.length; + return { video, size }; + } + + const video = await downloadVideo(videoPath); + + console.log('Downloaded video', videoPath, video.size); + + const uploadUrl = new URL( + 'https://video.bsky.app/xrpc/app.bsky.video.uploadVideo' + ); + uploadUrl.searchParams.append('did', agent.session!.did); + uploadUrl.searchParams.append('name', videoPath.split('/').pop()!); + + const uploadResponse = await fetch(uploadUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${serviceAuth.token}`, + 'Content-Type': 'video/mp4', + 'Content-Length': video.size.toString(), + }, + body: video.video, + }); + + const jobStatus = (await uploadResponse.json()) as AppBskyVideoDefs.JobStatus; + console.log('JobId:', jobStatus.jobId); + let blob: BlobRef | undefined = jobStatus.blob; + const videoAgent = new AtpAgent({ service: 'https://video.bsky.app' }); + + let attempts = 0; + const maxAttempts = 18; // ~9 minutes at 30s interval + while (!blob) { + if (attempts++ >= maxAttempts) { + throw new BadBody( + 'bluesky', + JSON.stringify({}), + {} as any, + 'Video upload timed out, job did not complete' + ); + } + + const { data: status } = await videoAgent.app.bsky.video.getJobStatus({ + jobId: jobStatus.jobId, + }); + console.log( + 'Status:', + status.jobStatus.state, + status.jobStatus.progress || '' + ); + if (status.jobStatus.blob) { + blob = status.jobStatus.blob; + } + + if (status.jobStatus.state === 'JOB_STATE_FAILED') { + throw new BadBody( + 'bluesky', + JSON.stringify({}), + {} as any, + 'Could not upload video, job failed' + ); + } + + await timer(30000); + } + + console.log('posting video...'); + + return { + $type: 'app.bsky.embed.video', + video: blob, + } satisfies AppBskyEmbedVideo.Main; +} + +@Rules( + 'Bluesky can have maximum 1 video or 4 pictures in one post, it can also be without attachments' +) +export class BlueskyProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 2; // Bluesky has moderate rate limits + identifier = 'bluesky'; + name = 'Bluesky'; + toolTip = "We don’t currently support two-factor authentication. If it’s enabled on Bluesky, you’ll need to disable it." + isBetweenSteps = false; + scopes = ['write:statuses', 'profile', 'write:media']; + editor = 'normal' as const; + maxLength() { + return 300; + } + + override async checkValidity( + posts: Array + ): Promise { + if ( + posts?.some( + (p) => + p?.some((a) => (a?.path?.indexOf?.('mp4') ?? -1) > -1) && + (p?.length ?? 0) > 1 + ) + ) { + return 'You can only upload one video per post.'; + } + + if (posts?.some((p) => (p?.length ?? 0) > 4)) { + return 'There can be maximum 4 pictures in a post.'; + } + return true; + } + + async customFields() { + return [ + { + key: 'service', + label: 'Service', + defaultValue: 'https://bsky.social', + validation: `/^(https?:\\/\\/)?((([a-zA-Z0-9\\-_]{1,256}\\.[a-zA-Z]{2,6})|(([0-9]{1,3}\\.){3}[0-9]{1,3}))(:[0-9]{1,5})?)(\\/[^\\s]*)?$/`, + type: 'text' as const, + }, + { + key: 'identifier', + label: 'Identifier', + validation: `/^.+$/`, + type: 'text' as const, + }, + { + key: 'password', + label: 'Password', + validation: `/^.{3,}$/`, + type: 'password' as const, + }, + ]; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body = JSON.parse(Buffer.from(params.code, 'base64').toString()); + + // Bluesky talks to a user-supplied service URL via BskyAgent (not our + // `this.fetch`), so the undici SSRF dispatcher can't intercept it. Validate + // the URL here — the connection chokepoint — so an internal/private address + // can never be saved as an integration. Opt-out matches the dispatcher env. + if ( + process.env.DISABLE_SSRF_PROTECTION !== 'true' && + !(await isSafePublicHttpsUrl(body.service)) + ) { + return 'Invalid service URL: must be a public HTTPS address'; + } + + try { + const agent = new BskyAgent({ + service: body.service, + }); + + const { + data: { accessJwt, refreshJwt, handle, did }, + } = await agent.login({ + identifier: body.identifier, + password: body.password, + }); + + const profile = await agent.getProfile({ + actor: did, + }); + + return { + refreshToken: refreshJwt, + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + accessToken: accessJwt, + id: did, + name: profile.data.displayName!, + picture: profile?.data?.avatar || '', + username: profile.data.handle!, + }; + } catch (e) { + console.log(e); + return 'Invalid credentials'; + } + } + + private async getAgent(integration: Integration) { + const body = JSON.parse( + AuthService.fixedDecryption(integration.customInstanceDetails!) + ); + const agent = new BskyAgent({ + service: body.service, + }); + + try { + await agent.login({ + identifier: body.identifier, + password: body.password, + }); + } catch (err) { + throw new RefreshToken('bluesky', JSON.stringify(err), {} as BodyInit); + } + + return agent; + } + + private async uploadMediaForPost( + agent: BskyAgent, + post: PostDetails + ): Promise<{ embed: any; images: any[] }> { + // Separate images and videos + const imageMedia = + post.media?.filter((p) => !hasExtension(p.path, 'mp4')) || []; + const videoMedia = + post.media?.filter((p) => hasExtension(p.path, 'mp4')) || []; + + // 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])), + }; + }) + ); + + // 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); + } + + // 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, + }, + })), + }; + } + + return { embed, images }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + 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 { + 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({ + identifier: 'bluesky-autoRepostPost', + title: 'Auto Repost Posts', + description: + 'When a post reached a certain number of likes, repost it to increase engagement (1 week old posts)', + runEveryMilliseconds: 21600000, + totalRuns: 3, + fields: [ + { + name: 'likesAmount', + type: 'number', + placeholder: 'Amount of likes', + description: 'The amount of likes to trigger the repost', + validation: /^\d+$/, + }, + ], + }) + async autoRepostPost( + integration: Integration, + id: string, + fields: { likesAmount: string } + ) { + const body = JSON.parse( + AuthService.fixedDecryption(integration.customInstanceDetails!) + ); + const agent = new BskyAgent({ + service: body.service, + }); + + await agent.login({ + identifier: body.identifier, + password: body.password, + }); + + const getThread = await agent.getPostThread({ + uri: id, + depth: 0, + }); + + // @ts-ignore + if (getThread.data.thread.post?.likeCount >= +fields.likesAmount) { + await timer(2000); + await agent.repost( + // @ts-ignore + getThread.data.thread.post?.uri, + // @ts-ignore + getThread.data.thread.post?.cid + ); + return true; + } + + return true; + } + + @Plug({ + identifier: 'bluesky-autoPlugPost', + title: 'Auto plug post', + description: + 'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion', + runEveryMilliseconds: 21600000, + totalRuns: 3, + fields: [ + { + name: 'likesAmount', + type: 'number', + placeholder: 'Amount of likes', + description: 'The amount of likes to trigger the repost', + validation: /^\d+$/, + }, + { + name: 'post', + type: 'richtext', + placeholder: 'Post to plug', + description: 'Message content to plug', + validation: /^[\s\S]{3,}$/g, + }, + ], + }) + async autoPlugPost( + integration: Integration, + id: string, + fields: { likesAmount: string; post: string } + ) { + const body = JSON.parse( + AuthService.fixedDecryption(integration.customInstanceDetails!) + ); + const agent = new BskyAgent({ + service: body.service, + }); + + await agent.login({ + identifier: body.identifier, + password: body.password, + }); + + const getThread = await agent.getPostThread({ + uri: id, + depth: 0, + }); + + // @ts-ignore + if (getThread.data.thread.post?.likeCount >= +fields.likesAmount) { + await timer(2000); + const rt = new RichText({ + text: stripHtmlValidation('normal', fields.post, true), + }); + + await agent.post({ + text: rt.text, + facets: rt.facets, + createdAt: new Date().toISOString(), + reply: { + root: { + // @ts-ignore + uri: getThread.data.thread.post?.uri, + // @ts-ignore + cid: getThread.data.thread.post?.cid, + }, + parent: { + // @ts-ignore + uri: getThread.data.thread.post?.uri, + // @ts-ignore + cid: getThread.data.thread.post?.cid, + }, + }, + }); + return true; + } + + return true; + } + + override async mention( + token: string, + d: { query: string }, + id: string, + integration: Integration + ) { + const body = JSON.parse( + AuthService.fixedDecryption(integration.customInstanceDetails!) + ); + + const agent = new BskyAgent({ + service: body.service, + }); + + await agent.login({ + identifier: body.identifier, + password: body.password, + }); + + const list = await agent.searchActors({ + q: d.query, + }); + + return list.data.actors.map((p) => ({ + label: p.displayName, + id: p.handle, + image: p.avatar, + })); + } + + mentionFormat(idOrHandle: string, name: string) { + return `@${idOrHandle}`; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..f0aee14a95291deafbe4a8ba88e5c401bea3eed1 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/dev.to.provider.ts @@ -0,0 +1,188 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { DevToSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dev.to.settings.dto'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +export class DevToProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; // Dev.to has moderate publishing limits + identifier = 'devto'; + name = 'Dev.to'; + isBetweenSteps = false; + editor = 'markdown' as const; + scopes = [] as string[]; + maxLength() { + return 100000; + } + dto = DevToSettingsDto; + + async generateAuthUrl() { + const state = makeId(6); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + override handleErrors(body: string) { + if (body.indexOf('Canonical url has already been taken') > -1) { + return { + type: 'bad-body' as const, + value: 'Canonical URL already exists', + }; + } + + return undefined; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async customFields() { + return [ + { + key: 'apiKey', + label: 'API key', + validation: `/^.{3,}$/`, + type: 'password' as const, + }, + ]; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body = JSON.parse(Buffer.from(params.code, 'base64').toString()); + try { + const { name, id, profile_image, username } = await ( + await fetch('https://dev.to/api/users/me', { + headers: { + 'api-key': body.apiKey, + }, + }) + ).json(); + + return { + refreshToken: '', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + accessToken: body.apiKey, + id, + name, + picture: profile_image || '', + username, + }; + } catch (err) { + return 'Invalid credentials'; + } + } + + @Tool({ description: 'Tag list', dataSchema: [] }) + async tags(token: string) { + const tags = await ( + await fetch('https://dev.to/api/tags?per_page=1000&page=1', { + headers: { + 'api-key': token, + }, + }) + ).json(); + + return tags.map((p: any) => ({ value: p.id, label: p.name })); + } + + @Tool({ description: 'Organization list', dataSchema: [] }) + async organizations(token: string) { + const orgs = await ( + await fetch('https://dev.to/api/articles/me/all?per_page=1000', { + headers: { + 'api-key': token, + }, + }) + ).json(); + + const allOrgs: string[] = [ + ...new Set( + orgs + .flatMap((org: any) => org?.organization?.username) + .filter((f: string) => f) + ), + ] as string[]; + const fullDetails = await Promise.all( + allOrgs.map(async (org: string) => { + return ( + await fetch(`https://dev.to/api/organizations/${org}`, { + headers: { + 'api-key': token, + }, + }) + ).json(); + }) + ); + + return fullDetails.map((org: any) => ({ + id: org.id, + name: org.name, + username: org.username, + })); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const { settings } = postDetails?.[0] || { settings: {} }; + const { id: postId, url } = await ( + await this.fetch(`https://dev.to/api/articles`, { + method: 'POST', + body: JSON.stringify({ + article: { + title: settings.title, + body_markdown: postDetails?.[0].message, + published: true, + ...(settings?.main_image?.path + ? { main_image: settings?.main_image?.path } + : {}), + tags: settings?.tags?.map((t: any) => t.label), + organization_id: settings.organization, + ...(settings.canonical + ? { canonical_url: settings.canonical } + : {}), + }, + }), + headers: { + 'Content-Type': 'application/json', + 'api-key': accessToken, + }, + }) + ).json(); + + return [ + { + id: postDetails?.[0].id, + status: 'completed', + postId: String(postId), + releaseURL: url, + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts b/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..a70f36ce151c441ae93ff41da1a200be7829388d --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/discord.provider.ts @@ -0,0 +1,411 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { Integration } from '@prisma/client'; +import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +export class DiscordProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 5; // Discord has generous rate limits for webhook posting + identifier = 'discord'; + name = 'Discord'; + isBetweenSteps = false; + editor = 'markdown' as const; + scopes = ['identify', 'guilds']; + maxLength() { + return 1980; + } + dto = DiscordDto; + + async refreshToken(refreshToken: string): Promise { + const { access_token, expires_in, refresh_token } = await ( + await this.fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + body: new URLSearchParams({ + refresh_token: refreshToken, + grant_type: 'refresh_token', + }), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + process.env.DISCORD_CLIENT_ID + + ':' + + process.env.DISCORD_CLIENT_SECRET + ).toString('base64')}`, + }, + }) + ).json(); + + const { application } = await ( + await this.fetch('https://discord.com/api/oauth2/@me', { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + refreshToken: refresh_token, + expiresIn: expires_in, + accessToken: access_token, + id: '', + name: application.name, + picture: '', + username: '', + }; + } + async generateAuthUrl() { + const state = makeId(6); + return { + url: `https://discord.com/oauth2/authorize?client_id=${ + process.env.DISCORD_CLIENT_ID + }&permissions=377957124096&response_type=code&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/discord` + )}&integration_type=0&scope=bot+identify+guilds&state=${state}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const { access_token, expires_in, refresh_token, scope, guild } = await ( + await this.fetch('https://discord.com/api/oauth2/token', { + method: 'POST', + body: new URLSearchParams({ + code: params.code, + grant_type: 'authorization_code', + redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/discord`, + }), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + process.env.DISCORD_CLIENT_ID + + ':' + + process.env.DISCORD_CLIENT_SECRET + ).toString('base64')}`, + }, + }) + ).json(); + + this.checkScopes(this.scopes, scope.split(' ')); + + const { application } = await ( + await this.fetch('https://discord.com/api/oauth2/@me', { + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + id: guild.id, + name: application.name, + accessToken: access_token, + refreshToken: refresh_token, + expiresIn: expires_in, + picture: `https://cdn.discordapp.com/avatars/${application.bot.id}/${application.bot.avatar}.png`, + username: application.bot.username, + }; + } + + @Tool({ description: 'Channels', dataSchema: [] }) + async channels(accessToken: string, params: any, id: string) { + const list = await ( + await this.fetch(`https://discord.com/api/guilds/${id}/channels`, { + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`, + }, + }) + ).json(); + + return list + .filter((p: any) => p.type === 0 || p.type === 5 || p.type === 15) + .map((p: any) => ({ + id: String(p.id), + name: p.name, + })); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + 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 this.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 { + 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 this.fetch( + `https://discord.com/api/channels/${channel}/messages/${postId}/threads`, + { + method: 'POST', + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: 'Thread', + auto_archive_duration: 1440, + }), + } + ) + ).json(); + threadChannel = threadId; + } + + 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( + `files[${index}]`, + await loadMedia.blob(), + media.path.split('/').pop() + ); + index++; + } + + const data = await ( + await this.fetch( + `https://discord.com/api/channels/${threadChannel}/messages`, + { + method: 'POST', + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`, + }, + body: form, + } + ) + ).json(); + + return [ + { + id: commentPost.id, + releaseURL: `https://discord.com/channels/${id}/${threadChannel}/${data.id}`, + postId: data.id, + status: 'success', + }, + ]; + } + + async changeNickname(id: string, accessToken: string, name: string) { + await ( + await this.fetch(`https://discord.com/api/guilds/${id}/members/@me`, { + method: 'PATCH', + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + nick: name, + }), + }) + ).json(); + + return { + name, + }; + } + + override async mention( + token: string, + data: { query: string }, + id: string, + integration: Integration + ) { + const allRoles = await ( + await this.fetch(`https://discord.com/api/guilds/${id}/roles`, { + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`, + 'Content-Type': 'application/json', + }, + }) + ).json(); + + const matching = allRoles + .filter((role: any) => + role.name.toLowerCase().includes(data.query.toLowerCase()) + ) + .filter((f: any) => f.name !== '@everyone' && f.name !== '@here'); + + const list = await ( + await this.fetch( + `https://discord.com/api/guilds/${id}/members/search?query=${data.query}`, + { + headers: { + Authorization: `Bot ${process.env.DISCORD_BOT_TOKEN_ID}`, + 'Content-Type': 'application/json', + }, + } + ) + ).json(); + + return [ + ...[ + { + id: String('here'), + label: 'here', + image: '', + doNotCache: true, + }, + { + id: String('everyone'), + label: 'everyone', + image: '', + doNotCache: true, + }, + ].filter((role: any) => { + return role.label.toLowerCase().includes(data.query.toLowerCase()); + }), + ...matching.map((p: any) => ({ + id: String('&' + p.id), + label: p.name.split('@')[1], + image: '', + doNotCache: true, + })), + ...list.map((p: any) => ({ + id: String(p.user.id), + label: p.user.global_name || p.user.username, + image: `https://cdn.discordapp.com/avatars/${p.user.id}/${p.user.avatar}.png`, + })), + ]; + } + + mentionFormat(idOrHandle: string, name: string) { + if (name === '@here' || name === '@everyone') { + return name; + } + return `[[[@${idOrHandle.replace('@', '')}]]]`; + } + + override handleErrors( + body: string + ): + | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | undefined { + if (body.includes('50001')) { + return { + type: 'bad-body', + value: "Bot doesn't have access to this channel", + }; + } + + if (body.includes('50013')) { + return { + type: 'bad-body', + value: 'Bot lacks permission to send messages in this channel', + }; + } + + if (body.includes('10003')) { + return { + type: 'bad-body', + value: 'Channel no longer exists', + }; + } + + if (body.includes('40005')) { + return { + type: 'bad-body', + value: "Attachment exceeds Discord's size limit", + }; + } + + if (body.includes('20028')) { + return { + type: 'retry', + value: 'Rate limited by Discord', + }; + } + + return undefined; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..7b93eae45637af92d81ecdd0f217a19ef35d171f --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/dribbble.provider.ts @@ -0,0 +1,225 @@ +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import axios from 'axios'; +import FormData from 'form-data'; +import { + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { DribbbleDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dribbble.dto'; +import mime from 'mime-types'; +import { DiscordDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/discord.dto'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +export class DribbbleProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; // Dribbble has moderate API limits + identifier = 'dribbble'; + name = 'Dribbble'; + isBetweenSteps = false; + scopes = ['public', 'upload']; + editor = 'normal' as const; + maxLength() { + return 40000; + } + dto = DribbbleDto; + + override async checkValidity( + [firstItem]: Array + ): Promise { + const isMp4 = firstItem?.find( + (item) => (item?.path?.indexOf?.('mp4') ?? -1) > -1 + ); + if (firstItem?.length !== 1) { + return 'Requires one item'; + } + if (isMp4) { + return 'Does not support mp4 files'; + } + const details = await this.getImageDimensions(firstItem?.[0]?.path); + if ( + (details?.width === 400 && details?.height === 300) || + (details?.width === 800 && details?.height === 600) + ) { + return true; + } + return 'Invalid image size. Requires 400x300 or 800x600 px images.'; + } + + async refreshToken(refreshToken: string): Promise { + const { access_token, expires_in } = await ( + await this.fetch('https://api-sandbox.pinterest.com/v5/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + `${process.env.PINTEREST_CLIENT_ID}:${process.env.PINTEREST_CLIENT_SECRET}` + ).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + scope: `${this.scopes.join(',')}`, + redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/pinterest`, + }), + }) + ).json(); + + const { id, profile_image, username } = await ( + await this.fetch('https://api-sandbox.pinterest.com/v5/user_account', { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + id: id, + name: username, + accessToken: access_token, + refreshToken: refreshToken, + expiresIn: expires_in, + picture: profile_image || '', + username, + }; + } + + @Tool({ description: 'Teams list', dataSchema: [] }) + async teams(accessToken: string) { + const { teams } = await ( + await this.fetch('https://api.dribbble.com/v2/user', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return ( + teams?.map((team: any) => ({ + id: team.id, + name: team.name, + })) || [] + ); + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: `https://dribbble.com/oauth/authorize?client_id=${ + process.env.DRIBBBLE_CLIENT_ID + }&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/dribbble` + )}&response_type=code&scope=${this.scopes.join('+')}&state=${state}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh: string; + }) { + const { access_token, scope } = await ( + await this.fetch( + `https://dribbble.com/oauth/token?client_id=${process.env.DRIBBBLE_CLIENT_ID}&client_secret=${process.env.DRIBBBLE_CLIENT_SECRET}&code=${params.code}&redirect_uri=${process.env.FRONTEND_URL}/integrations/social/dribbble`, + { + method: 'POST', + } + ) + ).json(); + + this.checkScopes(this.scopes, scope); + + const { id, name, avatar_url, login } = await ( + await this.fetch('https://api.dribbble.com/v2/user', { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + id: id, + name, + accessToken: access_token, + refreshToken: '', + expiresIn: 999999999, + picture: avatar_url, + username: login, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const { data, status } = await axios.get( + postDetails?.[0]?.media?.[0]?.path!, + { + responseType: 'stream', + } + ); + + const slash = postDetails?.[0]?.media?.[0]?.path.split('/').at(-1); + + const formData = new FormData(); + formData.append('image', data, { + filename: slash, + contentType: mime.lookup(slash!) || '', + }); + + formData.append('title', postDetails[0].settings.title); + formData.append('description', postDetails[0].message); + + const data2 = await axios.post( + 'https://api.dribbble.com/v2/shots', + formData, + { + headers: { + ...formData.getHeaders(), + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + const location = data2.headers['location']; + const newId = location.split('/').at(-1); + + return [ + { + id: postDetails?.[0]?.id, + status: 'completed', + postId: newId, + releaseURL: `https://dribbble.com/shots/${newId}`, + }, + ]; + } + + analytics( + id: string, + accessToken: string, + date: number + ): Promise { + return Promise.resolve([]); + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + // Dribbble doesn't provide detailed post-level analytics via their API + return []; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b2bf9aa227b1572f905bc99b853dc88292eaff8 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/facebook.provider.ts @@ -0,0 +1,887 @@ +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import dayjs from 'dayjs'; +import { + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { + FacebookDto, + FACEBOOK_PRESET_MAX_CHARS, +} from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/facebook.dto'; +import { DribbbleDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/dribbble.dto'; +import { Integration } from '@prisma/client'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; + +@Rules( + "Facebook posts can be text only, or include photos or a video. If it's a story, it must have at least one attachment (photo or video), and each media is published as a separate story." +) +export class FacebookProvider extends SocialAbstract implements SocialProvider { + identifier = 'facebook'; + name = 'Facebook Page'; + isBetweenSteps = true; + scopes = [ + 'pages_show_list', + 'business_management', + 'pages_manage_posts', + 'pages_manage_engagement', + 'pages_read_engagement', + 'read_insights', + ]; + override maxConcurrentJob = 500; // Facebook has reasonable rate limits + editor = 'normal' as const; + maxLength() { + return 63206; + } + dto = FacebookDto; + + override async checkValidity( + [firstPost]: Array, + settings: any + ): Promise { + if (settings?.post_type === 'story') { + if (!firstPost?.length) { + return 'Story should have at least one media'; + } + } + return true; + } + + override handleErrors( + body: string, + status: number + ): + | { + type: 'refresh-token' | 'bad-body'; + value: string; + } + | undefined { + // Access token validation errors - require re-authentication + if (body.indexOf('Error validating access token') > -1) { + return { + type: 'refresh-token' as const, + value: 'Please re-authenticate your Facebook account', + }; + } + + if (body.indexOf('REVOKED_ACCESS_TOKEN') > -1) { + return { + type: 'refresh-token' as const, + value: 'Access token has been revoked, please re-authenticate', + }; + } + + if (body.indexOf('1366046') > -1) { + return { + type: 'bad-body' as const, + value: 'Photos should be smaller than 4 MB and saved as JPG, PNG', + }; + } + + if (body.indexOf('1390008') > -1) { + return { + type: 'bad-body' as const, + value: 'You are posting too fast, please slow down', + }; + } + + // Content policy violations + if (body.indexOf('1346003') > -1) { + return { + type: 'bad-body' as const, + value: 'Content flagged as abusive by Facebook', + }; + } + + if (body.indexOf('1404006') > -1) { + return { + type: 'bad-body' as const, + value: + "We couldn't post your comment, A security check in facebook required to proceed.", + }; + } + + if (body.indexOf('2069019') > -1) { + return { + type: 'bad-body' as const, + value: 'Invalid file', + } + } + + if (body.indexOf('1404102') > -1) { + return { + type: 'bad-body' as const, + value: 'Content violates Facebook Community Standards', + }; + } + + // Permission errors + if (body.indexOf('1404078') > -1) { + return { + type: 'refresh-token' as const, + value: 'Page publishing authorization required, please re-authenticate', + }; + } + + if (body.indexOf('1366051') > -1) { + return { + type: 'bad-body' as const, + value: 'These photos were already posted.', + }; + } + + if (body.indexOf('1609008') > -1) { + return { + type: 'bad-body' as const, + value: 'Cannot post Facebook.com links', + }; + } + + // Parameter validation errors + if (body.indexOf('2061006') > -1) { + return { + type: 'bad-body' as const, + value: 'Invalid URL format in post content', + }; + } + + if (body.indexOf('1349125') > -1) { + return { + type: 'bad-body' as const, + value: 'Invalid content format', + }; + } + + if (body.indexOf('1404112') > -1) { + return { + type: 'bad-body' as const, + value: + 'For security reasons, your account has limited access to the site for a few days', + }; + } + + if (body.indexOf('Name parameter too long') > -1) { + return { + type: 'bad-body' as const, + value: 'Post content is too long', + }; + } + + // Service errors - checking specific subcodes first + if (body.indexOf('1363047') > -1) { + return { + type: 'bad-body' as const, + value: 'Facebook service temporarily unavailable', + }; + } + + if (body.indexOf('1609010') > -1) { + return { + type: 'bad-body' as const, + value: 'Facebook service temporarily unavailable', + }; + } + + if (body.indexOf('4854002') > -1) { + return { + type: 'bad-body' as const, + value: + 'Confirm your identity before you can publish as this Page. Open the Facebook app on your phone and follow the instructions', + }; + } + if (body.indexOf('(#100) No permission to publish the video') > -1) { + return { + type: 'bad-body' as const, + value: 'Facebook return: No permission to publish the video', + }; + } + if (body.indexOf('490') > -1) { + return { + type: 'refresh-token' as const, + value: 'Access token expired, please re-authenticate', + }; + } + + if (status === 401) { + return { + type: 'bad-body' as const, + value: + 'An unknown error occurred, please try again later or contact support', + }; + } + + return undefined; + } + + async refreshToken(refresh_token: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: + 'https://www.facebook.com/v20.0/dialog/oauth' + + `?client_id=${process.env.FACEBOOK_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/facebook` + )}` + + `&state=${state}` + + `&scope=${this.scopes.join(',')}`, + codeVerifier: makeId(10), + state, + }; + } + + async reConnect( + id: string, + requiredId: string, + accessToken: string + ): Promise> { + const information = await this.fetchPageInformation(accessToken, { + page: requiredId, + }); + + return { + id: information.id, + name: information.name, + accessToken: information.access_token, + picture: information.picture, + username: information.username, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const getAccessToken = await ( + await fetch( + 'https://graph.facebook.com/v20.0/oauth/access_token' + + `?client_id=${process.env.FACEBOOK_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/facebook${ + params.refresh ? `?refresh=${params.refresh}` : '' + }` + )}` + + `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + + `&code=${params.code}` + ) + ).json(); + + const { access_token } = await ( + await fetch( + 'https://graph.facebook.com/v20.0/oauth/access_token' + + '?grant_type=fb_exchange_token' + + `&client_id=${process.env.FACEBOOK_APP_ID}` + + `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + + `&fb_exchange_token=${getAccessToken.access_token}&fields=access_token,expires_in` + ) + ).json(); + + const { data } = await ( + await fetch( + `https://graph.facebook.com/v20.0/me/permissions?access_token=${access_token}` + ) + ).json(); + + const permissions = data + .filter((d: any) => d.status === 'granted') + .map((p: any) => p.permission); + this.checkScopes(this.scopes, permissions); + + const { id, name, picture } = await ( + await fetch( + `https://graph.facebook.com/v20.0/me?fields=id,name,picture&access_token=${access_token}` + ) + ).json(); + + return { + id, + name, + accessToken: access_token, + refreshToken: access_token, + expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(), + picture: picture?.data?.url || '', + username: '', + }; + } + + async pages(accessToken: string) { + const seenIds = new Set(); + const allPages: any[] = []; + + const fetchPaginated = async (startUrl: string) => { + let nextUrl: string | undefined = startUrl; + while (nextUrl) { + const response = await (await fetch(nextUrl)).json(); + if (response.data) { + for (const page of response.data) { + if (!seenIds.has(page.id)) { + seenIds.add(page.id); + allPages.push(page); + } + } + } + nextUrl = response.paging?.next; + } + }; + + // Fetch pages the user explicitly shared during the OAuth dialog + await fetchPaginated( + `https://graph.facebook.com/v20.0/me/accounts?fields=id,username,name,access_token,picture.type(large)&limit=100&access_token=${accessToken}` + ); + + // Also fetch pages via Business Manager API to discover pages + // not selected during the OAuth page selection step + try { + let bizUrl: + | string + | undefined = `https://graph.facebook.com/v20.0/me/businesses?access_token=${accessToken}`; + + while (bizUrl) { + const bizResponse = await (await fetch(bizUrl)).json(); + if (bizResponse.data) { + for (const business of bizResponse.data) { + try { + await fetchPaginated( + `https://graph.facebook.com/v20.0/${business.id}/owned_pages?fields=id,username,name,access_token,picture.type(large)&limit=100&access_token=${accessToken}` + ); + } catch { + // Continue with other businesses + } + + try { + await fetchPaginated( + `https://graph.facebook.com/v20.0/${business.id}/client_pages?fields=id,username,name,access_token,picture.type(large)&limit=100&access_token=${accessToken}` + ); + } catch { + // Continue with other businesses + } + } + } + bizUrl = bizResponse.paging?.next; + } + } catch { + // Business Manager API not available for all users + } + + return allPages; + } + + async fetchPageInformation(accessToken: string, data: { page: string }) { + const pageId = data.page; + const fields = 'id,username,name,access_token,picture.type(large)'; + + const searchPaginated = async (startUrl: string) => { + let url: string | undefined = startUrl; + while (url) { + const response = await (await fetch(url)).json(); + if (response.data) { + const page = response.data.find( + (p: any) => String(p.id) === String(pageId) + ); + if (page) { + return { + id: page.id, + name: page.name, + access_token: page.access_token, + picture: page.picture?.data?.url || '', + username: page.username, + }; + } + } + url = response.paging?.next; + } + return null; + }; + + // 1. Check /me/accounts + const fromAccounts = await searchPaginated( + `https://graph.facebook.com/v20.0/me/accounts?fields=${fields}&limit=100&access_token=${accessToken}` + ); + if (fromAccounts) return fromAccounts; + + // 2. Check Business Manager owned_pages and client_pages + try { + let bizUrl: + | string + | undefined = `https://graph.facebook.com/v20.0/me/businesses?access_token=${accessToken}`; + + while (bizUrl) { + const bizResponse = await (await fetch(bizUrl)).json(); + if (bizResponse.data) { + for (const business of bizResponse.data) { + try { + const fromOwned = await searchPaginated( + `https://graph.facebook.com/v20.0/${business.id}/owned_pages?fields=${fields}&limit=100&access_token=${accessToken}` + ); + if (fromOwned) return fromOwned; + } catch { + // Continue with other businesses + } + + try { + const fromClient = await searchPaginated( + `https://graph.facebook.com/v20.0/${business.id}/client_pages?fields=${fields}&limit=100&access_token=${accessToken}` + ); + if (fromClient) return fromClient; + } catch { + // Continue with other businesses + } + } + } + bizUrl = bizResponse.paging?.next; + } + } catch { + // Business Manager API not available for all users + } + + throw new Error('Page not found in your accounts'); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const [firstPost] = postDetails; + const isStory = firstPost?.settings?.post_type === 'story'; + + let finalId = ''; + let finalUrl = ''; + if (isStory) { + let lastPostId = ''; + for (const media of firstPost?.media || []) { + const isVideoStory = hasExtension(media.path, 'mp4'); + if (isVideoStory) { + const { video_id, upload_url } = await ( + await this.fetch( + `https://graph.facebook.com/v20.0/${id}/video_stories?upload_phase=start&access_token=${accessToken}`, + { + method: 'POST', + }, + 'start video story upload' + ) + ).json(); + + await this.fetch( + upload_url, + { + method: 'POST', + headers: { + Authorization: `OAuth ${accessToken}`, + file_url: media.path, + }, + }, + 'upload video story' + ); + + let videoStatus = 'in_progress'; + let attempts = 0; + const maxAttempts = 54; // ~9 minutes at 10s interval + while (videoStatus !== 'upload_complete' && videoStatus !== 'ready') { + if (attempts++ >= maxAttempts) { + throw new Error('Video processing timed out'); + } + + const { status } = await ( + await this.fetch( + `https://graph.facebook.com/v20.0/${video_id}?fields=status&access_token=${accessToken}`, + undefined, + '', + 0, + true + ) + ).json(); + videoStatus = status?.video_status || 'in_progress'; + if (videoStatus === 'error') { + throw new Error('Video processing failed'); + } + if (videoStatus !== 'upload_complete' && videoStatus !== 'ready') { + await timer(10000); + } + } + + const { post_id: storyPostId } = await ( + await this.fetch( + `https://graph.facebook.com/v20.0/${id}/video_stories?upload_phase=finish&video_id=${video_id}&access_token=${accessToken}`, + { + method: 'POST', + }, + 'finish video story upload' + ) + ).json(); + + lastPostId = storyPostId; + } else { + const { id: photoId } = await ( + await this.fetch( + `https://graph.facebook.com/v20.0/${id}/photos?access_token=${accessToken}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: media.path, + published: false, + }), + }, + 'upload photo story' + ) + ).json(); + + const { post_id: storyPostId } = await ( + await this.fetch( + `https://graph.facebook.com/v20.0/${id}/photo_stories?photo_id=${photoId}&access_token=${accessToken}`, + { + method: 'POST', + }, + 'publish photo story' + ) + ).json(); + + lastPostId = storyPostId; + } + } + + finalId = lastPostId; + finalUrl = `https://www.facebook.com/stories/${lastPostId}`; + } else if (hasExtension(firstPost?.media?.[0]?.path, 'mp4')) { + const { + id: videoId, + permalink_url, + ...all + } = await ( + await this.fetch( + `https://graph.facebook.com/v20.0/${id}/videos?access_token=${accessToken}&fields=id,permalink_url`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + file_url: firstPost?.media?.[0]?.path!, + description: firstPost.message, + published: true, + }), + }, + 'upload mp4' + ) + ).json(); + + finalUrl = 'https://www.facebook.com/reel/' + videoId; + finalId = videoId; + } else { + const uploadPhotos = !firstPost?.media?.length + ? [] + : await Promise.all( + firstPost.media.map(async (media) => { + const { id: photoId } = await ( + await this.fetch( + `https://graph.facebook.com/v20.0/${id}/photos?access_token=${accessToken}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: media.path, + published: false, + }), + }, + 'upload images slides' + ) + ).json(); + + return { media_fbid: photoId }; + }) + ); + + // Background presets are only valid on text-only posts (no media) and + // Facebook caps them at ~130 chars, so we only attach the preset when it + // can apply. + const presetId = + !uploadPhotos?.length && + firstPost?.settings?.text_format_preset_id && + (firstPost.message?.length || 0) <= FACEBOOK_PRESET_MAX_CHARS + ? firstPost.settings.text_format_preset_id + : undefined; + + const publishFeed = async (withPreset: boolean) => + ( + await this.fetch( + `https://graph.facebook.com/v20.0/${id}/feed?access_token=${accessToken}&fields=id,permalink_url`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...(uploadPhotos?.length + ? { attached_media: uploadPhotos } + : {}), + ...(firstPost?.settings?.url + ? { link: firstPost.settings.url } + : {}), + ...(withPreset && presetId + ? { text_format_preset_id: presetId } + : {}), + message: firstPost.message, + published: true, + }), + }, + 'finalize upload' + ) + ).json(); + + // Facebook exposes no official preset list and adds/retires backgrounds + // over time, so a stale text_format_preset_id can make FB reject the whole + // post. Observed Graph API responses for a bad preset: + // - malformed id -> HTTP 400, code 100, message names + // "text_format_preset_id" explicitly + // - retired numeric id -> HTTP 500, code 1, generic "unknown error" + // (our fetch() retries 500s and then reports it with the body stripped) + // So retry once without the preset on an explicit preset error or a + // generic/unknown failure, but never on a recognized auth/token error + // (dropping the background can't fix that). A retry that only succeeds once + // the preset is removed confirms the preset was the cause. + const isPresetRejection = (err: any): boolean => { + const detail = `${err?.details?.[0]?.json ?? ''} ${err?.message ?? ''}`; + if ( + /access token|re-authenticate|revoked|"code":\s*190\b/i.test(detail) + ) { + return false; + } + return ( + /text_format_preset_id/i.test(detail) || + /"code":\s*1\b/.test(detail) || + String(err?.message) === 'Unknown Error' + ); + }; + + let feedResult: any; + try { + feedResult = await publishFeed(!!presetId); + } catch (err) { + if (!presetId || !isPresetRejection(err)) { + throw err; + } + // Surface the (recovered) rejection in the logs, since the fallback + // below makes the activity succeed and Facebook's error would otherwise + // be swallowed silently. + console.warn( + 'Facebook rejected text_format_preset_id — dropping the background and publishing as plain text', + { + preset: presetId, + facebook: (err as any)?.details?.[0]?.json, + message: (err as any)?.message, + } + ); + feedResult = await publishFeed(false); + } + + const { id: postId, permalink_url, ...all } = feedResult; + + finalUrl = permalink_url; + finalId = postId; + } + + return [ + { + id: firstPost.id, + postId: finalId, + releaseURL: finalUrl, + status: 'success', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + 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', + }, + ]; + } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const until = dayjs().endOf('day').unix(); + const since = dayjs().subtract(date, 'day').unix(); + + // Reach/impression metrics (page_impressions_unique, page_posts_impressions_unique, + // page_video_views) were deprecated by Meta on 2026-06-15 and now return an + // "invalid metric" error. They are replaced by the Media Views metrics, which + // require Graph API v23.0+: + // - page_total_media_view_unique: total unique views on the page's media (reach) + // - page_media_view: total media views, broken down between paid and organic + const { data } = await ( + await fetch( + `https://graph.facebook.com/v23.0/${id}/insights?metric=page_total_media_view_unique,page_media_view,page_post_engagements,page_daily_follows&access_token=${accessToken}&period=day&since=${since}&until=${until}` + ) + ).json(); + + // page_media_view returns paid/organic breakdowns as an object; sum them to + // keep the single-total UI working. + const sumValue = (value: any): number => { + if (value && typeof value === 'object') { + return Object.values(value as Record).reduce( + (sum: number, v: number) => sum + (Number(v) || 0), + 0 + ); + } + return Number(value) || 0; + }; + + return ( + data?.map((d: any) => ({ + label: + d.name === 'page_total_media_view_unique' + ? 'Page Impressions' + : d.name === 'page_post_engagements' + ? 'Posts Engagement' + : d.name === 'page_daily_follows' + ? 'Page followers' + : 'Media views', + percentageChange: 5, + data: d?.values?.map((v: any) => ({ + total: sumValue(v.value), + date: dayjs(v.end_time).format('YYYY-MM-DD'), + })), + })) || [] + ); + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + try { + // Fetch post insights from Facebook Graph API. + // post_impressions_unique was deprecated by Meta on 2026-06-15; it is replaced + // by post_total_media_view_unique (unique media views = reach), available on + // Graph API v23.0+. Engagement metrics below are unaffected. + const { data } = await ( + await fetch( + `https://graph.facebook.com/v23.0/${postId}/insights?metric=post_total_media_view_unique,post_reactions_by_type_total,post_clicks,post_clicks_by_type&access_token=${accessToken}` + ) + ).json(); + + if (!data || data.length === 0) { + return []; + } + + const result: AnalyticsData[] = []; + + for (const metric of data) { + const value = metric.values?.[0]?.value; + if (value === undefined) continue; + + let label = ''; + let total = ''; + + switch (metric.name) { + case 'post_total_media_view_unique': + label = 'Impressions'; + total = String(value); + break; + case 'post_clicks': + label = 'Clicks'; + total = String(value); + break; + case 'post_clicks_by_type': + // This returns an object with click types + if (typeof value === 'object') { + const totalClicks = Object.values( + value as Record + ).reduce((sum: number, v: number) => sum + v, 0); + label = 'Clicks by Type'; + total = String(totalClicks); + } + break; + case 'post_reactions_by_type_total': + // This returns an object with reaction types + if (typeof value === 'object') { + const totalReactions = Object.values( + value as Record + ).reduce((sum: number, v: number) => sum + v, 0); + label = 'Reactions'; + total = String(totalReactions); + } + break; + } + + if (label) { + result.push({ + label, + percentageChange: 0, + data: [{ total, date: today }], + }); + } + } + + return result; + } catch (err) { + console.error('Error fetching Facebook post analytics:', err); + return []; + } + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts b/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..0e237c4015d94a5cf53e3a0340d23fdcb9b6e9b9 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/farcaster.provider.ts @@ -0,0 +1,200 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import dayjs from 'dayjs'; +import { + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { NeynarAPIClient } from '@neynar/nodejs-sdk'; +import { Integration } from '@prisma/client'; +import { FarcasterDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/farcaster.dto'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; + +const client = new NeynarAPIClient({ + apiKey: process.env.NEYNAR_SECRET_KEY || '00000000-000-0000-000-000000000000', +}); + +@Rules( + 'Farcaster/Warpcast can only accept pictures' +) +export class FarcasterProvider + extends SocialAbstract + implements SocialProvider +{ + identifier = 'wrapcast'; + name = 'Farcaster'; + isBetweenSteps = false; + isWeb3 = true; + scopes = [] as string[]; + override maxConcurrentJob = 3; // Farcaster has moderate limits + editor = 'normal' as const; + maxLength() { + return 800; + } + dto = FarcasterDto; + + override async checkValidity( + list: Array + ): Promise { + if ( + list?.some((item) => + item?.some((field) => (field?.path?.indexOf?.('mp4') ?? -1) > -1) + ) + ) { + return 'Can only accept images'; + } + return true; + } + + async refreshToken(refresh_token: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(17); + return { + url: `${process.env.NEYNAR_CLIENT_ID}||${state}` || '', + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const data = JSON.parse(Buffer.from(params.code, 'base64').toString()); + return { + id: String(data.fid), + name: data.display_name, + accessToken: data.signer_uuid, + refreshToken: '', + expiresIn: dayjs().add(200, 'year').unix() - dayjs().unix(), + picture: data?.pfp_url || '', + username: data.username, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const [firstPost] = postDetails; + const ids: { releaseURL: string; postId: string }[] = []; + + const channels = + !firstPost?.settings?.subreddit || + firstPost?.settings?.subreddit.length === 0 + ? [undefined] + : firstPost?.settings?.subreddit; + + for (const channel of channels) { + const data = await client.publishCast({ + embeds: + firstPost?.media?.map((media) => ({ + url: media.path, + })) || [], + signerUuid: accessToken, + text: firstPost.message, + ...(channel?.value?.id ? { channelId: channel?.value?.id } : {}), + }); + + ids.push({ + // @ts-ignore + releaseURL: `https://warpcast.com/${data.cast.author.username}/${data.cast.hash}`, + postId: data.cast.hash, + }); + } + + return [ + { + id: firstPost.id, + postId: ids.map((p) => p.postId).join(','), + releaseURL: ids.map((p) => p.releaseURL).join(','), + status: 'published', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [commentPost] = postDetails; + const ids: { releaseURL: string; postId: string }[] = []; + + // postId can be comma-separated if posted to multiple channels + const parentIds = (lastCommentId || postId).split(','); + + for (const parentHash of parentIds) { + const data = await client.publishCast({ + embeds: + commentPost?.media?.map((media) => ({ + url: media.path, + })) || [], + signerUuid: accessToken, + text: commentPost.message, + parent: parentHash, + }); + + ids.push({ + // @ts-ignore + releaseURL: `https://warpcast.com/${data.cast.author.username}/${data.cast.hash}`, + postId: data.cast.hash, + }); + } + + return [ + { + id: commentPost.id, + postId: ids.map((p) => p.postId).join(','), + releaseURL: ids.map((p) => p.releaseURL).join(','), + status: 'published', + }, + ]; + } + + @Tool({ + description: 'Search channels', + dataSchema: [{ key: 'word', type: 'string', description: 'Search word' }], + }) + async subreddits( + accessToken: string, + data: any, + id: string, + integration: Integration + ) { + const search = await client.searchChannels({ + q: data.word, + limit: 10, + }); + + return search.channels.map((p) => { + return { + title: p.name, + name: p.name, + id: p.id, + }; + }); + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d3bdc6e136c33b022965837071f34a81873ab07 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/gmb.provider.ts @@ -0,0 +1,628 @@ +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { google } from 'googleapis'; +import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client'; +import { + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import * as process from 'node:process'; +import dayjs from 'dayjs'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; +import { GmbSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/gmb.settings.dto'; + +const clientAndGmb = () => { + const client = new google.auth.OAuth2({ + clientId: process.env.GOOGLE_GMB_CLIENT_ID || process.env.YOUTUBE_CLIENT_ID, + clientSecret: + process.env.GOOGLE_GMB_CLIENT_SECRET || process.env.YOUTUBE_CLIENT_SECRET, + redirectUri: `${process.env.FRONTEND_URL}/integrations/social/gmb`, + }); + + const oauth2 = (newClient: OAuth2Client) => + google.oauth2({ + version: 'v2', + auth: newClient, + }); + + return { client, oauth2 }; +}; + +@Rules( + 'Google My Business posts can have text content and optionally one image. Posts can be updates, events, or offers.' +) +export class GmbProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; + identifier = 'gmb'; + name = 'Google My Business'; + isBetweenSteps = true; + scopes = [ + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/business.manage', + ]; + editor = 'normal' as const; + dto = GmbSettingsDto; + + maxLength() { + return 1500; + } + + override async checkValidity( + items: Array, + settings: any + ): Promise { + // GMB posts can have text only, or text with one image + if ((items?.length ?? 0) > 0 && (items?.[0]?.length ?? 0) > 1) { + return 'Google My Business posts can only have one image'; + } + + // Check for video - GMB doesn't support video in local posts + if ((items?.length ?? 0) > 0 && (items?.[0]?.length ?? 0) > 0) { + const media = items?.[0]?.[0]; + if ((media?.path?.indexOf?.('mp4') ?? -1) > -1) { + return 'Google My Business posts do not support video attachments'; + } + } + + // Event posts require a title + if (settings?.topicType === 'EVENT' && !settings?.eventTitle) { + return 'Event posts require an event title'; + } + + return true; + } + + override handleErrors(body: string): + | { + type: 'refresh-token' | 'bad-body'; + value: string; + } + | undefined { + if (body.includes('UNAUTHENTICATED') || body.includes('invalid_grant')) { + return { + type: 'refresh-token', + value: 'Please re-authenticate your Google My Business account', + }; + } + + if (body.includes('Unauthorized')) { + return { + type: 'refresh-token', + value: + 'Token expired or invalid, please reconnect your YouTube account.', + }; + } + + if (body.includes('PERMISSION_DENIED')) { + return { + type: 'refresh-token', + value: + 'Permission denied. Please ensure you have access to this business location.', + }; + } + + if (body.includes('NOT_FOUND')) { + return { + type: 'bad-body', + value: 'Business location not found. It may have been deleted.', + }; + } + + if (body.includes('INVALID_ARGUMENT')) { + return { + type: 'bad-body', + value: 'Invalid post content. Please check your post details.', + }; + } + + if (body.includes('RESOURCE_EXHAUSTED')) { + return { + type: 'bad-body', + value: 'Rate limit exceeded. Please try again later.', + }; + } + + return undefined; + } + + async refreshToken(refresh_token: string): Promise { + const { client, oauth2 } = clientAndGmb(); + client.setCredentials({ refresh_token }); + const { credentials } = await client.refreshAccessToken(); + const user = oauth2(client); + const expiryDate = new Date(credentials.expiry_date!); + const unixTimestamp = + Math.floor(expiryDate.getTime() / 1000) - + Math.floor(new Date().getTime() / 1000); + + const { data } = await user.userinfo.get(); + + return { + accessToken: credentials.access_token!, + expiresIn: unixTimestamp!, + refreshToken: credentials.refresh_token || refresh_token, + id: data.id!, + name: data.name!, + picture: data?.picture || '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(7); + const { client } = clientAndGmb(); + return { + url: client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + state, + redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/gmb`, + scope: this.scopes.slice(0), + }), + codeVerifier: makeId(11), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const { client, oauth2 } = clientAndGmb(); + const { tokens } = await client.getToken(params.code); + client.setCredentials(tokens); + const { scopes } = await client.getTokenInfo(tokens.access_token!); + this.checkScopes(this.scopes, scopes); + + const user = oauth2(client); + const { data } = await user.userinfo.get(); + + const expiryDate = new Date(tokens.expiry_date!); + const unixTimestamp = + Math.floor(expiryDate.getTime() / 1000) - + Math.floor(new Date().getTime() / 1000); + + return { + accessToken: tokens.access_token!, + expiresIn: unixTimestamp, + refreshToken: tokens.refresh_token!, + id: data.id!, + name: data.name!, + picture: data?.picture || '', + username: '', + }; + } + + async pages(accessToken: string) { + // Get all accounts with pagination + const allAccounts: any[] = []; + let accountsPageToken: string | undefined; + + do { + const params = new URLSearchParams(); + if (accountsPageToken) { + params.set('pageToken', accountsPageToken); + } + const url = `https://mybusinessaccountmanagement.googleapis.com/v1/accounts${params.toString() ? `?${params}` : ''}`; + + const accountsResponse = await fetch(url, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + const accountsData = await accountsResponse.json(); + + if (accountsData.accounts) { + allAccounts.push(...accountsData.accounts); + } + accountsPageToken = accountsData.nextPageToken; + } while (accountsPageToken); + + if (allAccounts.length === 0) { + return []; + } + + // Get locations for each account + const allLocations: Array<{ + id: string; + name: string; + picture: { data: { url: string } }; + accountName: string; + locationName: string; + }> = []; + + for (const account of allAccounts) { + const accountName = account.name; // format: accounts/{accountId} + + try { + // Get all locations with pagination + let locationsPageToken: string | undefined; + + do { + const params = new URLSearchParams({ + readMask: 'name,title,storefrontAddress,metadata', + }); + if (locationsPageToken) { + params.set('pageToken', locationsPageToken); + } + + const locationsResponse = await fetch( + `https://mybusinessbusinessinformation.googleapis.com/v1/${accountName}/locations?${params}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + const locationsData = await locationsResponse.json(); + + if (locationsData.locations) { + for (const location of locationsData.locations) { + // location.name is in format: locations/{locationId} + // We need the full path: accounts/{accountId}/locations/{locationId} + const locationId = location.name.replace('locations/', ''); + const fullResourceName = `${accountName}/locations/${locationId}`; + + // Get profile photo if available + let photoUrl = ''; + try { + const mediaResponse = await fetch( + `https://mybusinessbusinessinformation.googleapis.com/v1/${location.name}/media`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + const mediaData = await mediaResponse.json(); + if (mediaData.mediaItems && mediaData.mediaItems.length > 0) { + const profilePhoto = mediaData.mediaItems.find( + (m: any) => + m.mediaFormat === 'PHOTO' && + m.locationAssociation?.category === 'PROFILE' + ); + if (profilePhoto?.googleUrl) { + photoUrl = profilePhoto.googleUrl; + } else if (mediaData.mediaItems[0]?.googleUrl) { + photoUrl = mediaData.mediaItems[0].googleUrl; + } + } + } catch { + // Ignore media fetch errors + } + + allLocations.push({ + // id is the full resource path for the v4 API: accounts/{accountId}/locations/{locationId} + id: fullResourceName, + name: location.title || 'Unnamed Location', + picture: { data: { url: photoUrl } }, + accountName: accountName, + locationName: location.name, + }); + } + } + locationsPageToken = locationsData.nextPageToken; + } while (locationsPageToken); + } catch (error) { + // Continue with other accounts if one fails + console.error( + `Failed to fetch locations for account ${accountName}:`, + error + ); + } + } + + return allLocations; + } + + async fetchPageInformation( + accessToken: string, + data: { id: string; accountName: string; locationName: string } + ) { + // data.id is the full resource path: accounts/{accountId}/locations/{locationId} + // data.locationName is the v1 API format: locations/{locationId} + // Fetch location details using the v1 API format + const locationResponse = await fetch( + `https://mybusinessbusinessinformation.googleapis.com/v1/${data.locationName}?readMask=name,title,storefrontAddress,metadata`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + const locationData = await locationResponse.json(); + + // Try to get profile photo + let photoUrl = ''; + try { + const mediaResponse = await fetch( + `https://mybusinessbusinessinformation.googleapis.com/v1/${data.locationName}/media`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + const mediaData = await mediaResponse.json(); + if (mediaData.mediaItems && mediaData.mediaItems.length > 0) { + const profilePhoto = mediaData.mediaItems.find( + (m: any) => + m.mediaFormat === 'PHOTO' && + m.locationAssociation?.category === 'PROFILE' + ); + if (profilePhoto?.googleUrl) { + photoUrl = profilePhoto.googleUrl; + } else if (mediaData.mediaItems[0]?.googleUrl) { + photoUrl = mediaData.mediaItems[0].googleUrl; + } + } + } catch { + // Ignore media fetch errors + } + + return { + // Return the full resource path as id (for v4 Local Posts API) + id: data.id, + name: locationData.title || 'Unnamed Location', + access_token: accessToken, + picture: photoUrl, + username: '', + }; + } + + async reConnect( + id: string, + requiredId: string, + accessToken: string + ): Promise> { + const pages = await this.pages(accessToken); + const findPage = pages.find((p) => p.id === requiredId); + + if (!findPage) { + throw new Error('Location not found'); + } + + const information = await this.fetchPageInformation(accessToken, { + id: requiredId, + accountName: findPage.accountName, + locationName: findPage.locationName, + }); + + return { + id: information.id, + name: information.name, + accessToken: information.access_token, + picture: information.picture, + username: information.username, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const [firstPost] = postDetails; + const { settings } = firstPost; + + // Build the local post request body + const postBody: any = { + languageCode: 'en', + summary: firstPost.message, + topicType: settings?.topicType || 'STANDARD', + }; + + // Add call to action if provided (and not NONE) + if ( + settings?.callToActionType && + settings.callToActionType !== 'NONE' && + settings?.callToActionUrl + ) { + postBody.callToAction = { + actionType: settings.callToActionType, + url: settings.callToActionUrl, + }; + } + + // Add media if provided + if (firstPost.media && firstPost.media.length > 0) { + const mediaItem = firstPost.media[0]; + postBody.media = [ + { + mediaFormat: mediaItem.type === 'video' ? 'VIDEO' : 'PHOTO', + sourceUrl: mediaItem.path, + }, + ]; + } + + // Add event details if it's an event post + if (settings?.topicType === 'EVENT' && settings?.eventTitle) { + postBody.event = { + title: settings.eventTitle, + schedule: { + startDate: this.formatDate(settings.eventStartDate), + endDate: this.formatDate(settings.eventEndDate), + ...(settings.eventStartTime && { + startTime: this.formatTime(settings.eventStartTime), + }), + ...(settings.eventEndTime && { + endTime: this.formatTime(settings.eventEndTime), + }), + }, + }; + } + + // Add offer details if it's an offer post + if (settings?.topicType === 'OFFER') { + postBody.offer = { + couponCode: settings?.offerCouponCode || undefined, + redeemOnlineUrl: settings?.offerRedeemUrl || undefined, + termsConditions: settings?.offerTerms || undefined, + }; + } + + // Create the local post + const response = await this.fetch( + `https://mybusiness.googleapis.com/v4/${id}/localPosts`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(postBody), + }, + 'create local post' + ); + + const postData = await response.json(); + + // Extract the post ID and construct the URL + const postId = postData.name || ''; + const locationId = id.split('/').pop(); + + // GMB posts don't have direct URLs, but we can link to the business profile + const releaseURL = `https://business.google.com/locations/${locationId}`; + + return [ + { + id: firstPost.id, + postId: postId, + releaseURL: releaseURL, + status: 'success', + }, + ]; + } + + private formatDate(dateString?: string): any { + if (!dateString) { + return { + year: dayjs().year(), + month: dayjs().month() + 1, + day: dayjs().date(), + }; + } + const date = dayjs(dateString); + return { + year: date.year(), + month: date.month() + 1, + day: date.date(), + }; + } + + private formatTime(timeString?: string): any { + if (!timeString) { + return undefined; + } + const [hours, minutes] = timeString.split(':').map(Number); + return { + hours: hours || 0, + minutes: minutes || 0, + seconds: 0, + nanos: 0, + }; + } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + try { + const endDate = dayjs().format('YYYY-MM-DD'); + const startDate = dayjs().subtract(date, 'day').format('YYYY-MM-DD'); + + // id is in format: accounts/{accountId}/locations/{locationId} + // Business Profile Performance API expects: locations/{locationId} + const locationId = id.split('/locations/')[1]; + const locationPath = `locations/${locationId}`; + + // Use the Business Profile Performance API + const response = await fetch( + `https://businessprofileperformance.googleapis.com/v1/${locationPath}:fetchMultiDailyMetricsTimeSeries?dailyMetrics=WEBSITE_CLICKS&dailyMetrics=CALL_CLICKS&dailyMetrics=BUSINESS_DIRECTION_REQUESTS&dailyMetrics=BUSINESS_IMPRESSIONS_DESKTOP_MAPS&dailyMetrics=BUSINESS_IMPRESSIONS_MOBILE_MAPS&dailyRange.startDate.year=${dayjs( + startDate + ).year()}&dailyRange.startDate.month=${ + dayjs(startDate).month() + 1 + }&dailyRange.startDate.day=${dayjs( + startDate + ).date()}&dailyRange.endDate.year=${dayjs( + endDate + ).year()}&dailyRange.endDate.month=${ + dayjs(endDate).month() + 1 + }&dailyRange.endDate.day=${dayjs(endDate).date()}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + const data = await response.json(); + + // Response structure: { multiDailyMetricTimeSeries: [{ dailyMetricTimeSeries: [...] }] } + const dailyMetricTimeSeries = + data.multiDailyMetricTimeSeries?.[0]?.dailyMetricTimeSeries; + + if (!dailyMetricTimeSeries || dailyMetricTimeSeries.length === 0) { + return []; + } + + const metricLabels: { [key: string]: string } = { + WEBSITE_CLICKS: 'Website Clicks', + CALL_CLICKS: 'Phone Calls', + BUSINESS_DIRECTION_REQUESTS: 'Direction Requests', + BUSINESS_IMPRESSIONS_DESKTOP_MAPS: 'Desktop Map Views', + BUSINESS_IMPRESSIONS_MOBILE_MAPS: 'Mobile Map Views', + }; + + const analytics: AnalyticsData[] = []; + + for (const series of dailyMetricTimeSeries) { + const metricName = series.dailyMetric; + const label = metricLabels[metricName] || metricName; + + const datedValues = series.timeSeries?.datedValues || []; + + const dataPoints = datedValues.map((dv: any) => ({ + total: parseInt(dv.value || '0', 10), + date: `${dv.date.year}-${String(dv.date.month).padStart( + 2, + '0' + )}-${String(dv.date.day).padStart(2, '0')}`, + })); + + if (dataPoints.length > 0) { + analytics.push({ + label, + percentageChange: 0, + data: dataPoints, + }); + } + } + + return analytics; + } catch (error) { + console.error('Error fetching GMB analytics:', error); + return []; + } + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + // Google My Business local posts don't have detailed individual post analytics + // The API focuses on location-level metrics rather than post-level metrics + return []; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..7eeadef06b28c8a12f07a65fc81e5f8c4ec8107c --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/hashnode.provider.ts @@ -0,0 +1,230 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { tags } from '@gitroom/nestjs-libraries/integrations/social/hashnode.tags'; +import { jsonToGraphQLQuery } from 'json-to-graphql-query'; +import { HashnodeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/hashnode.settings.dto'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +export class HashnodeProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; // Hashnode has lenient publishing limits + identifier = 'hashnode'; + name = 'Hashnode'; + isBetweenSteps = false; + scopes = [] as string[]; + editor = 'markdown' as const; + maxLength() { + return 10000; + } + dto = HashnodeSettingsDto; + + async generateAuthUrl() { + const state = makeId(6); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async customFields() { + return [ + { + key: 'apiKey', + label: 'API key', + validation: `/^.{3,}$/`, + type: 'password' as const, + }, + ]; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body = JSON.parse(Buffer.from(params.code, 'base64').toString()); + try { + const { + data: { + me: { name, id, profilePicture, username }, + }, + } = await ( + await fetch('https://gql.hashnode.com', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${body.apiKey}`, + }, + body: JSON.stringify({ + query: ` + query { + me { + name, + id, + profilePicture + username + } + } + `, + }), + }) + ).json(); + + return { + refreshToken: '', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + accessToken: body.apiKey, + id, + name, + picture: profilePicture || '', + username, + }; + } catch (err) { + return 'Invalid credentials'; + } + } + + async tags() { + return tags.map((tag) => ({ value: tag.objectID, label: tag.name })); + } + + @Tool({ description: 'Tags', dataSchema: [] }) + tagsList() { + return tags; + } + + @Tool({ description: 'Publications', dataSchema: [] }) + async publications(accessToken: string) { + const { + data: { + me: { + publications: { edges }, + }, + }, + } = await ( + await fetch('https://gql.hashnode.com', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${accessToken}`, + }, + body: JSON.stringify({ + query: ` + query { + me { + publications (first: 50) { + edges{ + node { + id + title + } + } + } + } + } + `, + }), + }) + ).json(); + + return edges.map( + ({ node: { id, title } }: { node: { id: string; title: string } }) => ({ + id, + name: title, + }) + ); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const { settings } = postDetails?.[0] || { settings: {} }; + const query = jsonToGraphQLQuery( + { + mutation: { + publishPost: { + __args: { + input: { + title: settings.title, + publicationId: settings.publication, + ...(settings.canonical + ? { originalArticleURL: settings.canonical } + : {}), + contentMarkdown: postDetails?.[0].message, + tags: settings.tags.map((tag: any) => ({ id: tag.value })), + ...(settings.subtitle ? { subtitle: settings.subtitle } : {}), + ...(settings.main_image + ? { + coverImageOptions: { + coverImageURL: `${ + settings?.main_image?.path?.indexOf('http') === -1 + ? `${process.env.NEXT_PUBLIC_BACKEND_URL}/${process.env.NEXT_PUBLIC_UPLOAD_STATIC_DIRECTORY}` + : `` + }${settings?.main_image?.path}`, + }, + } + : {}), + }, + }, + post: { + id: true, + url: true, + }, + }, + }, + }, + { pretty: true } + ); + + const { + data: { + publishPost: { + post: { id: postId, url }, + }, + }, + } = await ( + await this.fetch('https://gql.hashnode.com', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `${accessToken}`, + }, + body: JSON.stringify({ + query, + }), + }) + ).json(); + + return [ + { + id: postDetails?.[0].id, + status: 'completed', + postId: postId, + releaseURL: url, + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/hashnode.tags.ts b/libraries/nestjs-libraries/src/integrations/social/hashnode.tags.ts new file mode 100644 index 0000000000000000000000000000000000000000..35d62a272ce62aad46f735dd8301421892f63abd --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/hashnode.tags.ts @@ -0,0 +1,5194 @@ +export const tags = [ + { + name: 'JavaScript', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513320898547/BJjpblWfG.png', + slug: 'javascript', + objectID: '56744721958ef13879b94cad', + }, + { + name: 'General Programming', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1535648192079/H1daWiBvQ.png', + slug: 'programming', + objectID: '56744721958ef13879b94c7e', + }, + { + name: 'React', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513321478077/ByCWNxZMf.png', + slug: 'reactjs', + objectID: '56744723958ef13879b95434', + }, + { + name: 'Web Development', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450469658/vdxecajl3uwbprclsctm.jpg', + slug: 'web-development', + objectID: '56744722958ef13879b94f1b', + }, + { + name: 'Python', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512408213/rJeQpSNIX.png', + slug: 'python', + objectID: '56744721958ef13879b94d67', + }, + { + name: 'Node.js', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513321388034/SJV3QgWfz.png', + slug: 'nodejs', + objectID: '56744722958ef13879b94ffb', + }, + { + name: 'CSS', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513316949083/By6UMkbfG.png', + slug: 'css', + objectID: '56744721958ef13879b94b91', + }, + { + name: 'beginners', + slug: 'beginners', + objectID: '56744723958ef13879b955a9', + }, + { + name: 'Java', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512378322/H1gM-pH4UQ.png', + slug: 'java', + objectID: '56744721958ef13879b94c9f', + }, + { + name: 'Developer', + slug: 'developer', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1554321431158/MqVqSHr8Q.jpeg', + objectID: '56744723958ef13879b952d7', + }, + { + name: 'HTML5', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513322217442/SkZlDeWzz.png', + slug: 'html5', + objectID: '56744723958ef13879b95483', + }, + { + name: '2Articles1Week', + slug: '2articles1week', + logo: '', + objectID: '5f058ab0c9763d47e2d2eedc', + }, + { + name: 'learning', + slug: 'learning', + objectID: '56744723958ef13879b9532b', + }, + { + name: 'PHP', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513177307594/rJ4Jba0-G.png', + slug: 'php', + objectID: '56744722958ef13879b94fd9', + }, + { + name: 'AWS', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450468151/vmrnzobr1lonnigttn3c.png', + slug: 'aws', + objectID: '56744721958ef13879b94bc5', + }, + { + name: 'Tutorial', + slug: 'tutorial', + objectID: '56744720958ef13879b947ce', + }, + { + name: 'programming blogs', + slug: 'programming-blogs', + objectID: '56744721958ef13879b94ae7', + }, + { + name: 'coding', + slug: 'coding', + objectID: '56744723958ef13879b954c1', + }, + { + name: 'Go Language', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512687168/S1D40rVLm.png', + slug: 'go', + objectID: '56744721958ef13879b94bd0', + }, + { + name: 'Frontend Development', + slug: 'frontend-development', + objectID: '56a399f292921b8f79d3633c', + }, + { + name: 'GitHub', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513321555902/BkhLElZMG.png', + slug: 'github', + objectID: '56744721958ef13879b94c63', + }, + { + name: 'Hashnode', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1619605440273/S3_X4Rf7V.jpeg', + slug: 'hashnode', + objectID: '567ae5a72b926c3063c3061a', + }, + { + name: 'Python 3', + slug: 'python3', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1503468096/axvqxfbcm0b7ourhshj7.jpg', + objectID: '56744723958ef13879b95342', + }, + { + name: 'Codenewbies', + slug: 'codenewbies', + objectID: '5f22b52283e4e9440619af83', + }, + { + name: 'webdev', + slug: 'webdev', + objectID: '56744723958ef13879b952af', + }, + { + name: 'Machine Learning', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513321644252/Sk43El-fz.png', + slug: 'machine-learning', + objectID: '56744722958ef13879b950a8', + }, + { + name: 'General Advice', + slug: 'general-advice', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1516183731966/B13heohVM.jpeg', + objectID: '56fe3b2e7a82968f9f7d51c1', + }, + { + name: 'software development', + slug: 'software-development', + objectID: '56744721958ef13879b94ad1', + }, + { + name: 'CSS3', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513316988840/r1Htz1Wzz.png', + slug: 'css3', + objectID: '56744721958ef13879b94b21', + }, + { + name: 'Android', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450468271/qbj34hxd8981nfdugyph.png', + slug: 'android', + objectID: '56744723958ef13879b953d0', + }, + { + name: 'Productivity', + slug: 'productivity', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1497250361/v3sij4jc8hz9xoic22eq.png', + objectID: '56744721958ef13879b94a60', + }, + { + name: 'React Native', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1475235386/rkij45wit50lfpkbte5q.jpg', + slug: 'react-native', + objectID: '56744722958ef13879b94f4d', + }, + { + name: '100DaysOfCode', + slug: '100daysofcode', + objectID: '576ab68f152618ad1dc938ad', + }, + { + name: 'Design', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513324674454/r1qtxW-zf.png', + slug: 'design', + objectID: '56744722958ef13879b94e89', + }, + { + name: 'Devops', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496913014/cnvm0znfqcrwelhgtblb.png', + slug: 'devops', + objectID: '56744723958ef13879b9550d', + }, + { + name: 'Open Source', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496913431/hdg1q4zbmobhrq0csomm.png', + slug: 'opensource', + objectID: '56744722958ef13879b94f32', + }, + { + name: 'Git', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1473706112/l2hom2y5xxpgwlgg0sz0.jpg', + slug: 'git', + objectID: '56744723958ef13879b9526c', + }, + { + name: 'HTML', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513322147587/Hk2jIxZGG.png', + slug: 'html', + objectID: '56744722958ef13879b94f96', + }, + { + name: 'data science', + slug: 'data-science', + objectID: '56744721958ef13879b94e35', + }, + { + name: 'Testing', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450619295/xszq3zb8t6rmgg6regon.png', + slug: 'testing', + objectID: '56744723958ef13879b9549b', + }, + { + name: 'Linux', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450641462/ogpsvoxw5kt8aksuiptj.png', + slug: 'linux', + objectID: '56744721958ef13879b94b55', + }, + { + name: 'Security', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1472744837/bnzk4gvspiy66dsmw9ku.png', + slug: 'security', + objectID: '56744722958ef13879b94fb7', + }, + { + name: 'Laravel', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1454754733/exjubzyuvwz0pvvpxxwv.jpg', + slug: 'laravel', + objectID: '56744721958ef13879b94a83', + }, + { + name: 'TypeScript', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1470054384/fuy3ypcjuj4cwdz4qpxn.jpg', + slug: 'typescript', + objectID: '56744723958ef13879b954e0', + }, + { + name: 'APIs', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450468334/jirjz7cc54l2mstzpaab.png', + slug: 'apis', + objectID: '56744723958ef13879b95245', + }, + { + name: 'Ruby', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512722989/BksL0SELm.png', + slug: 'ruby', + objectID: '56744721958ef13879b94c0a', + }, + { + name: 'Vue.js', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1505294440/t5igqu22z1s86xa7nkqi.png', + slug: 'vuejs', + objectID: '56744722958ef13879b950e4', + }, + { + name: 'technology', + slug: 'technology', + objectID: '56744721958ef13879b94d26', + }, + { + name: 'Docker', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1453789075/ryxk99vk41tdn8bo28m4.png', + slug: 'docker', + objectID: '56744721958ef13879b94b77', + }, + { + name: 'programming languages', + slug: 'programming-languages', + objectID: '579a67e2cec33eafc07249c7', + }, + { + name: 'Programming Tips', + slug: 'programming-tips', + objectID: '5f398753c4d5973f55c912fb', + }, + { + name: 'Cloud', + slug: 'cloud', + objectID: '56744721958ef13879b94938', + }, + { + name: 'Blogging', + slug: 'blogging', + objectID: '56744721958ef13879b949aa', + }, + { + name: 'newbie', + slug: 'newbie', + objectID: '56744720958ef13879b947e8', + }, + { + name: 'Career', + slug: 'career', + objectID: '56aa13e5f28f9d9d99e3a5de', + }, + { + name: 'Swift', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512662717/Sy1XAHNLQ.png', + slug: 'swift', + objectID: '56744722958ef13879b94ead', + }, + { + name: 'Flutter Community', + slug: 'flutter', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1560841840250/KhofPXnAk.jpeg', + objectID: '56744722958ef13879b9507c', + }, + { + name: 'python beginner', + slug: 'python-beginner', + objectID: '5f3867d1c4d5973f55c90b8b', + }, + { + name: 'Software Engineering', + slug: 'software-engineering', + objectID: '569d22c892921b8f79d35f68', + }, + { + name: 'learn coding', + slug: 'learn-coding', + objectID: '5f3f40bfdfbb4247f7c14d4c', + }, + { + name: 'MongoDB', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450467711/awgzya1xei3pgch5b8xu.png', + slug: 'mongodb', + objectID: '56744722958ef13879b94f6f', + }, + { + name: 'iOS', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450468231/t4x2aoglmhhz9yw3ezry.png', + slug: 'ios', + objectID: '56744722958ef13879b94f11', + }, + { + name: 'algorithms', + slug: 'algorithms', + objectID: '56744721958ef13879b94a8d', + }, + { + name: 'Web Design', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450622407/deczahnypldw1ftbdxog.png', + slug: 'web-design', + objectID: '56744721958ef13879b94d32', + }, + { + name: 'Databases', + slug: 'databases', + objectID: '56744722958ef13879b950eb', + }, + { + name: 'ES6', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512767931/S1dYCSNIm.png', + slug: 'es6', + objectID: '56744723958ef13879b954cb', + }, + { + name: 'Learning Journey', + slug: 'learning-journey', + objectID: '5f9435c7fbdce372c9a56fb6', + }, + { + name: 'Blockchain', + slug: 'blockchain', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1540281064342/rkle7U3sQ.png', + objectID: '5690224191716a2d1dbadbc1', + }, + { + name: 'data structures', + slug: 'data-structures', + objectID: '56744722958ef13879b951bb', + }, + { + name: 'Redux', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513322046756/HyPSUgWMG.png', + slug: 'redux', + objectID: '56744723958ef13879b95567', + }, + { + name: 'backend', + slug: 'backend', + objectID: '56744722958ef13879b950bd', + }, + { + name: 'C#', + slug: 'csharp', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512595400/HkoATH48Q.png', + objectID: '56744721958ef13879b94a30', + }, + { + name: 'Startups', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1459504275/iksgbnwvscz6zjzk5nhe.jpg', + slug: 'startups', + objectID: '56744721958ef13879b94b5b', + }, + { + name: 'Django', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1475235489/g7q2vh5igqcxo8jlfwl9.jpg', + slug: 'django', + objectID: '56744722958ef13879b94e81', + }, + { + name: 'UX', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1474023086/dnrwfr6sxylhx60mp26j.png', + slug: 'ux', + objectID: '56744722958ef13879b94e9d', + }, + { + name: 'interview', + slug: 'interview', + objectID: '56744720958ef13879b947e1', + }, + { + name: 'Visual Studio Code', + slug: 'vscode', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1497045716/r3myqwr6m8olahqaxl5x.png', + objectID: '57323a8bae9d49b5a5a5b39c', + }, + { + name: 'internships', + slug: 'internships', + objectID: '56744720958ef13879b94811', + }, + { + name: 'Next.js', + slug: 'nextjs', + objectID: '584879f0c0aaf085e2012086', + }, + { + name: 'Kubernetes', + slug: 'kubernetes', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1554318943530/J-r4NJeEi.png', + objectID: '56744723958ef13879b9522c', + }, + { + name: 'Computer Science', + slug: 'computer-science', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1514959838703/BkDJVxqQM.jpeg', + objectID: '56744722958ef13879b9512b', + }, + { + name: 'REST API', + slug: 'rest-api', + objectID: '56b1208d04f0061506b360ff', + }, + { + name: 'business', + slug: 'business', + objectID: '56744723958ef13879b952a1', + }, + { + name: 'automation', + slug: 'automation', + objectID: '56744723958ef13879b9535d', + }, + { + name: 'Kotlin', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1458728299/fuo7n9epkkxyafihrlhz.jpg', + slug: 'kotlin', + objectID: '56c2f39e850906a7da47cdeb', + }, + { + name: 'Google', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450469897/djpesw0ajrbxvmlyoezx.png', + slug: 'google', + objectID: '56744723958ef13879b95470', + }, + { + name: 'app development', + slug: 'app-development', + objectID: '56744720958ef13879b947c4', + }, + { + name: 'Azure', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1524473475544/B1ntAzsnM.jpeg', + slug: 'azure', + objectID: '56744721958ef13879b94d89', + }, + { + name: 'Game Development', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1473275923/lhhroyopcm9gpfvqxe44.jpg', + slug: 'game-development', + objectID: '56744723958ef13879b953f2', + }, + { + name: 'C++', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512626199/BkcgCSNUm.png', + slug: 'cpp', + objectID: '56744721958ef13879b948b7', + }, + { + name: 'js', + slug: 'js', + objectID: '56744721958ef13879b94bf5', + }, + { + name: 'UI', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1487144606/jy8ee18buuag2zbsbqai.png', + slug: 'ui', + objectID: '56744723958ef13879b954f5', + }, + { + name: 'Mobile Development', + slug: 'mobile-development', + objectID: '568a9b8ce4c4e23aef243c1f', + }, + { + name: 'Cloud Computing', + slug: 'cloud-computing', + objectID: '56744723958ef13879b9533a', + }, + { + name: 'frontend', + slug: 'frontend', + objectID: '56744721958ef13879b94d0f', + }, + { + name: 'Artificial Intelligence', + slug: 'artificial-intelligence', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496737518/sgflljcm3hidlvipsriq.png', + objectID: '56744721958ef13879b94927', + }, + { + name: 'npm', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1460372304/ovff2sszokeskrwdfjjv.png', + slug: 'npm', + objectID: '56744723958ef13879b95322', + }, + { + name: 'development', + slug: 'development', + objectID: '56744721958ef13879b94d9b', + }, + { + name: 'Ruby on Rails', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1475235552/twnpcxcm29mub2gez4yf.jpg', + slug: 'ruby-on-rails', + objectID: '56744722958ef13879b94ff1', + }, + { + name: 'WordPress', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450732925/vndlqh4zwgqoy6kbcs0j.jpg', + slug: 'wordpress', + objectID: '56744721958ef13879b94beb', + }, + { + name: 'tips', + slug: 'tips', + objectID: '56744723958ef13879b95319', + }, + { + name: 'javascript framework', + slug: 'javascript-framework', + objectID: '56744723958ef13879b95527', + }, + { + name: 'Technical writing ', + slug: 'technical-writing-1', + objectID: '5f3330322a23d9080d17a0da', + }, + { + name: 'Express', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513936680781/HJbEP8qzM.png', + slug: 'express', + objectID: '56744721958ef13879b9487d', + }, + { + name: 'serverless', + slug: 'serverless', + objectID: '57979f8dcec33eafc07247a2', + }, + { + name: 'Angular', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450469536/svgqrg8jtoqihqdffiai.jpg', + slug: 'angular', + objectID: '56744722958ef13879b94f59', + }, + { + name: 'DevLife', + slug: 'devlife', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1516175805632/HkL6WK3Ez.jpeg', + objectID: '592fe1bf8515388d7dfc2650', + }, + { + name: 'Functional Programming', + slug: 'functional-programming', + objectID: '568f5c6beea132481d017c36', + }, + { + name: 'programmer', + slug: 'programmer', + objectID: '568409636b179c61d167f05d', + }, + { + name: 'python projects', + slug: 'python-projects', + objectID: '5f76046e37eb052c1b80da9f', + }, + { + name: 'MySQL', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496912606/hclufcmqr2btz24a6egj.png', + slug: 'mysql', + objectID: '56744721958ef13879b94dff', + }, + { + name: 'Dart', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450601337/v7ng3klyzehzxtbjoym9.png', + slug: 'dart', + objectID: '56744721958ef13879b94df0', + }, + { + name: 'Firebase', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1464240463/xo1rbiqimh25bmlgwb3g.jpg', + slug: 'firebase', + objectID: '56744722958ef13879b94e99', + }, + { + name: 'Windows', + slug: 'windows', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1554134710664/jnLVaVy-N.png', + objectID: '56744723958ef13879b953f7', + }, + { + name: 'code', + slug: 'code', + objectID: '56744721958ef13879b94982', + }, + { + name: 'GraphQL', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1475235506/qbofja8kwx8cw8nuyaqg.jpg', + slug: 'graphql', + objectID: '56744723958ef13879b9555c', + }, + { + name: 'SEO', + slug: 'seo', + objectID: '56744722958ef13879b9519c', + }, + { + name: 'ReactHooks', + slug: 'reacthooks', + objectID: '5f8523be6ad92638db4944a9', + }, + { + name: '#beginners #learningtocode #100daysofcode', + slug: 'beginners-learningtocode-100daysofcode', + objectID: '5f789ec19c3b6e410121699a', + }, + { + name: 'hacking', + slug: 'hacking', + objectID: '56744723958ef13879b9553a', + }, + { + name: 'Cryptocurrency', + slug: 'cryptocurrency', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1512988849862/SJ5heynZG.png', + objectID: '58e4c1144d64a3de3e94b31b', + }, + { + name: 'SQL', + slug: 'sql', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1501338352/cv7owxtxvr39rjzxoolr.png', + objectID: '56744723958ef13879b953ed', + }, + { + name: 'hashnodebootcamp', + slug: 'hashnodebootcamp', + objectID: '5f75f322b7a1d82bf9b34c6d', + }, + { + name: 'Tailwind CSS', + slug: 'tailwind-css', + objectID: '5f4ebbb150b5c61ec6ef4ad2', + }, + { + name: 'webpack', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1457865805/st9hz4f5ufmpxhizmfpk.jpg', + slug: 'webpack', + objectID: '56744722958ef13879b95055', + }, + { + name: 'learn', + slug: 'learn', + objectID: '56a2235672ca04ea5d7a00c2', + }, + { + name: 'first post', + slug: 'first-post-1', + objectID: '5f08ee681981c53c4987f2b3', + }, + { + name: 'design patterns', + slug: 'design-patterns', + objectID: '56744721958ef13879b94968', + }, + { + name: 'ai', + slug: 'ai', + objectID: '56744721958ef13879b9488e', + }, + { + name: 'Microservices', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1479724330/cpcqfxm9af8d8esgo8wp.jpg', + slug: 'microservices', + objectID: '56744721958ef13879b948a2', + }, + { + name: 'data analysis', + slug: 'data-analysis', + objectID: '56744722958ef13879b951ac', + }, + { + name: 'best practices', + slug: 'best-practices', + objectID: '56744723958ef13879b95598', + }, + { + name: 'beginner', + slug: 'beginner', + objectID: '56744723958ef13879b952b6', + }, + { + name: 'Deep Learning', + slug: 'deep-learning', + objectID: '578f611523e94ba91a5bebd8', + }, + { + name: 'Ubuntu', + slug: 'ubuntu', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496151690/x8g04hsjiekjgkkuhrk7.png', + objectID: '56744721958ef13879b94988', + }, + { + name: 'C', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1475235467/zfbdpx1pe00glfy6lc6b.jpg', + slug: 'c', + objectID: '56744721958ef13879b9492c', + }, + { + name: 'MERN Stack', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512793459/Hk-s0B4Im.png', + slug: 'mern', + objectID: '56c32d8c316f8ee15e9e0fde', + }, + { + name: 'education', + slug: 'education', + objectID: '56b631c8e6740d0959b6f3ef', + }, + { + name: 'authentication', + slug: 'authentication', + objectID: '56744721958ef13879b94b00', + }, + { + name: 'community', + slug: 'community', + objectID: '56744722958ef13879b9514c', + }, + { + name: 'marketing', + slug: 'marketing', + objectID: '57449fa89ade925885158d1e', + }, + { + name: 'Hello World', + slug: 'hello-world', + objectID: '591d0f67b5bbb96606f07af4', + }, + { + name: 'tools', + slug: 'tools', + objectID: '56744721958ef13879b94e0c', + }, + { + name: 'ecommerce', + slug: 'ecommerce', + objectID: '56744722958ef13879b95041', + }, + { + name: 'news', + slug: 'news', + objectID: '56744721958ef13879b9493e', + }, + { + name: 'Microsoft', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450622053/ioqfwklxmzqwwy7jrxmj.png', + slug: 'microsoft', + objectID: '56744721958ef13879b94d1d', + }, + { + name: 'jQuery', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450815745/cd8sl0j2hkeuoq2isuc3.png', + slug: 'jquery', + objectID: '56744721958ef13879b94c2b', + }, + { + name: 'Javascript library', + slug: 'javascript-library', + objectID: '568fa207525da8063d08fb68', + }, + { + name: 'data', + slug: 'data', + objectID: '56744721958ef13879b949d3', + }, + { + name: 'clean code', + slug: 'clean-code', + objectID: '573504d39835efadc8742016', + }, + { + name: 'web', + slug: 'web', + objectID: '56744722958ef13879b94f40', + }, + { + name: 'programing', + slug: 'programing', + objectID: '56ab1a78f28f9d9d99e3a6d1', + }, + { + name: 'tech ', + slug: 'tech', + objectID: '5677de7c7dd5d4174dcc2073', + }, + { + name: 'Mobile apps', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450619495/qiwhbnoxoas2b5dtb6cx.png', + slug: 'mobile-apps', + objectID: '56744721958ef13879b94c5b', + }, + { + name: 'performance', + slug: 'performance', + objectID: '56744721958ef13879b94dc4', + }, + { + name: 'UI Design', + slug: 'ui-design', + objectID: '5682df44aeae5c9e229cf9f9', + }, + { + name: 'PostgreSQL', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1460706552/iwl62ldvrzgf4k9rhame.jpg', + slug: 'postgresql', + objectID: '56744721958ef13879b949b5', + }, + { + name: 'Rust', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512703511/HJDSCr4UQ.png', + slug: 'rust', + objectID: '5684bee6bf03be7d4a9ed853', + }, + { + name: 'motivation', + slug: 'motivation', + objectID: '56b0ba4604f0061506b35fae', + }, + { + name: 'software architecture', + slug: 'software-architecture', + objectID: '56744722958ef13879b950c9', + }, + { + name: 'introduction', + slug: 'introduction', + objectID: '56744721958ef13879b948cc', + }, + { + name: 'Bootstrap', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450470158/wpi0t8fj9kr8on9v6jmd.jpg', + slug: 'bootstrap', + objectID: '56744721958ef13879b94be1', + }, + { + name: 'networking', + slug: 'networking', + objectID: '56ffbb5d5861692778050361', + }, + { + name: 'blog', + slug: 'blog', + objectID: '56744721958ef13879b948ac', + }, + { + name: 'jobs', + slug: 'jobs', + objectID: '56a77939281161e11972fdd7', + }, + { + name: 'terminal', + slug: 'terminal', + objectID: '56744721958ef13879b94da6', + }, + { + name: 'command line', + slug: 'command-line', + objectID: '56744723958ef13879b9539a', + }, + { + name: 'website', + slug: 'website', + objectID: '5674471d958ef13879b94785', + }, + { + name: 'Developer Tools', + slug: 'developer-tools', + objectID: '57ebac0bd9b08ec06a77be05', + }, + { + name: 'aws lambda', + slug: 'aws-lambda', + objectID: '57c7ea36e53060955aa8c0c0', + }, + { + name: 'Ethereum', + slug: 'ethereum', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1512988945062/HJKfZJhWf.png', + objectID: '58e4c1144d64a3de3e94b31d', + }, + { + name: 'System Architecture', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496913335/duppaieikvyvepmoj6uz.png', + slug: 'system-architecture', + objectID: '56744723958ef13879b955b0', + }, + { + name: '#cybersecurity', + slug: 'cybersecurity-1', + objectID: '5f2e70c0b8ac395b1f23a6cb', + }, + { + name: 'linux for beginners', + slug: 'linux-for-beginners', + objectID: '5fa5022a3e634314b5179cf5', + }, + { + name: 'Games', + slug: 'games', + objectID: '578f6a105460288cdeb6f2ab', + }, + { + name: 'developers', + slug: 'developers', + objectID: '56744722958ef13879b94f05', + }, + { + name: 'internet', + slug: 'internet', + objectID: '56f260f15ec781bb472f83af', + }, + { + name: 'android app development', + slug: 'android-app-development', + objectID: '56744721958ef13879b94890', + }, + { + name: 'full stack', + slug: 'full-stack', + objectID: '56744723958ef13879b95387', + }, + { + name: 'server', + slug: 'server', + objectID: '56744721958ef13879b94e17', + }, + { + name: 'projects', + slug: 'projects', + objectID: '56744722958ef13879b95074', + }, + { + name: 'macOS', + slug: 'macos', + objectID: '576a1d6e13cc2eb2d90e2383', + }, + { + name: 'project management', + slug: 'project-management', + objectID: '569d22af46dfdb8479aa6921', + }, + { + name: 'writing', + slug: 'writing', + objectID: '5674471d958ef13879b9477e', + }, + { + name: 'Flutter Examples', + slug: 'flutter-examples', + objectID: '5f08f6a1b0bf5b3c273ea78b', + }, + { + name: 'guide', + slug: 'guide', + objectID: '56744723958ef13879b955a7', + }, + { + name: 'deployment', + slug: 'deployment', + objectID: '56744721958ef13879b94dad', + }, + { + name: 'array', + slug: 'array', + objectID: '578e290c5460288cdeb6f187', + }, + { + name: 'Bash', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1464705930/i6dyhkbiwqezfwbsq4c2.jpg', + slug: 'bash', + objectID: '56744722958ef13879b95119', + }, + { + name: 'Bitcoin', + slug: 'bitcoin', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1512988974934/rJwNWJhZz.png', + objectID: '5697e90f46dfdb8479aa6708', + }, + { + name: 'Google Chrome', + slug: 'chrome', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1502183259/uhcfgovkcf3pm66xjsl0.png', + objectID: '56744722958ef13879b94f68', + }, + { + name: '.NET', + slug: 'net', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1515075179602/rkEdLho7G.jpeg', + objectID: '56744723958ef13879b9556e', + }, + { + name: 'dotnet', + slug: 'dotnet', + objectID: '5794f65abecb9ebac0d5fc55', + }, + { + name: 'life', + slug: 'life', + objectID: '57bc257693309a25047c5e43', + }, + { + name: 'Twitter', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1464240092/ysal5yejuviop7p7bvbl.png', + slug: 'twitter', + objectID: '56744721958ef13879b949ad', + }, + { + name: 'Object Oriented Programming', + slug: 'object-oriented-programming', + objectID: '591e9732ab184fdc3bcd9185', + }, + { + name: 'iot', + slug: 'iot', + objectID: '56744723958ef13879b9532f', + }, + { + name: 'json', + slug: 'json', + objectID: '56744721958ef13879b94dec', + }, + { + name: 'api', + slug: 'api', + objectID: '56744721958ef13879b94c20', + }, + { + name: 'Express.js', + slug: 'expressjs-cilb5apda0066e053g7td7q24', + objectID: '56d729602c0ee8a839b966f1', + }, + { + name: 'basics', + slug: 'basics', + objectID: '57b75ddd51da93ffde24c7d9', + }, + { + name: 'http', + slug: 'http', + objectID: '56744721958ef13879b94c04', + }, + { + name: 'Self Improvement ', + slug: 'self-improvement-1', + objectID: '5f2e55763b12e25afe3e4d05', + }, + { + name: 'GitLab', + slug: 'gitlab', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1506019761/umvea3aqsquj7z9ighjt.png', + objectID: '56bb10616bd8ce129b0bcc6c', + }, + { + name: 'google cloud', + slug: 'google-cloud', + objectID: '56744722958ef13879b951dd', + }, + { + name: 'Spring', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1458677282/mtezd0wf8jhhmbgkzo1g.jpg', + slug: 'spring', + objectID: '5674471d958ef13879b94772', + }, + { + name: 'selenium', + slug: 'selenium', + objectID: '56a1bb2a92921b8f79d3620f', + }, + { + name: 'Gatsby', + slug: 'gatsby', + objectID: '58a37012803129b7f158f514', + }, + { + name: 'containers', + slug: 'containers', + objectID: '571f798917ae2452d9887631', + }, + { + name: 'resources', + slug: 'resources', + objectID: '56744721958ef13879b94d55', + }, + { + name: 'operating system', + slug: 'operating-system', + objectID: '56744721958ef13879b94b09', + }, + { + name: 'product', + slug: 'product', + objectID: '577f7bc442d3fa70a37e450e', + }, + { + name: 'cms', + slug: 'cms', + objectID: '56744723958ef13879b953ff', + }, + { + name: 'ui ux designer', + slug: 'ui-ux-designer', + objectID: '5f7af8bd9c3b6e4101218399', + }, + { + name: 'hosting', + slug: 'hosting', + objectID: '56744721958ef13879b94b0f', + }, + { + name: 'social media', + slug: 'social-media', + objectID: '5775ff2c57675ec2fcfd086e', + }, + { + name: 'debugging', + slug: 'debugging', + objectID: '56744723958ef13879b95372', + }, + { + name: 'Heroku', + slug: 'heroku', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496175418/k6pahvykel6hcfqtkk3d.jpg', + objectID: '568935c69a4538cecc3ae55f', + }, + { + name: 'software', + slug: 'software', + objectID: '56744721958ef13879b9481e', + }, + { + name: 'asp.net core', + slug: 'aspnet-core', + objectID: '56bad3b76bd8ce129b0bcc04', + }, + { + name: 'hackathon', + slug: 'hackathon', + objectID: '56744720958ef13879b947d4', + }, + { + name: 'framework', + slug: 'framework', + objectID: '56744721958ef13879b94b4d', + }, + { + name: 'cli', + slug: 'cli', + objectID: '56744723958ef13879b953a7', + }, + { + name: 'array methods', + slug: 'array-methods', + objectID: '5f397a30c4d5973f55c91219', + }, + { + name: 'Electron', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1473241164/mqhaydhn8fhejzrsxofr.png', + slug: 'electron', + objectID: '56744723958ef13879b95419', + }, + { + name: 'challenge', + slug: 'challenge', + objectID: '56744721958ef13879b949c9', + }, + { + name: 'Freelancing', + slug: 'freelancing', + objectID: '56744723958ef13879b953cc', + }, + { + name: 'linux-basics', + slug: 'linux-basics', + objectID: '5fb01c1fc03b0e471014f758', + }, + { + name: 'portfolio', + slug: 'portfolio', + objectID: '5690e78091716a2d1dbadc0f', + }, + { + name: 'functions', + slug: 'functions', + objectID: '56744721958ef13879b94a01', + }, + { + name: 'Springboot', + slug: 'springboot', + objectID: '58646144cc0caec55e2fd1d1', + }, + { + name: 'youtube', + slug: 'youtube', + objectID: '56ced112f0ec33085f1cc5ab', + }, + { + name: 'Browsers', + slug: 'browsers', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1502183382/psbzjcrqxjjndian3nph.png', + objectID: '56744721958ef13879b94d63', + }, + { + name: 'vue', + slug: 'vue', + objectID: '570e5021115103c3b09785e1', + }, + { + name: 'Flask Framework', + slug: 'flask', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1518503935975/S1_-_WePM.png', + objectID: '56744723958ef13879b95588', + }, + { + name: 'HashnodeCommunity', + slug: 'hashnodecommunity', + objectID: '5f3272264332ee07eb55c4bd', + }, + { + name: 'Apple', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1465890893/iickievhb3ymoyga6wyw.png', + slug: 'apple', + objectID: '56744721958ef13879b948ba', + }, + { + name: 'Business and Finance ', + slug: 'business-and-finance', + objectID: '5f253857669da9610ee1771d', + }, + { + name: 'CSS Animation', + slug: 'css-animation', + objectID: '567c03e03f1768f6bf48a678', + }, + { + name: 'books', + slug: 'books', + objectID: '56744721958ef13879b94d2a', + }, + { + name: 'Technical interview', + slug: 'technical-interview', + objectID: '5f0725a8570e2e29ce255012', + }, + { + name: 'PHP7', + slug: 'php7', + objectID: '5680fde5aeae5c9e229cf8e2', + }, + { + name: 'side project', + slug: 'side-project', + objectID: '576fa8aca245bcf2e2e91044', + }, + { + name: 'personal', + slug: 'personal', + objectID: '56b41c593f1e4ff03c56b4e4', + }, + { + name: 'github-actions', + slug: 'github-actions-1', + objectID: '5f4f0f5850b5c61ec6ef4eb4', + }, + { + name: 'Facebook', + slug: 'facebook', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496176300/khcjk48ycpejt19sfgav.png', + objectID: '56744721958ef13879b94da0', + }, + { + name: 'code review', + slug: 'code-review', + objectID: '56744721958ef13879b949f9', + }, + { + name: 'elasticsearch', + slug: 'elasticsearch', + objectID: '56744723958ef13879b95430', + }, + { + name: 'TDD (Test-driven development)', + slug: 'tdd', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1502441836/zcgicxrtkoquz67dlkgs.png', + objectID: '56744721958ef13879b94898', + }, + { + name: 'Svelte', + slug: 'svelte', + objectID: '583d0951f533d193a2e694d1', + }, + { + name: 'Sass', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1490082271/p0drxprfhz9qmkm0txrf.png', + slug: 'sass', + objectID: '56744721958ef13879b94df7', + }, + { + name: 'Entrepreneurship', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496913550/abhdc0juuwrn1kfjk966.png', + slug: 'entrepreneurship', + objectID: '567a50052b926c3063c305c9', + }, + { + name: 'Bugs and Errors', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1465891233/e2dpkrprraf8jitcvc3i.png', + slug: 'bugs-and-errors', + objectID: '575f9bc3da600b8ef43e5263', + }, + { + name: 'android apps', + slug: 'android-apps', + objectID: '590c655dd7c4344afe6c3241', + }, + { + name: 'Flutter Widgets', + slug: 'flutter-widgets', + objectID: '5f08f6a1b0bf5b3c273ea78c', + }, + { + name: 'documentation', + slug: 'documentation', + objectID: '56744722958ef13879b950f8', + }, + { + name: 'Continuous Integration', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1460096831/jeyz4slnhjuflhkqbanb.png', + slug: 'continuous-integration', + objectID: '56744721958ef13879b94de0', + }, + { + name: 'version control', + slug: 'version-control', + objectID: '56744722958ef13879b9506b', + }, + { + name: 'asynchronous', + slug: 'asynchronous', + objectID: '56744722958ef13879b94e66', + }, + { + name: 'Magento', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1487144361/a8xaya1bv8advcuoj90m.png', + slug: 'magento', + objectID: '56eadc94bcca2d711e191c4c', + }, + { + name: 'Netlify', + slug: 'netlify', + objectID: '57ce27e495368c463b098050', + }, + { + name: 'nginx', + slug: 'nginx', + objectID: '56744722958ef13879b94f8b', + }, + { + name: 'web scraping', + slug: 'web-scraping', + objectID: '58dfb250eb0ffea9e764936d', + }, + { + name: 'ios app development', + slug: 'ios-app-development', + objectID: '584a50f7e1ffd7084c8b1e6c', + }, + { + name: 'Redis', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513324425585/r1M9y-bMM.png', + slug: 'redis', + objectID: '56744721958ef13879b94c41', + }, + { + name: 'infrastructure', + slug: 'infrastructure', + objectID: '56a4e1d28e1dd6d05014efdb', + }, + { + name: 'shell', + slug: 'shell', + objectID: '56744723958ef13879b95561', + }, + { + name: 'CSS Frameworks', + slug: 'css-frameworks', + objectID: '56744721958ef13879b94b82', + }, + { + name: 'Responsive Web Design', + slug: 'responsive-web-design', + objectID: '574dc610be8cff2ed6571a40', + }, + { + name: 'bootcamp', + slug: 'bootcamp', + objectID: '58d54af36047f98ddcae780b', + }, + { + name: 'Competitive programming', + slug: 'competitive-programming', + objectID: '56fb79d4da7018d48c208e91', + }, + { + name: 'podcast', + slug: 'podcast', + objectID: '56744722958ef13879b950d3', + }, + { + name: 'email', + slug: 'email', + objectID: '56744722958ef13879b95038', + }, + { + name: 'Material Design', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1474050455/stm5thxo0n1evvzpl7np.png', + slug: 'material-design', + objectID: '56744722958ef13879b95029', + }, + { + name: 'NoSQL', + slug: 'nosql', + objectID: '56744721958ef13879b94b41', + }, + { + name: 'markdown', + slug: 'markdown', + objectID: '56744722958ef13879b950b2', + }, + { + name: 'components', + slug: 'components', + objectID: '571c5374fc5b53a1ace37ce8', + }, + { + name: 'unit testing', + slug: 'unit-testing', + objectID: '56744721958ef13879b94ac4', + }, + { + name: 'management', + slug: 'management', + objectID: '56744721958ef13879b948d1', + }, + { + name: 'research', + slug: 'research', + objectID: '56744723958ef13879b952cb', + }, + { + name: 'Ionic Framework', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1513839196650/HyrD9A_fM.jpeg', + slug: 'ionic', + objectID: '56744721958ef13879b94b62', + }, + { + name: 'vim', + slug: 'vim', + objectID: '56744722958ef13879b95126', + }, + { + name: 'Accessibility', + slug: 'accessibility', + objectID: '56744723958ef13879b95230', + }, + { + name: 'remote', + slug: 'remote', + objectID: '56744721958ef13879b94841', + }, + { + name: 'agile', + slug: 'agile', + objectID: '56744723958ef13879b9551b', + }, + { + name: 'analytics', + slug: 'analytics', + objectID: '56744721958ef13879b9495b', + }, + { + name: 'vscode extensions', + slug: 'vscode-extensions', + objectID: '5f5c6d4213599a5f2e33f00f', + }, + { + name: 'statistics', + slug: 'statistics', + objectID: '56744721958ef13879b949ea', + }, + { + name: 'react router', + slug: 'react-router', + objectID: '56744721958ef13879b949bc', + }, + { + name: 'IDEs', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450468381/x5vcqb3xxe7wdheuopww.png', + slug: 'ides', + objectID: '56744722958ef13879b94eff', + }, + { + name: 'forms', + slug: 'forms', + objectID: '56744721958ef13879b948fa', + }, + { + name: 'Terraform', + slug: 'terraform', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1617121672721/6r0bN-GSK.png', + objectID: '57bf546693309a25047c6206', + }, + { + name: 'animation', + slug: 'animation', + objectID: '56744723958ef13879b95338', + }, + { + name: 'Developer Blogging', + slug: 'developer-blogging', + objectID: '5f1c1e25e8769101a9ef64d2', + }, + { + name: 'PWA', + slug: 'pwa', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496404433/rlgbcgsuycivf0ukgxrl.png', + objectID: '57cbc5d49b3eb82e014a0320', + }, + { + name: 'JAMstack', + slug: 'jamstack', + objectID: '58f9253e01cb858c63429c31', + }, + { + name: 'Elixir', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1452000435/svfntjev0f681f6oiptm.png', + slug: 'elixir', + objectID: '56744723958ef13879b95392', + }, + { + name: 'dotnetcore', + slug: 'dotnetcore', + objectID: '5794f65abecb9ebac0d5fc56', + }, + { + name: 'ShowHashnode', + slug: 'showhashnode', + objectID: '5d946e601971c92f3298b280', + }, + { + name: 'coding challenge', + slug: 'coding-challenge', + objectID: '5f16831dfefe35614464e44b', + }, + { + name: 'Android Studio', + slug: 'android-studio', + objectID: '5868042db99398bc30c43e77', + }, + { + name: 'variables', + slug: 'variables', + objectID: '56744721958ef13879b94863', + }, + { + name: 'ci-cd', + slug: 'ci-cd', + objectID: '5f0ed0dd7611e111fbd7194f', + }, + { + name: 'nlp', + slug: 'nlp', + objectID: '573a8e38a5dc678fc9090d31', + }, + { + name: '#howtos', + slug: 'howtos', + objectID: '5f18178960b5d372e20d5a86', + }, + { + name: 'Web Hosting', + slug: 'web-hosting', + objectID: '571faab486b33947d9bdbab2', + }, + { + name: 'oop', + slug: 'oop', + objectID: '5674471d958ef13879b94779', + }, + { + name: 'DigitalOcean', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1491567594/mtbp3w2posceqcdj8rx5.jpg', + slug: 'digitalocean', + objectID: '56744721958ef13879b948c3', + }, + { + name: 'SVG', + slug: 'svg', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1500325020/kp4vjytdfuqbhaibiqm7.png', + objectID: '56744723958ef13879b95469', + }, + { + name: 'promises', + slug: 'promises', + objectID: '56744722958ef13879b951d9', + }, + { + name: 'womenwhocode', + slug: 'womenwhocode', + objectID: '5f1fdd28ed20ff21a11e7126', + }, + { + name: 'Flutter SDK', + slug: 'flutter-sdk', + objectID: '5f08f6a1b0bf5b3c273ea78a', + }, + { + name: 'optimization', + slug: 'optimization', + objectID: '56744721958ef13879b94821', + }, + { + name: 'work', + slug: 'work', + objectID: '56a361abff99ae055eeffd33', + }, + { + name: 'database', + slug: 'database', + objectID: '56744722958ef13879b950ef', + }, + { + name: 'pandas', + slug: 'pandas', + objectID: '56744723958ef13879b953e6', + }, + { + name: 'chrome extension', + slug: 'chrome-extension', + objectID: '56b1945b04f0061506b361db', + }, + { + name: 'privacy', + slug: 'privacy', + objectID: '56744723958ef13879b952fc', + }, + { + name: 'events', + slug: 'events', + objectID: '575d75e2da600b8ef43e506d', + }, + { + name: 'ansible', + slug: 'ansible', + objectID: '56744722958ef13879b95152', + }, + { + name: 'Mathematics', + slug: 'mathematics', + objectID: '592d60cb8a6f7b0a1195412a', + }, + { + name: 'startup', + slug: 'startup', + objectID: '56744721958ef13879b94bbb', + }, + { + name: 'music', + slug: 'music', + objectID: '56744721958ef13879b949c6', + }, + { + name: 'problem solving skills', + slug: 'problem-solving-skills', + objectID: '5f8560a8e83ccb407537a1ee', + }, + { + name: 'review', + slug: 'review', + objectID: '56744723958ef13879b953b4', + }, + { + name: 'GIS', + slug: 'gis', + objectID: '57fb4f226849a80ac266ca71', + }, + { + name: 'unity', + slug: 'unity', + objectID: '56744721958ef13879b94885', + }, + { + name: 'test', + slug: 'test', + objectID: '56744722958ef13879b951d6', + }, + { + name: 'TIL', + slug: 'til', + objectID: '5d93238ce235795f6eb6dd79', + }, + { + name: 'Auth0', + slug: 'auth0', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1627916134204/sEaEU0wiP.png', + objectID: '56fb1506ea33a5b266f2ffc3', + }, + { + name: 'Certification', + slug: 'certification', + objectID: '57d4461ed17cab545cab66de', + }, + { + name: 'webdevelopment', + slug: 'webdevelopment', + objectID: '56744721958ef13879b94b20', + }, + { + name: 'lifestyle', + slug: 'lifestyle', + objectID: '56744721958ef13879b948f2', + }, + { + name: 'course', + slug: 'course', + objectID: '575150c412a8cb07bb842118', + }, + { + name: 'Story', + slug: 'story', + objectID: '57348ce934963cba3535abb4', + }, + { + name: 'job search', + slug: 'job-search', + objectID: '5f08ee681981c53c4987f2b4', + }, + { + name: 'Raspberry Pi', + slug: 'raspberry-pi', + objectID: '56d2cbb4099859fa044d68c0', + }, + { + name: 'Amazon Web Services', + slug: 'amazon-web-services', + objectID: '56a6742dc84f2c6913b8eac3', + }, + { + name: 'tutorials', + slug: 'tutorials', + objectID: '56744721958ef13879b94dcc', + }, + { + slug: 'flutter-cjx3aa7op001jims1kuwl3ekz', + objectID: '5d0a3b36c7de780e772aff0a', + }, + { + name: '#data visualisation', + slug: 'data-visualisation-1', + objectID: '5f4b7d61f540845bb26f0291', + }, + { + name: 'continuous deployment', + slug: 'continuous-deployment', + objectID: '56744722958ef13879b94f92', + }, + { + name: 'video', + slug: 'video', + objectID: '56744723958ef13879b954e9', + }, + { + name: 'DOM', + slug: 'dom', + objectID: '56744723958ef13879b95376', + }, + { + name: 'search', + slug: 'search', + objectID: '56744721958ef13879b9497b', + }, + { + name: 'JWT', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1464240237/bqu9k0lklrg7xxvk2pzq.jpg', + slug: 'jwt', + objectID: '56744723958ef13879b9536e', + }, + { + name: 'Interviews', + slug: 'interviews', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496318621/g6yz7ukrqftqat2y3ycn.png', + objectID: '56744721958ef13879b948b1', + }, + { + name: 'vanilla-js', + slug: 'vanilla-js-1', + objectID: '5f9aaeaba1658252d1a7b620', + }, + { + name: 'monitoring', + slug: 'monitoring', + objectID: '56744723958ef13879b95361', + }, + { + name: 'Text Editors', + slug: 'text-editors', + objectID: '571459a7162bdaad9f92b0d7', + }, + { + name: 'gaming', + slug: 'gaming', + objectID: '57e951b155544e5132a4d5df', + }, + { + name: 'mongoose', + slug: 'mongoose', + objectID: '56744723958ef13879b9540c', + }, + { + name: 'SaaS', + slug: 'saas', + objectID: '56744722958ef13879b950a5', + }, + { + name: 'content', + slug: 'content', + objectID: '56744721958ef13879b94849', + }, + { + name: 'apache', + slug: 'apache', + objectID: '56744723958ef13879b95513', + }, + { + name: 'engineering', + slug: 'engineering', + objectID: '56744722958ef13879b950b5', + }, + { + name: 'headless cms', + slug: 'headless-cms', + objectID: '5914be36db93b4aae8008897', + }, + { + name: 'newsletter', + slug: 'newsletter', + objectID: '56744722958ef13879b9516a', + }, + { + name: 'network', + slug: 'network', + objectID: '56744721958ef13879b94923', + }, + { + name: 'IT', + slug: 'it', + objectID: '57628dcd820dd45f3fbd8eb5', + }, + { + name: 'mobile app development', + slug: 'mobile-app-development', + objectID: '56744723958ef13879b95222', + }, + { + name: 'freeCodeCamp.org', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1518534240940/ByFDRugwf.jpeg', + slug: 'freecodecamp', + objectID: '57039f98f950faa9ab7ec552', + }, + { + name: 'Cryptography', + slug: 'cryptography', + objectID: '58426a8997063da359fe2cf4', + }, + { + name: 'Augmented Reality', + slug: 'augmented-reality', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1506666999/lnnrwwh9td4xm87lh13d.png', + objectID: '57ce29fde5e41a2a5c24fa98', + }, + { + name: 'training', + slug: 'training', + objectID: '56b0a1600a7ca0c6f70c3703', + }, + { + name: 'Objects', + slug: 'objects', + objectID: '57e793cdef99cf03582fe42b', + }, + { + name: 'flexbox', + slug: 'flexbox', + objectID: '56744721958ef13879b94afb', + }, + { + name: 'SSL', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450712342/u2tfvtrfojyne6qzaflq.jpg', + slug: 'ssl', + objectID: '56744721958ef13879b94912', + }, + { + name: 'ASP.NET', + slug: 'aspnet', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1515075607732/ByxXu3jQG.jpeg', + objectID: '567e2a600db88211bac0a032', + }, + { + name: 'distributed system', + slug: 'distributed-system', + objectID: '568c90725e7a940b3d3e08ed', + }, + { + name: 'logging', + slug: 'logging', + objectID: '568bb9dbe99c5444f3233893', + }, + { + name: 'Applications', + slug: 'applications', + objectID: '56ea7aebbcca2d711e191c02', + }, + { + name: 'user experience', + slug: 'user-experience', + objectID: '56744721958ef13879b948d4', + }, + { + name: 'architecture', + slug: 'architecture', + objectID: '56744723958ef13879b9529a', + }, + { + name: 'package', + slug: 'package', + objectID: '56744723958ef13879b9533c', + }, + { + name: 'tricks', + slug: 'tricks', + objectID: '56744721958ef13879b94b19', + }, + { + name: 'R Language', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1490864688/fiw7ngemmxkumjpkntdp.png', + slug: 'r', + objectID: '56744722958ef13879b95111', + }, + { + name: 'css flexbox', + slug: 'css-flexbox', + objectID: '56744721958ef13879b94c3a', + }, + { + name: 'Xcode', + slug: 'xcode', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1502355718/vddmkshskl3sbl4xogwn.jpg', + objectID: '56744720958ef13879b947ff', + }, + { + name: 'Monetization', + slug: 'monetization', + objectID: '5736a1db6a4640415dc89e28', + }, + { + name: 'async', + slug: 'async', + objectID: '56cbdb23b70682283f9edeb8', + }, + { + name: 'SQL Server', + slug: 'sql-server', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1551380133166/kHlXAcxdU.jpeg', + objectID: '56744720958ef13879b947b6', + }, + { + name: 'tensorflow', + slug: 'tensorflow', + objectID: '56744722958ef13879b9518a', + }, + { + name: 'Vercel Hashnode Hackathon', + slug: 'vercelhashnode', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1610701406772/nrAD-f_i6.png', + objectID: '6001530cf611a365208ad66a', + }, + { + name: 'extension', + slug: 'extension', + objectID: '569f6b4492921b8f79d36061', + }, + { + name: 'free', + slug: 'free', + objectID: '56744723958ef13879b95214', + }, + { + name: 'kotlin beginner', + slug: 'kotlin-beginner', + objectID: '5f081e73b587713318b74a42', + }, + { + name: 'SurviveJS', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1461251276/bmmcz554bnl0zk83l1iz.png', + slug: 'survivejs', + objectID: '5718ec0fc4b104334fad928e', + }, + { + name: 'Rails', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1453793832/qypx8pjpm7tybpcbfhif.jpg', + slug: 'rails', + objectID: '56744722958ef13879b94eb5', + }, + { + name: 'Web Perf', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1472485302/gvihfpia52e0l5r3rau9.jpg', + slug: 'webperf', + objectID: '56744722958ef13879b950c6', + }, + { + name: 'big data', + slug: 'big-data', + objectID: '56744721958ef13879b94e3b', + }, + { + name: 'communication', + slug: 'communication', + objectID: '57d2d92415ae0c65b80ace44', + }, + { + name: 'Solidity', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1512988916861/ryTxWknbG.png', + slug: 'solidity', + objectID: '595ab8b5a3e02ebe146b2f2a', + }, + { + name: 'Experience ', + slug: 'experience', + objectID: '587dbc32d40f782e50cf92e0', + }, + { + name: 'Amazon S3', + slug: 'amazon-s3', + objectID: '569d145446dfdb8479aa690d', + }, + { + name: 'Meteor', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450467991/aaskzxstfaadd1sbhxj2.png', + slug: 'meteor', + objectID: '56744722958ef13879b94fa7', + }, + { + name: 'agile development', + slug: 'agile-development', + objectID: '56744721958ef13879b94dba', + }, + { + name: 'Oracle', + slug: 'oracle', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1516182996908/ByaA6q2NG.jpeg', + objectID: '56744721958ef13879b9498a', + }, + { + name: 'scss', + slug: 'scss', + objectID: '56744722958ef13879b951f1', + }, + { + name: 'GCP', + slug: 'gcp', + objectID: '58d4d1fbcfc5bd6596a0a6b5', + }, + { + name: 'domain', + slug: 'domain', + objectID: '5714fe4e151fa7c4488cc1ae', + }, + { + name: 'Regex', + slug: 'regex', + objectID: '56f6aef0aa013a5f87413615', + }, + { + name: 'Symfony', + slug: 'symfony', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1541163459741/BknTF6t3m.png', + objectID: '572d6c67bf97af427dd07f13', + }, + { + name: 'app', + slug: 'app', + objectID: '56744721958ef13879b94a0e', + }, + { + name: 'Junior developer ', + slug: 'junior-developer', + objectID: '5f071caa6e04d8269a566170', + }, + { + name: 'advice', + slug: 'advice', + objectID: '56744723958ef13879b95333', + }, + { + name: 'Powershell', + slug: 'powershell', + objectID: '56f7871ffc7154468758edb7', + }, + { + name: 'Babel', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1504815622/wo9hjfe0klgxj8mahf6j.png', + slug: 'babel', + objectID: '56744722958ef13879b95045', + }, + { + name: 'Reactive Programming', + slug: 'reactive-programming', + objectID: '56744721958ef13879b94aee', + }, + { + name: 'Smart Contracts', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1512989048807/S1WtW1hZz.png', + slug: 'smart-contracts', + objectID: '5a2e407a5b9ed1636662b8f9', + }, + { + name: 'string', + slug: 'string', + objectID: '57448e2a9ade925885158cfe', + }, + { + name: 'images', + slug: 'images', + objectID: '56744723958ef13879b95229', + }, + { + name: 'hiring', + slug: 'hiring', + objectID: '56744721958ef13879b9497e', + }, + { + name: 'Christmas Hackathon', + slug: 'christmashackathon', + logo: null, + objectID: '5fe187955620145ec6e3a5c2', + }, + { + name: 'services', + slug: 'services', + objectID: '5682e64e2c29f7e0c86d024b', + }, + { + name: 'aws-cdk', + slug: 'aws-cdk', + objectID: '5f743910a3a6d515f7142eb4', + }, + { + name: 'Laravel 5', + slug: 'laravel-5', + objectID: '56ec06ac5edec9d7189a0ad6', + }, + { + name: 'crypto', + slug: 'crypto', + objectID: '57b188c971be21426cb4916e', + }, + { + name: 'instagram', + slug: 'instagram', + objectID: '56744721958ef13879b94aec', + }, + { + name: 'questions', + slug: 'questions', + objectID: '56744723958ef13879b952fe', + }, + { + name: 'bot', + slug: 'bot', + objectID: '56744721958ef13879b948df', + }, + { + name: 'chatbot', + slug: 'chatbot', + objectID: '57444f35468ae9e479434fac', + }, + { + name: 'risingstack', + slug: 'risingstack', + objectID: '587745676b985e96ec6d48b7', + }, + { + name: 'trends', + slug: 'trends', + objectID: '56744721958ef13879b94a2a', + }, + { + name: 'Jest', + slug: 'jest', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496389933/s2t8atgotu6wvjgojpn6.png', + objectID: '56cfe81bfa28f5fe7f74d215', + }, + { + name: 'refactoring', + slug: 'refactoring', + objectID: '56744720958ef13879b947df', + }, + { + name: 'frameworks', + slug: 'frameworks', + objectID: '56744721958ef13879b94db1', + }, + { + name: 'arrays', + slug: 'arrays', + objectID: '579350e1d87e23e5efe30d84', + }, + { + name: 'cheatsheet', + slug: 'cheatsheet', + objectID: '56cc66fff978c91273a36237', + }, + { + name: 'team', + slug: 'team', + objectID: '56744723958ef13879b952e7', + }, + { + name: 'docker images', + slug: 'docker-images', + objectID: '5f442ff51b2ea309b7529267', + }, + { + name: 'classes', + slug: 'classes', + objectID: '56744723958ef13879b955a3', + }, + { + name: 'workflow', + slug: 'workflow', + objectID: '56744722958ef13879b94e77', + }, + { + name: 'ML', + slug: 'ml', + objectID: '57c6e7bdb274bac7e601abe2', + }, + { + name: 'neural networks', + slug: 'neural-networks', + objectID: '56af3b4ccc975f0cc6878c8a', + }, + { + name: 'javascript modules', + slug: 'javascript-modules', + objectID: '56cbdab9b70682283f9edeae', + }, + { + name: 'skills', + slug: 'skills', + objectID: '576b3918decdd3bf3610c80b', + }, + { + name: 'Internet of Things', + slug: 'internet-of-things', + objectID: '58f8acb0e928dad5e4c7ab2b', + }, + { + name: 'dns', + slug: 'dns', + objectID: '5674471d958ef13879b94798', + }, + { + name: 'Blazor ', + slug: 'blazor-1', + objectID: '5f219f52ef20f63bcf9822c6', + }, + { + name: 'Script', + slug: 'script', + objectID: '56a294beff99ae055eeffcea', + }, + { + name: 'Help Needed', + slug: 'help', + objectID: '5674471d958ef13879b94764', + }, + { + name: 'mobile', + slug: 'mobile', + objectID: '56744723958ef13879b9524e', + }, + { + name: 'Amplify Hashnode', + slug: 'amplifyhashnode', + logo: null, + objectID: '60223d4f281265375d643d83', + }, + { + name: 'ssh', + slug: 'ssh', + objectID: '5677ff6aec7aa67e51f1e096', + }, + { + name: 'Software Testing', + slug: 'software-testing', + objectID: '56b54dae8dabdc6142c1ac86', + }, + { + name: 'dev tools', + slug: 'dev-tools', + objectID: '56744723958ef13879b9527c', + }, + { + name: 'https', + slug: 'https', + objectID: '56744722958ef13879b94e73', + }, + { + name: 'Inspiration', + slug: 'inspiration', + objectID: '57de56e3c61e5b59729da2a8', + }, + { + name: 'Ajax', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1459504130/huzynvc2g3hd5w8sjw6w.jpg', + slug: 'ajax', + objectID: '56744722958ef13879b95140', + }, + { + name: 'DEVCommunity', + slug: 'devcommunity', + objectID: '5f1ccb30f4016901885cc50f', + }, + { + name: 'oauth', + slug: 'oauth', + objectID: '56744722958ef13879b951b1', + }, + { + name: 'design principles', + slug: 'design-principles', + objectID: '5f965c1c40346172a86c2c4b', + }, + { + name: 'mentalhealth', + slug: 'mentalhealth-1', + objectID: '5f7e39240e5d207780d949e9', + }, + { + name: '#hacktoberfest ', + slug: 'hacktoberfest-1', + objectID: '5f6629266dfc523d0a89357b', + }, + { + name: 'MobX', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1534512814483/SJUnRS4U7.jpeg', + slug: 'mobx', + objectID: '5729bc14faa06f875ef32e95', + }, + { + name: 'ec2', + slug: 'ec2', + objectID: '56744721958ef13879b94a18', + }, + { + name: 'setup', + slug: 'setup', + objectID: '57a37bf75bfdd08aeffb5832', + }, + { + name: 'devtools', + slug: 'devtools', + objectID: '56744722958ef13879b950fe', + }, + { + name: 'ecmascript', + slug: 'ecmascript', + objectID: '56744722958ef13879b9511f', + }, + { + name: 'styled-components', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1486104606/jbhiqodxlyhaqogfuqwy.png', + slug: 'styled-components', + objectID: '58900d47afa2b4bce2efb44f', + }, + { + name: 'REST', + slug: 'rest', + objectID: '56744721958ef13879b949f6', + }, + { + name: 'caching', + slug: 'caching', + objectID: '56744723958ef13879b9540f', + }, + { + name: '7daystreak', + slug: '7daystreak', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1626280769878/xaxdZgS0N.png', + objectID: '60ed9e18fc37a15ec15683b3', + }, + { + name: 'image processing', + slug: 'image-processing', + objectID: '5674471d958ef13879b94776', + }, + { + name: 'Web API', + slug: 'web-api', + objectID: '5894ec2f47e4163deb72c252', + }, + { + name: 'ideas', + slug: 'ideas', + objectID: '56744721958ef13879b948f6', + }, + { + name: 'hack', + slug: 'hack', + objectID: '56744723958ef13879b95426', + }, + { + name: 'hardware', + slug: 'hardware', + objectID: '568439646b179c61d167f08d', + }, + { + name: 'web application', + slug: 'web-application', + objectID: '56744723958ef13879b952c2', + }, + { + name: 'library', + slug: 'library', + objectID: '56744721958ef13879b94d94', + }, + { + name: 'opencv', + slug: 'opencv', + objectID: '587745676b985e96ec6d48b8', + }, + { + name: 'AWS Certified Solutions Architect Associate', + slug: 'aws-certified-solutions-architect-associate', + objectID: '5f71b762eb14b172f1d4bc39', + }, + { + name: 'CSS Grid', + slug: 'css-grid', + objectID: '58becf402a99d222c65c24d8', + }, + { + name: 'job', + slug: 'job', + objectID: '56744721958ef13879b94a46', + }, + { + name: 'leadership', + slug: 'leadership', + objectID: '57c15e52387df20e0b9f94a0', + }, + { + name: 'Jenkins', + slug: 'jenkins', + objectID: '57d6d71cf72dd3705c15ffcf', + }, + { + name: 'eslint', + slug: 'eslint', + objectID: '570f716a115103c3b0978698', + }, + { + name: 'time', + slug: 'time', + objectID: '58f7bab0e1eb1bd4e45f05f0', + }, + { + name: 'realtime', + slug: 'realtime', + objectID: '56744721958ef13879b94bdf', + }, + { + name: 'Math', + slug: 'math', + objectID: '581ad086c055bbfb46d8811b', + }, + { + name: 'conference', + slug: 'conference', + objectID: '56744721958ef13879b9493b', + }, + { + name: 'general', + slug: 'general', + objectID: '56fd6444404be5549d3de51b', + }, + { + name: 'encryption', + slug: 'encryption', + objectID: '56744723958ef13879b9528d', + }, + { + name: 'files', + slug: 'files', + objectID: '57f7bbb9813841efc19c3488', + }, + { + name: 'error handling', + slug: 'error-handling', + objectID: '56744722958ef13879b95084', + }, + { + name: 'Auth0Hackathon', + slug: 'auth0hackathon', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1627916253311/DuFTo1seC.png', + objectID: '6108059fb97c436d241bddc5', + }, + { + name: 'numpy', + slug: 'numpy', + objectID: '57c7c7c7e53060955aa8c018', + }, + { + name: 'D3.js', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1459873316/fdlqr3pk587gddsrirxe.jpg', + slug: 'd3js', + objectID: '56744721958ef13879b94d8c', + }, + { + name: 'Apollo GraphQL', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1467922175/sbxeze75uotah3qeqhbh.png', + slug: 'apollo', + objectID: '57053ef1115103c3b0977fb0', + }, + { + name: 'Nuxt', + slug: 'nuxt', + objectID: '591c5a1956856e7d71046403', + }, + { + name: 'DDD', + slug: 'ddd', + objectID: '576b14ad41d2cbca360cf875', + }, + { + name: 'excel', + slug: 'excel', + objectID: '591414b39e2b75ff7c5fa62d', + }, + { + name: 'branding', + slug: 'branding', + objectID: '56b71ac92894c38346c06670', + }, + { + name: 'Web Components', + slug: 'web-components', + objectID: '56744723958ef13879b95564', + }, + { + name: 'dynamodb', + slug: 'dynamodb', + objectID: '56744722958ef13879b950d8', + }, + { + name: 'College', + slug: 'college', + objectID: '587dbc32d40f782e50cf92df', + }, + { + name: 'journal', + slug: 'journal', + objectID: '5674471d958ef13879b94791', + }, + { + name: 'state', + slug: 'state', + objectID: '584ac47b9747b36ae2a28c8a', + }, + { + name: 'impostor syndrome', + slug: 'impostor-syndrome', + objectID: '56744723958ef13879b95306', + }, + { + name: 'creativity', + slug: 'creativity', + objectID: '56744721958ef13879b94829', + }, + { + name: 'SheCodeAfrica ', + slug: 'shecodeafrica', + objectID: '5f115a51d6c58d29e0240e45', + }, + { + name: 'SocketIO', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1472485355/zsypm63fq6998mc1pqvl.png', + slug: 'socketio', + objectID: '56744721958ef13879b94b52', + }, + { + name: 'HTML Canvas', + slug: 'html-canvas', + objectID: '5692580fcad8946e563c570a', + }, + { + name: 'QA', + slug: 'qa', + objectID: '56a20c4d92921b8f79d36276', + }, + { + name: 'linux kernel', + slug: 'linux-kernel', + objectID: '5faadc16d6009557c49f5bbb', + }, + { + name: 'Travel', + slug: 'travel', + objectID: '58859588abf4ad10c6ac08b6', + }, + { + name: 'authorization', + slug: 'authorization', + objectID: '56744722958ef13879b9518c', + }, + { + name: 'Scrum', + slug: 'scrum', + objectID: '570a9a273aeb5317437380e4', + }, + { + name: 'Validation', + slug: 'validation', + objectID: '56c093923ddee41359169468', + }, + { + name: 'messaging', + slug: 'messaging', + objectID: '57d832bbd17cab545cab9dbf', + }, + { + name: 'Computer Vision', + slug: 'computer-vision', + objectID: '57534dab82cbbab8dcd475b9', + }, + { + name: 'ios app developer', + slug: 'ios-app-developer', + objectID: '56744723958ef13879b9542c', + }, + { + name: 'Xamarin', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1464701189/ms3lwj8fdp2agynrxrly.jpg', + slug: 'xamarin', + objectID: '56744721958ef13879b94825', + }, + { + name: 'mvc', + slug: 'mvc', + objectID: '56744721958ef13879b94995', + }, + { + name: 'fonts', + slug: 'fonts', + objectID: '56744721958ef13879b9499e', + }, + { + name: 'video streaming', + slug: 'video-streaming', + objectID: '590c71fe1ae3d06072e8956c', + }, + { + name: 'closure', + slug: 'closure', + objectID: '56744721958ef13879b94b1e', + }, + { + name: 'HarperDB Hackathon', + slug: 'harperdbhackathon', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1623401171709/jCXKCcOIl.png', + objectID: '60b7952425dc276ffb940618', + }, + { + name: 'axios', + slug: 'axios', + objectID: '58887bba81421379798066f5', + }, + { + name: 'mentorship', + slug: 'mentorship', + objectID: '575c2d212f07b512c4dce579', + }, + { + name: 'code smell ', + slug: 'code-smell-1', + objectID: '5fa7f4cac0d56c5ae62e3471', + }, + { + name: 'Web Accessibility', + slug: 'web-accessibility', + objectID: '5f3f1dcc5b3ac8481821c47c', + }, + { + name: '#growth', + slug: 'growth-1', + objectID: '5f21ee72ef20f63bcf98250b', + }, + { + name: 'shopify', + slug: 'shopify', + objectID: '57d2f8b8739df23de32d9a0b', + }, + { + name: 'dailydev', + slug: 'dailydev', + objectID: '5f4e6e6de613341d6f8cd33e', + }, + { + name: 'expressjs', + slug: 'expressjs', + objectID: '56744721958ef13879b94d81', + }, + { + name: 'fun', + slug: 'fun', + objectID: '56744723958ef13879b954b1', + }, + { + name: 'android development', + slug: 'android-development', + objectID: '56744722958ef13879b95086', + }, + { + name: 'DevBlogging', + slug: 'devblogging', + objectID: '5f323f334332ee07eb55c25e', + }, + { + name: 'Scala', + slug: 'scala', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496318498/u1ogtyiakscd683ar63g.png', + objectID: '56744723958ef13879b952a7', + }, + { + name: 'repository', + slug: 'repository', + objectID: '56744721958ef13879b94932', + }, + { + name: 'Gulp', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1455107024/ymnvwdrzghdaupgnh1pa.png', + slug: 'gulp', + objectID: '56744723958ef13879b954b9', + }, + { + name: 'CodePen', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1490300926/zpeedkxkcyorvxzepwdq.png', + slug: 'codepen', + objectID: '56744722958ef13879b94f3e', + }, + { + name: 'front-end', + slug: 'front-end-cik5w32oi016zos53hitiymhh', + objectID: '56b118e610979efc2b9a8d91', + }, + { + name: 'Salesforce', + slug: 'salesforce', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1542629160319/SkxND7eC7.jpeg', + objectID: '578d40c45460288cdeb6f094', + }, + { + name: 'Auth ', + slug: 'auth', + objectID: '5762d998d163d06a3fca2d8d', + }, + { + name: 'sorting', + slug: 'sorting', + objectID: '56e79a12c10bbcfb0ce541b1', + }, + { + name: 'slack', + slug: 'slack', + objectID: '56744723958ef13879b952bc', + }, + { + name: 'languages', + slug: 'languages', + objectID: '56744723958ef13879b95347', + }, + { + name: 'Amazon', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1469216724/nxiuwpm6dybqbn9dhybc.png', + slug: 'amazon', + objectID: '56744721958ef13879b94906', + }, + { + name: 'storage', + slug: 'storage', + objectID: '5708ff9c115103c3b09782d7', + }, + { + name: 'algorithm', + slug: 'algorithm', + objectID: '56744721958ef13879b94de3', + }, + { + name: 'pdf', + slug: 'pdf', + objectID: '57962622bdb2f5db657ae6c3', + }, + { + name: 'fetch', + slug: 'fetch', + objectID: '5758618112a8cb07bb8426d2', + }, + { + name: 'dependency injection', + slug: 'dependency-injection', + objectID: '56e6d5598c0bb8288a559c95', + }, + { + name: 'template', + slug: 'template', + objectID: '56c4cd6eedfec14f66f81d98', + }, + { + name: 'RxJS', + slug: 'rxjs', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1512113179321/HJXmNY0lf.jpeg', + objectID: '56744723958ef13879b95559', + }, + { + name: 'WebAssembly', + slug: 'webassembly', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1510296821/n90fqabiufcs8kbridxm.png', + objectID: '56744722958ef13879b95043', + }, + { + name: 'game', + slug: 'game', + objectID: '56744721958ef13879b9496d', + }, + { + name: 'lambda', + slug: 'lambda', + objectID: '56744721958ef13879b94867', + }, + { + name: 'JSX', + slug: 'jsx', + objectID: '577b65e0a1ac2f52aea75814', + }, + { + name: 'GUI', + slug: 'gui', + objectID: '574dd005be8cff2ed6571a4f', + }, + { + name: 'theme', + slug: 'theme', + objectID: '58e1a2b84200d85d6bfc1457', + }, + { + name: 'routing', + slug: 'routing', + objectID: '56744721958ef13879b949fb', + }, + { + name: 'Firefox', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1511214305890/HJ9ka6gxM.jpeg', + slug: 'firefox', + objectID: '56744721958ef13879b94929', + }, + { + name: 'visual studio', + slug: 'visual-studio', + objectID: '56744723958ef13879b953df', + }, + { + name: 'migration', + slug: 'migration', + objectID: '56744723958ef13879b9534f', + }, + { + name: 'Foundation', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450470022/sfgwosxc2dgxo9yslapu.png', + slug: 'foundation', + objectID: '56744722958ef13879b94fc2', + }, + { + name: 'LinkedIn', + slug: 'linkedin', + objectID: '575ebcbada600b8ef43e51c4', + }, + { + name: 'planning', + slug: 'planning', + objectID: '57ed528897eba84632db5b88', + }, + { + name: 'static', + slug: 'static', + objectID: '57cbff559b3eb82e014a0364', + }, + { + name: 'Indie Maker', + slug: 'indie-maker', + objectID: '5f1edf42cf3e61138dbef956', + }, + { + name: 'ThreeJS', + slug: 'threejs', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1520160600492/Bklw1UKOM.jpeg', + objectID: '571fa589cfc14de85d6aca42', + }, + { + name: 'Yarn', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1477030779/nbhawthd7lervqjdiwrz.jpg', + slug: 'yarn', + objectID: '5801b9c24c0f5aee780a3883', + }, + { + name: 'User Interface', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1462773835/uvhdwekyfkkh1tldkew7.jpg', + slug: 'user-interface', + objectID: '56744721958ef13879b94823', + }, + { + name: 'fullstack', + slug: 'fullstack', + objectID: '56744721958ef13879b94a6c', + }, + { + name: 'web performance', + slug: 'web-performance', + objectID: '56744721958ef13879b94950', + }, + { + name: 'websockets', + slug: 'websockets', + objectID: '56744721958ef13879b94a0f', + }, + { + name: 'SEO for Developers', + slug: 'seo-for-developers', + objectID: '5f58d1c9ffbb8f35dd030cdd', + }, + { + name: 'graphic design', + slug: 'graphic-design', + objectID: '56ab4801960088c21db4d845', + }, + { + name: 'bootstrap 4', + slug: 'bootstrap-4', + objectID: '56744723958ef13879b953a4', + }, + { + name: 'push notifications', + slug: 'push-notifications', + objectID: '577d40e61e03c69a78fb0dac', + }, + { + name: 'color', + slug: 'color', + objectID: '5774aa8157675ec2fcfd0744', + }, + { + name: 'Scope', + slug: 'scope', + objectID: '56f16b6cea857e0c6af05a4c', + }, + { + name: 'create-react-app', + slug: 'create-react-app', + objectID: '58ec8cb535aeeb5330e71961', + }, + { + name: 'scalability', + slug: 'scalability', + objectID: '5691193ecad8946e563c56e9', + }, + { + name: 'server hosting', + slug: 'server-hosting', + objectID: '56744723958ef13879b9553e', + }, + { + name: 'login', + slug: 'login', + objectID: '56b45894500fd79e29bd7bf4', + }, + { + name: 'Chat', + slug: 'chat', + objectID: '575e6494ed4fa39df4f9af08', + }, + { + name: 'Culture', + slug: 'culture', + objectID: '568a70511f77b14a93d83737', + }, + { + name: 'Recursion', + slug: 'recursion', + objectID: '56903d0e91716a2d1dbadbca', + }, + { + name: 'cloudflare', + slug: 'cloudflare', + objectID: '56744720958ef13879b947e6', + }, + { + name: 'whatsapp', + slug: 'whatsapp', + objectID: '5732da8af311f7ed13dddcb3', + }, + { + name: 'Off Topic', + slug: 'off-topic', + objectID: '575ab7852f07b512c4dce46e', + }, + { + name: 'passwords', + slug: 'passwords', + objectID: '578395f816a33191db0432f4', + }, + { + name: 'map', + slug: 'map', + objectID: '56fd21cd770db0f14a63ee67', + }, + { + slug: 'go-cjffccfnf0024tjs1mcwab09t', + objectID: '5abf7c154496b1f745e95fce', + }, + { + name: 'Tailwind CSS Tutorial', + slug: 'tailwind-css-tutorial', + objectID: '5f76e2947d160d41227d65b9', + }, + { + name: 'SQLite', + slug: 'sqlite', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1516183050940/BJXG0cn4z.jpeg', + objectID: '56d9e25a4aa5f35f09dd6c98', + }, + { + name: 'WebGL', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1472831587/ajchcv7sjghl7p5k1tgm.jpg', + slug: 'webgl', + objectID: '56744721958ef13879b94a3f', + }, + { + name: 'Phoenix framework', + slug: 'phoenix', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1522051540209/Hy20FXI5G.jpeg', + objectID: '56744721958ef13879b94abc', + }, + { + name: 'magento 2', + slug: 'magento-2', + objectID: '587789c03c79514bec516060', + }, + { + name: 'editors', + slug: 'editors', + objectID: '56744723958ef13879b95262', + }, + { + name: 'google sheets', + slug: 'google-sheets', + objectID: '56e669b622f645300192ed17', + }, + { + name: 'kafka', + slug: 'kafka', + objectID: '572527cf5ec4095ed6f48bf3', + }, + { + name: 'Art', + slug: 'art', + objectID: '56efa81abcca2d711e191eb9', + }, + { + name: 'generators', + slug: 'generators', + objectID: '56744722958ef13879b950b8', + }, + { + name: 'Company', + slug: 'company', + objectID: '572ca231bf97af427dd07e6c', + }, + { + name: 'console', + slug: 'console', + objectID: '56744723958ef13879b952e1', + }, + { + name: 'Virtual Reality', + slug: 'virtual-reality', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1506666919/d9j2ku0cjlhrzrboojit.png', + objectID: '56c87d289b87edaf6e25f825', + }, + { + name: 'apps', + slug: 'apps', + objectID: '56744721958ef13879b94ac2', + }, + { + name: 'plugins', + slug: 'plugins', + objectID: '56744723958ef13879b95204', + }, + { + name: 'terminal command', + slug: 'terminal-command', + objectID: '5f6afc44cbf0b22e6d444142', + }, + { + name: 'arduino', + slug: 'arduino', + objectID: '56744722958ef13879b951db', + }, + { + name: 'email marketing', + slug: 'email-marketing', + objectID: '57b76044a629e4147b4251d5', + }, + { + name: 'project', + slug: 'project', + objectID: '56744721958ef13879b94aae', + }, + { + name: '3d', + slug: '3d', + objectID: '56744721958ef13879b94ad9', + }, + { + name: 'charts', + slug: 'charts', + objectID: '56744720958ef13879b947d1', + }, + { + name: 'e-learning', + slug: 'e-learning', + objectID: '569c9b4c72ca04ea5d79fc6c', + }, + { + name: 'browser', + slug: 'browser', + objectID: '56744721958ef13879b94a11', + }, + { + name: 'snippets', + slug: 'snippets', + objectID: '56744721958ef13879b948ae', + }, + { + name: 'Flux', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450468113/cariy62rvjvlnz8ks7qw.png', + slug: 'flux', + objectID: '56744721958ef13879b94d46', + }, + { + name: 'mac', + slug: 'mac', + objectID: '56744721958ef13879b94a22', + }, + { + name: 'os', + slug: 'os', + objectID: '568f6e425e7a940b3d3e0a92', + }, + { + name: 'integration', + slug: 'integration', + objectID: '57f58a9917809963610207dd', + }, + { + name: 'logic', + slug: 'logic', + objectID: '57b23c4cab585a4d6c1529cd', + }, + { + name: 'history', + slug: 'history', + objectID: '572706c827ca2053d6613898', + }, + { + name: 'SOLID principles', + slug: 'solid-principles', + objectID: '5f4dd1ae6f2d7874d4060e9b', + }, + { + name: 'Blogger', + slug: 'blogger-1', + objectID: '5f2a4ee0d7d55f162b5da120', + }, + { + name: 'developer relations', + slug: 'developer-relations', + objectID: '56744723958ef13879b953b6', + }, + { + name: 'Service Workers', + slug: 'service-workers', + objectID: '56a746ba6e715c3c7fc5b7ef', + }, + { + name: 'iphone', + slug: 'iphone', + objectID: '56744722958ef13879b95166', + }, + { + name: 'Parse', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1457160633/ybpgyd9fhrucyvgwda6a.png', + slug: 'parse', + objectID: '56744722958ef13879b94efb', + }, + { + slug: 'sagar-jaybhay', + objectID: '5cd9909c45e85c572ab538f7', + }, + { + name: 'design review', + slug: 'design-review', + objectID: '5fb179420f6d4f4d2f66a32a', + }, + { + name: 'Career Coach', + slug: 'career-coach', + objectID: '5f0bc6ef3fe8405bdb8d80be', + }, + { + name: 'hadoop', + slug: 'hadoop', + objectID: '56744720958ef13879b94799', + }, + { + name: 'graph database', + slug: 'graph-database', + objectID: '58b96527be993da9e4853150', + }, + { + name: 'continuous delivery', + slug: 'continuous-delivery', + objectID: '56744721958ef13879b949a3', + }, + { + name: 'concurrency', + slug: 'concurrency', + objectID: '56744723958ef13879b95312', + }, + { + name: 'compiler', + slug: 'compiler', + objectID: '58790ce83c79514bec51631b', + }, + { + name: 'gsoc', + slug: 'gsoc', + objectID: '56744721958ef13879b94dea', + }, + { + name: 'spa', + slug: 'spa', + objectID: '56744721958ef13879b94d40', + }, + { + name: 'Collaboration', + slug: 'collaboration', + objectID: '57d0839fb64935c2e8fdba94', + }, + { + name: 'Event Loop', + slug: 'event-loop', + objectID: '56f7b7c59cad82b1e979026a', + }, + { + name: 'crud', + slug: 'crud', + objectID: '56f71ff1aa013a5f87413652', + }, + { + name: 'Hoisting', + slug: 'hoisting', + objectID: '56db37c9e853431899d03773', + }, + { + name: 'life-hack', + slug: 'life-hack', + objectID: '5f96548740346172a86c2be7', + }, + { + name: 'mobile application design', + slug: 'mobile-application-design', + objectID: '56744723958ef13879b95516', + }, + { + name: 'unix', + slug: 'unix', + objectID: '56744721958ef13879b94a53', + }, + { + name: 'AdonisJS', + slug: 'adonisjs', + objectID: '5770f47198002dc2b990254a', + }, + { + name: 'ecmascript6', + slug: 'ecmascript6', + objectID: '56744720958ef13879b947db', + }, + { + name: 'stack', + slug: 'stack', + objectID: '56744723958ef13879b95368', + }, + { + slug: 'cybersecurity', + objectID: '593a98f803de49038fb02fd4', + }, + { + name: 'streaming', + slug: 'streaming', + objectID: '56744722958ef13879b9505d', + }, + { + name: 'sysadmin', + slug: 'sysadmin', + objectID: '56744721958ef13879b94aa2', + }, + { + name: 'build', + slug: 'build', + objectID: '56744723958ef13879b95552', + }, + { + name: 'smart home', + slug: 'smart-home', + objectID: '590d86fe042257bf29db782c', + }, + { + name: 'modules', + slug: 'modules', + objectID: '56744722958ef13879b95197', + }, + { + name: 'CDN', + slug: 'cdn', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496755229/pmzin0lidq2ld88qeba5.png', + objectID: '56744720958ef13879b947ae', + }, + { + name: '#the-technical-writing-bootcamp', + slug: 'the-technical-writing-bootcamp-1', + objectID: '5f732a92f955ec0a130f6290', + }, + { + name: 'Sublime Text', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1497046439/xny5lu0xfjzpfybrrl9c.png', + slug: 'sublime-text', + objectID: '56744723958ef13879b95216', + }, + { + name: 'Ember.js', + slug: 'emberjs', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1498115063/txor4lfourtkcofjipii.png', + objectID: '56744721958ef13879b94c17', + }, + { + name: 'Vuex', + slug: 'vuex', + objectID: '580209af0c9f06220778a866', + }, + { + name: 'wordpress plugins', + slug: 'wordpress-plugins', + objectID: '56744721958ef13879b94965', + }, + { + name: 'zsh', + slug: 'zsh', + objectID: '56744723958ef13879b95202', + }, + { + name: 'recruitment', + slug: 'recruitment', + objectID: '57b0b5a1fbdd622c03136428', + }, + { + name: 'Server side rendering', + slug: 'server-side-rendering', + objectID: '5759222f462c2daddc9ac412', + }, + { + name: 'Roadmap', + slug: 'roadmap', + objectID: '58cd6353557528fb61666e5d', + }, + { + name: 'hashnodebootcamp2', + slug: 'hashnodebootcamp2-1', + objectID: '5faec06b7fcc8d387fc0d1a6', + }, + { + name: 'Polymer', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450468312/zwtljjmofmpplvho1wfa.png', + slug: 'polymer', + objectID: '56744723958ef13879b954ab', + }, + { + name: 'Expo', + slug: 'expo', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1515394575880/SkuMI5gEM.jpeg', + objectID: '58cb5f69ecb020d9744a6487', + }, + { + name: 'xml', + slug: 'xml', + objectID: '56744721958ef13879b94b0b', + }, + { + name: 'tooling', + slug: 'tooling', + objectID: '56744723958ef13879b95335', + }, + { + name: 'canvas', + slug: 'canvas', + objectID: '56744722958ef13879b94f55', + }, + { + name: 'Backup', + slug: 'backup', + objectID: '57df9e894a6aa43e72a98a15', + }, + { + name: 'Explain like I am five', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1516175943338/SJ1IzFnVf.jpeg', + slug: 'explain-like-i-am-five', + objectID: '5991e91f0bcf15061f140b7f', + }, + { + name: 'embedded', + slug: 'embedded', + objectID: '571eb24785916079574f035e', + }, + { + name: 'bots', + slug: 'bots', + objectID: '56f2726a35c92c494c5e3a73', + }, + { + name: 'Homebrew', + slug: 'homebrew', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1502355995/yusx5q732shmiaypoq3f.png', + objectID: '56744722958ef13879b951e9', + }, + { + name: 'webdesign', + slug: 'webdesign', + objectID: '56744721958ef13879b949ec', + }, + { + name: 'styling', + slug: 'styling', + objectID: '580515064c0f5aee780a3c9b', + }, + { + name: 'Mozilla', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1477481389/i3tfvov4fuqfkg2yeddv.png', + slug: 'mozilla', + objectID: '56744721958ef13879b94c4f', + }, + { + name: 'javascript books', + slug: 'javascript-books', + objectID: '56744723958ef13879b953fa', + }, + { + name: 'Atom', + slug: 'atom', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1497040963/kh7an2akihm9tf1w5ab2.png', + objectID: '56744721958ef13879b94aa6', + }, + { + name: 'dev', + slug: 'dev', + objectID: '56744721958ef13879b948bc', + }, + { + name: 'Best of Hashnode', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1556009843016/9mcKMnTI3.png', + slug: 'best-of-hashnode', + objectID: '5c0c2ed6659f658d077550cf', + }, + { + name: 'Stack Overflow', + slug: 'stackoverflow', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1499949492/jff8br3fbln1yccb1tpb.png', + objectID: '56744721958ef13879b949d7', + }, + { + name: 'progressive web apps', + slug: 'progressive-web-apps', + objectID: '5702d00aabbcb496574bce11', + }, + { + name: 'animations', + slug: 'animations', + objectID: '56744721958ef13879b948b4', + }, + { + name: 'translation', + slug: 'translation', + objectID: '576ccb742d4c0ff55a8ae17a', + }, + { + name: 'desktop', + slug: 'desktop', + objectID: '56744721958ef13879b948ce', + }, + { + name: 'habits', + slug: 'habits', + objectID: '57e22bd48b1fca72b28833a4', + }, + { + name: '#codingNewbies', + slug: 'codingnewbies', + objectID: '5f7f9e43b8638504a7c122ed', + }, + { + name: 'google maps', + slug: 'google-maps', + objectID: '57496c3892b151fb90adc735', + }, + { + name: 'back4app', + slug: 'back4app', + objectID: '578bf0674416601b9574cb3b', + }, + { + name: 'Libraries', + slug: 'libraries', + objectID: '568ecddf91716a2d1dbadb19', + }, + { + name: 'prototyping', + slug: 'prototyping', + objectID: '56744723958ef13879b95241', + }, + { + name: 'Real Estate', + slug: 'real-estate', + objectID: '56ee695b5edec9d7189a0be5', + }, + { + name: 'cache', + slug: 'cache', + objectID: '567bfb342b926c3063c307dc', + }, + { + name: 'teaching', + slug: 'teaching', + objectID: '56744723958ef13879b955b7', + }, + { + name: 'multithreading', + slug: 'multithreading', + objectID: '56744723958ef13879b95300', + }, + { + name: 'opinion pieces', + slug: 'opinion-pieces', + objectID: '5f0ffe5eaa660c1c354c06fc', + }, + { + name: '.net core', + slug: 'net-core', + objectID: '57d7d0d0f72dd3705c16014a', + }, + { + name: 'freelance', + slug: 'freelance', + objectID: '56744722958ef13879b94e57', + }, + { + name: 'deployment automation', + slug: 'deployment-automation', + objectID: '56744722958ef13879b95067', + }, + { + name: 'icon', + slug: 'icon', + objectID: '56744723958ef13879b95289', + }, + { + name: 'Hashing', + slug: 'hashing', + objectID: '591fd9bfe1cc498f829bf264', + }, + { + name: 'boilerplate', + slug: 'boilerplate', + objectID: '56744723958ef13879b953b2', + }, + { + name: 'navigation', + slug: 'navigation', + objectID: '574125dadf1e4d3563843066', + }, + { + name: 'Geospatial', + slug: 'geospatial', + objectID: '5f25726a90ac4260edf35078', + }, + { + name: 'angular material', + slug: 'angular-material', + objectID: '57c3ba45cb80370904fc5b48', + }, + { + name: 'ios apps', + slug: 'ios-apps', + objectID: '56744721958ef13879b94ae2', + }, + { + name: 'wordpress themes', + slug: 'wordpress-themes', + objectID: '56744721958ef13879b94af8', + }, + { + name: 'k8s', + slug: 'k8s', + objectID: '58456f2afc2da7579e5f3ed0', + }, + { + name: 'Hugo', + slug: 'hugo', + objectID: '57ce27e495368c463b09804f', + }, + { + name: 'a11y', + slug: 'a11y', + objectID: '57aa00d170387a4ab0fe0cf8', + }, + { + name: 'webapps', + slug: 'webapps', + objectID: '56744721958ef13879b94b6f', + }, + { + name: 'features', + slug: 'features', + objectID: '56744722958ef13879b9515c', + }, + { + name: 'Prettier', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496483511/fdbnmvy2bkecx03csbom.png', + slug: 'prettier', + objectID: '592d689fa6614cba3f738146', + }, + { + name: 'WebRTC', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1465362672/yzdode4h9er49uccfvbu.png', + slug: 'webrtc', + objectID: '56744722958ef13879b94f0e', + }, + { + name: 'web developers', + slug: 'web-developers', + objectID: '56744722958ef13879b94e6b', + }, + { + name: 'Emails', + slug: 'emails', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496150225/u6kgjtvvqkkefncoyovl.png', + objectID: '57458b6c92b151fb90adc493', + }, + { + name: 'bundling', + slug: 'bundling', + objectID: '5777dbd757675ec2fcfd09fb', + }, + { + name: 'localstorage', + slug: 'localstorage', + objectID: '56744722958ef13879b95107', + }, + { + name: 'Earth Engine', + slug: 'earth-engine', + objectID: '5f26246490ac4260edf3596e', + }, + { + name: 'test driven development', + slug: 'test-driven-development', + objectID: '56744723958ef13879b95595', + }, + { + name: 'S3', + slug: 's3', + objectID: '588f13c9ae0398620533ed80', + }, + { + name: 'message queue', + slug: 'message-queue', + objectID: '5688d3a00716b983ccc79766', + }, + { + name: 'mentor', + slug: 'mentor', + objectID: '56744721958ef13879b94dc8', + }, + { + name: 'websites', + slug: 'websites', + objectID: '56744721958ef13879b94c58', + }, + { + name: 'maven', + slug: 'maven', + objectID: '56744723958ef13879b95232', + }, + { + name: 'turkish', + slug: 'turkish', + objectID: '5f61e4c5dc74720d9b85ed19', + }, + { + name: 'MEAN Stack', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1472484615/gnwpbhw8nqe9aj4frzzh.jpg', + slug: 'mean', + objectID: '56744721958ef13879b94bc0', + }, + { + name: 'Emacs', + slug: 'emacs', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1502978326/qzxw4oqebc9su0pzpvqt.png', + objectID: '56744721958ef13879b949cf', + }, + { + name: 'Preact', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1459503980/i2tjk2olam4wqr7kyqet.jpg', + slug: 'preact', + objectID: '56fe1c265db965849f7b379f', + }, + { + name: 'Future', + slug: 'future', + objectID: '5699066c72ca04ea5d79faa1', + }, + { + name: 'es2015', + slug: 'es2015', + objectID: '5678d29ae0956f4764b3edfb', + }, + { + name: 'sales', + slug: 'sales', + objectID: '58cd06ec68e963fa61d68d7f', + }, + { + name: 'versioning', + slug: 'versioning', + objectID: '578b9582b1a4a0d81ffbb1fe', + }, + { + name: 'computer', + slug: 'computer', + objectID: '57628dcd820dd45f3fbd8eb6', + }, + { + name: 'cookies', + slug: 'cookies', + objectID: '56744721958ef13879b94a7d', + }, + { + name: 'proxy', + slug: 'proxy', + objectID: '56744721958ef13879b94917', + }, + { + name: 'Drupal', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1490298700/uqjjgtu4a1lpqxjcdshb.png', + slug: 'drupal', + objectID: '57444da29ade925885158cb0', + }, + { + name: 'graphics', + slug: 'graphics', + objectID: '578378ebfcb4d586db19492c', + }, + { + name: 'Scraping', + slug: 'scraping', + objectID: '5834805addfa96eb7c5d478b', + }, + { + name: 'typography', + slug: 'typography', + objectID: '56744721958ef13879b94944', + }, + { + name: 'marketplace', + slug: 'marketplace', + objectID: '586d0df986a586aec93327e1', + }, + { + name: 'OOPS', + slug: 'oops', + objectID: '5713f234162bdaad9f92b0c1', + }, + { + name: 'production', + slug: 'production', + objectID: '57067a5e115103c3b097818b', + }, + { + name: 'process', + slug: 'process', + objectID: '5694af13c1c0117cef5aea67', + }, + { + name: 'API basics ', + slug: 'api-basics', + objectID: '5f8dd8dffc30613d8cd9379a', + }, + { + name: 'PaaS', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1461694808/ip8ls4fz7nxi01uhvmch.jpg', + slug: 'paas', + objectID: '56744721958ef13879b94ddc', + }, + { + name: 'Website design', + slug: 'website-design', + objectID: '5866a4c0b99398bc30c43daa', + }, + { + name: 'SSR', + slug: 'ssr', + objectID: '5747fbbd9ade925885158f94', + }, + { + name: 'i18n', + slug: 'i18n', + objectID: '568f1af6525da8063d08fb2d', + }, + { + name: 'ci', + slug: 'ci', + objectID: '56744721958ef13879b94a16', + }, + { + name: 'centos', + slug: 'centos', + objectID: '57a67d66e6998a66b06f40e6', + }, + { + name: 'social', + slug: 'social', + objectID: '5709b8c3115103c3b0978327', + }, + { + slug: 'go-cjidm6n1p00lpq9s29dy2bsiq', + objectID: '5b218969e0d20c016e052f69', + }, + { + name: 'patterns', + slug: 'patterns', + objectID: '56744721958ef13879b94db8', + }, + { + name: 'workathome', + slug: 'workathome', + objectID: '5f19d647cef915427a14ca2c', + }, + { + name: 'selenium-webdriver', + slug: 'selenium-webdriver-1', + objectID: '5f0c3b23880268625262ba76', + }, + { + name: 'macbook', + slug: 'macbook', + objectID: '56744721958ef13879b94dc2', + }, + { + name: 'Voice', + slug: 'voice', + objectID: '590102fd9863a67f4cc93055', + }, + { + name: 'orm', + slug: 'orm', + objectID: '56b632b3a0967efc587c7d24', + }, + { + name: 'Bitbucket', + slug: 'bitbucket', + objectID: '580e08175fec191d85b14fc7', + }, + { + name: 'dashboard', + slug: 'dashboard', + objectID: '56b45894500fd79e29bd7bf3', + }, + { + name: 'composer', + slug: 'composer', + objectID: '56b234f2a71b2df12bea6e43', + }, + { + name: 'Remote Sensing ', + slug: 'remote-sensing', + objectID: '5f25726a90ac4260edf35077', + }, + { + name: 'ELM', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1491295764/mh4haipogztgffbnt4y4.png', + slug: 'elm', + objectID: '567bbdf52b926c3063c30713', + }, + { + name: 'spark', + slug: 'spark', + objectID: '56744722958ef13879b95180', + }, + { + name: 'ionic framework', + slug: 'ionic-framework', + objectID: '56744723958ef13879b95254', + }, + { + name: 'robotics', + slug: 'robotics', + objectID: '56744723958ef13879b953a2', + }, + { + name: 'twilio', + slug: 'twilio', + objectID: '57e57691ef99cf03582fe2b3', + }, + { + name: 'mvp', + slug: 'mvp', + objectID: '56744723958ef13879b95572', + }, + { + name: 'medium', + slug: 'medium', + objectID: '56744721958ef13879b94871', + }, + { + slug: 'devjourney', + objectID: '5e43fc8b8c89a92316ccd6c2', + }, + { + name: 'azure certified', + slug: 'azure-certified', + objectID: '5f28ea6e3e336e0de23093c0', + }, + { + name: 'PostCSS', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1459504796/nipxkl4fu2zf7sqfl5fj.jpg', + slug: 'postcss', + objectID: '56744721958ef13879b94e29', + }, + { + name: 'AR', + slug: 'ar', + objectID: '586cd5ae615b9737b81b3ddb', + }, + { + name: 'photoshop', + slug: 'photoshop', + objectID: '5674471d958ef13879b94796', + }, + { + name: 'crm', + slug: 'crm', + objectID: '580df8332a45c6fdcb43fa14', + }, + { + name: 'funny', + slug: 'funny', + objectID: '56744723958ef13879b9547b', + }, + { + name: 'Frontend frameworks', + slug: 'frontend-frameworks', + objectID: '56a0676792921b8f79d360f5', + }, + { + name: 'technology stack', + slug: 'technology-stack', + objectID: '56b99e6cacee1cee848702ec', + }, + { + name: 'jekyll', + slug: 'jekyll', + objectID: '56744721958ef13879b948e8', + }, + { + name: 'cloudinary', + slug: 'cloudinary', + objectID: '5678a007e0956f4764b3ed53', + }, + { + name: 'queue', + slug: 'queue', + objectID: '56744723958ef13879b952c0', + }, + { + name: 'sdk', + slug: 'sdk', + objectID: '56f972afea33a5b266f2fe04', + }, + { + name: 'styleguide', + slug: 'styleguide', + objectID: '56744722958ef13879b951a4', + }, + { + name: 'Meta', + slug: 'meta', + objectID: '58b6c12eb2566b537ac16cb7', + }, + { + name: 'CORS', + slug: 'cors', + objectID: '5676154ae64b075af6ade54e', + }, + { + name: 'props', + slug: 'props', + objectID: '5f2959166face9141b78fa82', + }, + { + name: 'Aurelia', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1453819641/j5c2dwhqwvzh9apczioe.jpg', + slug: 'aurelia', + objectID: '56744722958ef13879b94f49', + }, + { + name: 'YAML', + slug: 'yaml', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1499159858/ude93xlquvvbxbw5xkg4.png', + objectID: '56d9941a489cf60d99aa90c4', + }, + { + name: 'EQCSS', + slug: 'eqcss', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1520491825399/HJF4pLCdz.png', + objectID: '5784baeefcb4d586db194a64', + }, + { + name: 'layout', + slug: 'layout', + objectID: '56d2f72f1878dfef04178e6e', + }, + { + name: 'flow', + slug: 'flow', + objectID: '56744721958ef13879b94a2e', + }, + { + name: 'admin', + slug: 'admin', + objectID: '57778738f271844db9e1eb41', + }, + { + name: 'tech', + slug: 'tech-cilba77mg0010ya53d05qtkuu', + objectID: '56d7498b6722ee828dbeafe3', + }, + { + name: 'Cordova', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1520160966692/ByyCeLt_M.jpeg', + slug: 'cordova', + objectID: '56744721958ef13879b94a1b', + }, + { + name: 'Build tool', + slug: 'build-tool', + objectID: '56744722958ef13879b950a3', + }, + { + name: 'vps', + slug: 'vps', + objectID: '56744722958ef13879b951c4', + }, + { + name: 'gradle', + slug: 'gradle', + objectID: '56744722958ef13879b95164', + }, + { + name: 'ebook', + slug: 'ebook', + objectID: '56744721958ef13879b948f0', + }, + { + slug: 'hooks', + objectID: '5c1778c2252f6d5b707ae169', + }, + { + name: 'gmail', + slug: 'gmail', + objectID: '58596eaaeb509c3ba23d4c87', + }, + { + name: 'inheritance', + slug: 'inheritance', + objectID: '573349a7181d813d33746639', + }, + { + name: 'stripe', + slug: 'stripe', + objectID: '56744723958ef13879b9554c', + }, + { + name: '#sucessful blogging', + slug: 'sucessful-blogging', + objectID: '5fb801781b7ab0041800c67c', + }, + { + name: 'watercooler', + slug: 'watercooler', + objectID: '5f36e920877a013acb03cd10', + }, + { + name: 'eloquent', + slug: 'eloquent', + objectID: '56ed7b765edec9d7189a0b73', + }, + { + name: 'image', + slug: 'image', + objectID: '56744721958ef13879b948fc', + }, + { + name: 'book', + slug: 'book', + objectID: '56744720958ef13879b947b2', + }, + { + name: 'router', + slug: 'router', + objectID: '56744723958ef13879b95210', + }, + { + name: '#ChooseToChallenge', + slug: 'choosetochallenge', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1614605641484/1qeXO9QXg.png', + objectID: '603cc4b61f91337d465bee68', + }, + { + name: 'geemap', + slug: 'geemap', + objectID: '5f465bac9b597625e2dec06a', + }, + { + name: 'ASP', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1451108441/qt4zgtcynwzy2rjvk0t6.png', + slug: 'asp', + objectID: '5674471d958ef13879b9477c', + }, + { + name: 'front end', + slug: 'front-end', + objectID: '56744723958ef13879b95554', + }, + { + name: 'SVG Animation', + slug: 'svg-animation', + objectID: '569cd00972ca04ea5d79fca2', + }, + { + name: 'meteorjs', + slug: 'meteorjs', + objectID: '56744723958ef13879b9558f', + }, + { + name: 'nest', + slug: 'nest', + objectID: '583ca6c6ddfa96eb7c5d896f', + }, + { + name: 'podcasts', + slug: 'podcasts', + objectID: '56744722958ef13879b95194', + }, + { + name: 'designing', + slug: 'designing', + objectID: '56744721958ef13879b94bd9', + }, + { + name: 'Clerk.dev', + slug: 'clerkdev', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1625245518596/nW4Y4hYHH.png', + objectID: '60df384f03707d644a4feb38', + }, + { + name: 'web servers', + slug: 'web-servers', + objectID: '56744721958ef13879b94a88', + }, + { + name: 'function', + slug: 'function', + objectID: '56744720958ef13879b947ea', + }, + { + name: 'DraftJS', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1491387822/zwctxp8006exywfg17pd.jpg', + slug: 'draftjs', + objectID: '56f4d674990bca7c25e99318', + }, + { + name: 'redux-saga', + slug: 'redux-saga', + objectID: '5776f09cf271844db9e1eb05', + }, + { + name: 'responsive designs', + slug: 'responsive-designs', + objectID: '56744721958ef13879b94d5b', + }, + { + name: 'Socket.io', + slug: 'socketio-cijy9e2c700c6vm5357q8xsf3', + objectID: '56aa0ea0960088c21db4d77a', + }, + { + name: 'OSS', + slug: 'oss', + objectID: '581875942ca37f164781f4b1', + }, + { + name: 'chartjs', + slug: 'chartjs', + objectID: '56744721958ef13879b94993', + }, + { + slug: 'deno', + objectID: '5cca9dd21077bc6278d31cc7', + }, + { + slug: 'cisco', + objectID: '5d9cc879f74b4d4660eede6b', + }, + { + name: 'emoji', + slug: 'emoji', + objectID: '571751b03c2a84abc85a1e11', + }, + { + name: 'await', + slug: 'await', + objectID: '56cbdb23b70682283f9edeb7', + }, + { + name: 'hibernate', + slug: 'hibernate', + objectID: '56744723958ef13879b955ac', + }, + { + name: 'Julia', + slug: 'julia', + objectID: '58749cfee6e8728a7f133535', + }, + { + name: 'vagrant', + slug: 'vagrant', + objectID: '56744721958ef13879b94a24', + }, + { + name: 'grid', + slug: 'grid', + objectID: '56744723958ef13879b952d3', + }, + { + name: 'naming', + slug: 'naming', + objectID: '5747655e92b151fb90adc622', + }, + { + name: 'error', + slug: 'error', + objectID: '56744721958ef13879b9496b', + }, + { + name: 'templates', + slug: 'templates', + objectID: '56744721958ef13879b94853', + }, + { + name: 'design and architecture', + slug: 'design-and-architecture', + objectID: '5f38bd060801bf3f76e5f9e5', + }, + { + name: 'Haskell', + slug: 'haskell', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496182720/z8gaemi99htfdnclmicj.png', + objectID: '56744723958ef13879b9537a', + }, + { + name: 'PayPal', + slug: 'paypal', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1504679615/ds7ftsav58hjetqeeqq3.jpg', + objectID: '56ee65cfcb06805ba9b7c66d', + }, + { + name: 'native', + slug: 'native', + objectID: '56744723958ef13879b9530a', + }, + { + name: 'maps', + slug: 'maps', + objectID: '574853c092b151fb90adc6b1', + }, + { + name: 'class', + slug: 'class', + objectID: '573c6a7803e642f04bb03d47', + }, + { + name: 'mobile application development', + slug: 'mobile-application-development', + objectID: '56744721958ef13879b949b7', + }, + { + name: 'The Clerk Hackathon on Hashnode', + slug: 'clerkhackathon', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1625245553278/S6gbVfdNp.png', + objectID: '60df381403707d644a4feb2f', + }, + { + name: 'web3', + slug: 'web3', + logo: null, + objectID: '59df443dfb1deef9745a4ef0', + }, + { + slug: 'wsl', + objectID: '595ed5ae8f1dffe434c00000', + }, + { + name: 'geolocation', + slug: 'geolocation', + objectID: '579f2a6bb5724a7273404206', + }, + { + name: 'coroutines', + slug: 'coroutines', + objectID: '56facb5fbac95334fc2fa50b', + }, + { + name: 'object', + slug: 'object', + objectID: '56744722958ef13879b9505b', + }, + { + name: 'debug', + slug: 'debug', + objectID: '56744721958ef13879b94922', + }, + { + name: 'freelancer', + slug: 'freelancer', + objectID: '56744723958ef13879b9550a', + }, + { + name: 'Cosmic JS', + slug: 'cosmic-js', + objectID: '590743c50e14932382c2ad5a', + }, + { + name: 'WhoIsHiring', + slug: 'whoishiring', + objectID: '5d946e4ec510092a323bc34a', + }, + { + name: 'ide', + slug: 'ide', + objectID: '56744721958ef13879b94879', + }, + { + name: 'pair programming', + slug: 'pair-programming', + objectID: '56744722958ef13879b95071', + }, + { + slug: 'health-cjaeh844x02vvo3wtj5r2s75q', + objectID: '5a189c9fee67ea9312f02c18', + }, + { + name: 'code smell', + slug: 'code-smell', + objectID: '57361d1cffaaff8febd12cee', + }, + { + name: 'V8', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1450536374/hnlihf5tv3veoxx1igpa.jpg', + slug: 'v8', + objectID: '56744723958ef13879b954f0', + }, + { + name: 'Erlang', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1475321105/tp4co0lnolmi4x7ln7f6.jpg', + slug: 'erlang', + objectID: '56744722958ef13879b94e60', + }, + { + name: 'Clojure', + slug: 'clojure', + objectID: '56b01bce0a7ca0c6f70c1ef8', + }, + { + name: 'rabbitmq', + slug: 'rabbitmq', + objectID: '56a4fc8ec84f2c6913b8e9f9', + }, + { + name: 'Sketch', + slug: 'sketch', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1522266937699/ByGS7dtcG.jpeg', + objectID: '56744722958ef13879b94e7d', + }, + { + name: 'backend as a service', + slug: 'backend-as-a-service', + objectID: '577f08da16a33191db042f9e', + }, + { + name: 'mocha', + slug: 'mocha', + objectID: '56744721958ef13879b94a3a', + }, + { + name: 'stream', + slug: 'stream', + objectID: '56744723958ef13879b95580', + }, + { + name: 'container', + slug: 'container', + objectID: '56744721958ef13879b94ad6', + }, + { + name: 'woocommerce', + slug: 'woocommerce', + objectID: '56744720958ef13879b94808', + }, + { + name: 'webperf', + slug: 'webperf-ciur6tor503mfpx53ic2rvrs2', + objectID: '5810e609a901f605c438b691', + }, + { + name: 'form', + slug: 'form', + objectID: '56744722958ef13879b95138', + }, + { + name: '#⛺the-technical-writing-bootcamp', + slug: 'the-technical-writing-bootcamp', + objectID: '5f6d12a1005ded5336f6f534', + }, + { + name: 'HTML Emails', + slug: 'html-emails', + objectID: '56a1b72a72ca04ea5d7a003b', + }, + { + name: 'PHPUnit', + slug: 'phpunit', + objectID: '57ea3f2397eba84632db561a', + }, + { + name: 'http2', + slug: 'http2', + objectID: '56744721958ef13879b94a76', + }, + { + name: 'kibana', + slug: 'kibana', + objectID: '56744721958ef13879b9486d', + }, + { + name: 'osx', + slug: 'osx', + objectID: '56744723958ef13879b9523e', + }, + { + name: 'ghost', + slug: 'ghost', + objectID: '56744722958ef13879b951c6', + }, + { + name: 'hybrid apps', + slug: 'hybrid-apps', + objectID: '56744721958ef13879b94e08', + }, + { + name: 'virtual dom', + slug: 'virtual-dom', + objectID: '56744720958ef13879b947fc', + }, + { + name: 'editor', + slug: 'editor', + objectID: '5674471d958ef13879b94781', + }, + { + name: 'Session', + slug: 'session', + objectID: '57c8241860189c8953a67f81', + }, + { + name: 'parse server', + slug: 'parse-server', + objectID: '578ae4e4b1a4a0d81ffbb1bb', + }, + { + slug: 'tailwind', + objectID: '5ddd484e94c050e177a6aa7e', + }, + { + name: 'mongo', + slug: 'mongo', + objectID: '56744721958ef13879b94a93', + }, + { + name: 'what successful blogging means to me', + slug: 'what-successful-blogging-means-to-me', + objectID: '5faff31939a1f54636490632', + }, + { + name: 'windows server', + slug: 'windows-server', + objectID: '5f1dd296f4016901885ccbf8', + }, + { + name: 'Objective C', + slug: 'objective-c', + objectID: '56744721958ef13879b94bfe', + }, + { + name: 'vr', + slug: 'vr', + objectID: '5674d5807446b75bb60141f8', + }, + { + name: 'microsoft edge', + slug: 'microsoft-edge', + objectID: '56744720958ef13879b9480c', + }, + { + name: 'zurb', + slug: 'zurb', + objectID: '56744721958ef13879b94a36', + }, + { + name: 'promise', + slug: 'promise', + objectID: '56744721958ef13879b9488b', + }, + { + slug: 'growth', + objectID: '5a64fbe6e30c5b6655a6a4df', + }, + { + name: 'Meetup', + slug: 'meetup', + objectID: '56d9b1b0e853431899d036ce', + }, + { + name: 'modal', + slug: 'modal', + objectID: '56ace1e6cc975f0cc6878bc0', + }, + { + name: 'Benchmark', + slug: 'benchmark', + objectID: '5680fde5aeae5c9e229cf8e1', + }, + { + name: 'Lua', + slug: 'lua', + objectID: '5726e4fac1f71f91e880ad2b', + }, + { + name: 'perl', + slug: 'perl', + objectID: '56744722958ef13879b9512e', + }, + { + name: 'postgres', + slug: 'postgres', + objectID: '56744722958ef13879b94f0b', + }, + { + name: 'Element Queries', + slug: 'element-queries', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1498763362/lvxxrbdpyjwm1c8pxjck.png', + objectID: '581a55d4c055bbfb46d880da', + }, + { + name: 'logstash', + slug: 'logstash', + objectID: '56744723958ef13879b953c3', + }, + { + name: 'FaaS', + slug: 'faas', + objectID: '58cbe70848830eae2c11fdf4', + }, + { + name: 'laravel ', + slug: 'laravel-cikr40o0m01r27453d8eux03p', + objectID: '56c4ad109c7666b0da73f29d', + }, + { + name: 'immutable', + slug: 'immutable', + objectID: '56744722958ef13879b9514a', + }, + { + slug: 'pmlcourse', + objectID: '5e4a6b728c89a92316cd4a33', + }, + { + name: 'alternative', + slug: 'alternative', + objectID: '58085c202a45c6fdcb43f3c3', + }, + { + name: 'Smalltalk', + slug: 'smalltalk', + objectID: '57da642fd17cab545caba0d3', + }, + { + name: 'cpu', + slug: 'cpu', + objectID: '57ae11c08dae0c2f1d4420cb', + }, + { + name: 'survey', + slug: 'survey', + objectID: '56744721958ef13879b949c2', + }, + { + name: 'Cassandra', + slug: 'cassandra', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1516175375653/BJdMlF34z.jpeg', + objectID: '56744721958ef13879b9490e', + }, + { + name: 'css3 animation', + slug: 'css3-animation', + objectID: '56744722958ef13879b94ef0', + }, + { + name: 'Semantic UI', + slug: 'semantic-ui', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1496405644/rsuq8bqv2aoqqnq8ckzw.png', + objectID: '56744723958ef13879b95206', + }, + { + name: 'restful', + slug: 'restful', + objectID: '56744723958ef13879b952c6', + }, + { + name: 'Deploy ', + slug: 'deploy', + objectID: '57578b6282cbbab8dcd47842', + }, + { + name: 'solid', + slug: 'solid', + objectID: '56e6d5598c0bb8288a559c97', + }, + { + name: 'font awesome', + slug: 'font-awesome', + objectID: '56744721958ef13879b9492f', + }, + { + slug: 'flutter-cjxern4nz000zx6s1d95hxw7x', + objectID: '5d14d342867d9aba094fd8f5', + }, + { + slug: 'nestjs', + objectID: '59e46480ebcd60373ac04db3', + }, + { + name: 'junit', + slug: 'junit', + objectID: '57935f8804cd973c9154652c', + }, + { + name: 'TLS', + slug: 'tls', + objectID: '56a6742dc84f2c6913b8eac2', + }, + { + name: 'NetworkAutomation', + slug: 'networkautomation', + objectID: '5f9da80a701b426a980950db', + }, + { + name: 'Less', + slug: 'less', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1509610482/o0vybjlg9bncpy4tq0x0.png', + objectID: '56744721958ef13879b949ef', + }, + { + name: 'bdd', + slug: 'bdd', + objectID: '56744721958ef13879b94aa0', + }, + { + name: 'baas', + slug: 'baas', + objectID: '56744723958ef13879b953ad', + }, + { + name: 'MVVM', + slug: 'mvvm', + objectID: '56a0ee5172ca04ea5d79ff9d', + }, + { + name: 'responsive', + slug: 'responsive', + objectID: '56744723958ef13879b95520', + }, + { + name: 'Error Tracking', + slug: 'error-tracking', + objectID: '58d2b7fa440c92dcfd4c5801', + }, + { + name: 'media queries', + slug: 'media-queries', + objectID: '56744721958ef13879b949f2', + }, + { + slug: '2articles1week-1', + objectID: '5f0b171bf80d68509e50d2c1', + }, + { + name: 'RethinkDB', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1455115223/oluebzm7a23ayicyyr93.png', + slug: 'rethinkdb', + objectID: '5674471d958ef13879b94774', + }, + { + name: '.NET', + slug: 'net-cikag7ck9004u4153550rzs6c', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1515074840143/Bkl7B3sXz.jpeg', + objectID: '56b54dae8dabdc6142c1ac87', + }, + { + name: 'codeigniter', + slug: 'codeigniter', + objectID: '577d5a59f5d62870bc1e3436', + }, + { + name: 'web dev', + slug: 'web-dev', + objectID: '56744722958ef13879b951f5', + }, + { + name: 'Question', + slug: 'question', + objectID: '56b4ee44ed97cf2d3faa9e85', + }, + { + name: 'passport', + slug: 'passport', + objectID: '56744723958ef13879b955b5', + }, + { + slug: 'strapi', + objectID: '5a60b356acaaf63131a26558', + }, + { + name: 'ECS', + slug: 'ecs', + objectID: '58456f2afc2da7579e5f3ece', + }, + { + name: 'Motivation ', + slug: 'motivation-1', + objectID: '5f95c76540346172a86c28c1', + }, + { + name: 'KoaJS', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1472485426/mypuzb6iv30nivcnj67f.jpg', + slug: 'koa', + objectID: '56744720958ef13879b947fb', + }, + { + name: 'HapiJS', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1472485161/dtjd0iyqgwiqqksg3see.png', + slug: 'hapijs', + objectID: '56744721958ef13879b94dd2', + }, + { + name: 'Java Framework', + slug: 'java-framework', + objectID: '5674471d958ef13879b9476f', + }, + { + name: 'NativeScript', + slug: 'nativescript', + objectID: '578f329a5460288cdeb6f281', + }, + { + name: 'realtime apps', + slug: 'realtime-apps', + objectID: '56744721958ef13879b94a1e', + }, + { + name: 'DevRant', + slug: 'devrant', + objectID: '5d946e601971c92f3298b281', + }, + { + name: 'amp', + slug: 'amp', + objectID: '56744723958ef13879b9556c', + }, + { + name: 'grunt', + slug: 'grunt', + objectID: '56744723958ef13879b9547f', + }, + { + name: 'es5', + slug: 'es5', + objectID: '56744722958ef13879b94e5a', + }, + { + name: 'servers', + slug: 'servers', + objectID: '56744722958ef13879b94e49', + }, + { + name: 'rss', + slug: 'rss', + objectID: '56744721958ef13879b949e6', + }, + { + slug: 'flask-cje4g3tgk00wdm0wtaepqxd29', + objectID: '5a94378b2e2d22686d3319ec', + }, + { + slug: 'vpn', + objectID: '5a66e6714c88fdb11626d866', + }, + { + name: 'writing ', + slug: 'writing-1', + objectID: '5f541f8fd34e0b0a2135b7ac', + }, + { + name: 'CouchDB', + slug: 'couchdb', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1516182897537/HJ9_TqnNG.jpeg', + objectID: '56744722958ef13879b94e52', + }, + { + name: 'responsive design', + slug: 'responsive-design', + objectID: '568104b15d0b198322f23be3', + }, + { + name: 'functional', + slug: 'functional', + objectID: '56744723958ef13879b9541e', + }, + { + name: 'es7', + slug: 'es7', + objectID: '56744722958ef13879b9516e', + }, + { + name: 'flowtype', + slug: 'flowtype', + objectID: '57a07b7703626115baea275d', + }, + { + name: 'airbnb', + slug: 'airbnb', + objectID: '56744721958ef13879b9495f', + }, + { + slug: 'swiftui', + objectID: '5d117acd15a6b27b36bb063b', + }, + { + name: 'offline', + slug: 'offline', + objectID: '57ff8bed7a5d253b23bc40dd', + }, + { + name: 'css preprocessors', + slug: 'css-preprocessors', + objectID: '56744723958ef13879b95314', + }, + { + name: 'web app', + slug: 'web-app', + objectID: '56744722958ef13879b950de', + }, + { + name: 'beta', + slug: 'beta', + objectID: '56c6bd7d46a50cb768ba7d04', + }, + { + name: 'webdriver', + slug: 'webdriver', + objectID: '56a1bb2a92921b8f79d3620e', + }, + { + name: 'Algolia', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1454497142/tmtr6swfz0tqfeiphd0q.png', + slug: 'algolia', + objectID: '56744723958ef13879b95404', + }, + { + name: 'tech stacks', + slug: 'tech-stacks', + objectID: '56744721958ef13879b94aea', + }, + { + name: 'relay', + slug: 'relay', + objectID: '56744720958ef13879b947a8', + }, + { + name: 'Sequelize', + slug: 'sequelize', + objectID: '56bf8908f7a8a564cd3cf417', + }, + { + name: 'CoffeeScript', + slug: 'coffeescript', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1524116531939/ry2EnsS2M.jpeg', + objectID: '56744722958ef13879b9519f', + }, + { + name: 'browserify', + slug: 'browserify', + objectID: '56744721958ef13879b94c51', + }, + { + slug: 'rtos', + objectID: '5e94317328f1a84f59c49fb9', + }, + { + slug: 'spanish', + objectID: '5d24dd07963b3099469e31b1', + }, + { + name: 'universal', + slug: 'universal', + objectID: '5691098591906f99ef523690', + }, + { + name: 'software design', + slug: 'software-design', + objectID: '56744721958ef13879b94acd', + }, + { + name: 'CSS Modules', + slug: 'css-modules', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1502977775/w0xxhrabmj1zhddsdiu1.png', + objectID: '56bf8908f7a8a564cd3cf415', + }, + { + name: 'PhpStorm', + slug: 'phpstorm', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1497046152/nmpeb8i0lo2zofxg7xo5.png', + objectID: '56eae87928492b76a9948344', + }, + { + name: 'scaling', + slug: 'scaling', + objectID: '56744721958ef13879b94aa9', + }, + { + name: 'tool', + slug: 'tool', + objectID: '568bb9dbe99c5444f3233892', + }, + { + name: 'charting library', + slug: 'charting-library', + objectID: '56744721958ef13879b94e41', + }, + { + slug: 'devblog', + objectID: '5cdbcce2d7898f811504a6c9', + }, + { + name: 'IWD2021', + slug: 'iwd2021', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1614605663765/dl9O9JyP9.png', + objectID: '603cecbcc8eb04017922ce83', + }, + { + slug: 'cpp-ck4ra5k7300nlv2s1jbkdp2qh', + objectID: '5e08e075bcc8c0ce78e93263', + }, + { + name: 'smtp', + slug: 'smtp', + objectID: '56744723958ef13879b953c9', + }, + { + name: 'plugin', + slug: 'plugin', + objectID: '56744722958ef13879b94ff8', + }, + { + name: 'cto', + slug: 'cto', + objectID: '56744720958ef13879b9480f', + }, + { + name: '100DaysOfCloud', + slug: '100daysofcloud', + objectID: '5f216568938147308462a35b', + }, + { + name: 'PhoneGap', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1475235526/igis5i1twypixaebdkun.jpg', + slug: 'phonegap', + objectID: '56744720958ef13879b947fa', + }, + { + name: 'SailsJS', + logo: 'https://res.cloudinary.com/hashnode/image/upload/v1472484652/puabwwilk0dvwv9gsepb.png', + slug: 'sailsjs', + objectID: '56744723958ef13879b9527a', + }, + { + name: 'socket', + slug: 'socket', + objectID: '576bd575956de5c931689074', + }, + { + name: 'wasm', + slug: 'wasm', + objectID: '57612cfa7e4505f8314fb29a', + }, + { + name: 'rxjava', + slug: 'rxjava', + objectID: '56d93d14696d94e491c06f47', + }, + { + name: 'Testing Library', + slug: 'testing-library', + logo: 'https://cdn.hashnode.com/res/hashnode/image/upload/v1618896704282/9Z3cbqhmn.png', + objectID: '607e6751eb2bd30d2d22a556', + }, + { + name: 'c#', + slug: 'c-cikbdqjwh0042l553122kmxlz', + objectID: '56b629b2e6740d0959b6f3d9', + }, + { + name: 'Alexa', + slug: 'alexa', + objectID: '57bb2f081351c2290bba1d24', + }, + { + name: 'mern-stack', + slug: 'mern-stack', + objectID: '56c752ab34d45a99221aa34f', + }, + { + name: 'microservice', + slug: 'microservice', + objectID: '56744723958ef13879b95421', + }, + { + name: 'lodash', + slug: 'lodash', + objectID: '56744722958ef13879b95162', + }, + { + name: 'code splitting', + slug: 'code-splitting', + objectID: '56e17a0f5d4f204da59e0058', + }, + { + name: 'GraphQL ', + slug: 'graphql-cintl8ori01p0y353nth5857g', + objectID: '572a9b9f109fb69b463406e9', + }, + { + name: 'isomorphic apps', + slug: 'isomorphic-apps', + objectID: '56744723958ef13879b95505', + }, + { + name: 'internet explorer', + slug: 'internet-explorer', + objectID: '56744721958ef13879b94c7b', + }, + { + name: 'mobile app', + slug: 'mobile-app', + objectID: '576934c7a841f03b9338c6b3', + }, +]; diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..d372bd9074372d71aa0d0abda47d34276ec48b4b --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.provider.ts @@ -0,0 +1,1096 @@ +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { timer } from '@gitroom/helpers/utils/timer'; +import dayjs from 'dayjs'; +import { + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto'; +import { Integration } from '@prisma/client'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; + +@Rules( + "Instagram should have at least one attachment, if it's a story, it can have only one picture" +) +export class InstagramProvider + extends SocialAbstract + implements SocialProvider +{ + identifier = 'instagram'; + name = 'Instagram\n(Facebook Business)'; + isBetweenSteps = true; + toolTip = 'Instagram must be business and connected to a Facebook page'; + scopes = [ + 'instagram_basic', + 'pages_show_list', + 'pages_read_engagement', + 'business_management', + 'instagram_content_publish', + 'instagram_manage_comments', + 'instagram_manage_insights', + ]; + override maxConcurrentJob = 400; + editor = 'normal' as const; + dto = InstagramDto; + maxLength() { + return 2200; + } + + override async checkValidity( + [firstPost]: Array, + settings: any + ): Promise { + if (!firstPost?.length) { + return 'Should have at least one media'; + } + if (firstPost.length > 10) { + return 'Instagram carousel only supports up to 10 media attachments'; + } + if (this.assetBoolean(settings?.is_trial_reel)) { + if ((firstPost?.length ?? 0) > 1) { + return 'Trial Reels can only have one video'; + } + const hasVideo = firstPost?.some( + (f) => (f?.path?.indexOf?.('mp4') ?? -1) > -1 + ); + if (!hasVideo) { + return 'Trial Reels must be a video'; + } + } + if (settings?.audio?.id) { + if (settings?.post_type === 'story') { + return 'Audio can only be added to Reels, not to Stories'; + } + if ((firstPost?.length ?? 0) > 1) { + return 'Audio can only be added to a single video Reel'; + } + const hasVideo = firstPost?.some( + (f) => (f?.path?.indexOf?.('mp4') ?? -1) > -1 + ); + if (!hasVideo) { + return 'Audio can only be added to a video Reel'; + } + } + return true; + } + + async refreshToken(refresh_token: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + public override handleErrors( + body: string, + status: number + ): + | { + type: 'refresh-token' | 'bad-body' | 'retry'; + value: string; + } + | undefined { + if (body.indexOf('An unknown error occurred') > -1) { + return { + type: 'retry' as const, + value: 'An unknown error occurred, please try again later', + }; + } + if (body.indexOf('2207081') > -1) { + return { + type: 'bad-body' as const, + value: "This account doesn't support Trial Reels", + }; + } + + if ( + body.indexOf('REVOKED_ACCESS_TOKEN') > -1 || + body.indexOf('"error_subcode":33') > -1 + ) { + return { + type: 'refresh-token' as const, + value: + 'Something is wrong with your connected user, please re-authenticate', + }; + } + + if ( + body.toLowerCase().indexOf('the user is not an instagram business') > -1 + ) { + return { + type: 'refresh-token' as const, + value: + 'Your Instagram account is not a business account, please convert it to a business account', + }; + } + + if (body.toLowerCase().indexOf('session has been invalidated') > -1) { + return { + type: 'refresh-token' as const, + value: + 'You session has been invalidated, this can usually happen from frequent posting, please re-authenticate, and wait 1-2 days before posting again', + }; + } + + if (body.indexOf('2207050') > -1) { + return { + type: 'bad-body' as const, + value: 'Instagram user is restricted', + }; + } + + // Media download/upload errors + if (body.indexOf('2207003') > -1) { + return { + type: 'bad-body' as const, + value: 'Timeout downloading media, please try again', + }; + } + + if (body.indexOf('2207020') > -1) { + return { + type: 'bad-body' as const, + value: 'Media expired, please upload again', + }; + } + + if (body.indexOf('2207032') > -1) { + return { + type: 'bad-body' as const, + value: 'Failed to create media, please try again', + }; + } + + if (body.indexOf('2207053') > -1) { + return { + type: 'bad-body' as const, + value: 'Unknown upload error, please try again', + }; + } + + if (body.indexOf('2207052') > -1) { + return { + type: 'bad-body' as const, + value: 'Media fetch failed, please try again', + }; + } + + if (body.indexOf('2207057') > -1) { + return { + type: 'bad-body' as const, + value: 'Invalid thumbnail offset for video', + }; + } + + if (body.indexOf('2207026') > -1) { + return { + type: 'bad-body' as const, + value: 'Unsupported video format', + }; + } + + if (body.indexOf('2207023') > -1) { + return { + type: 'bad-body' as const, + value: 'Unknown media type', + }; + } + + if (body.indexOf('2207006') > -1) { + return { + type: 'bad-body' as const, + value: 'Media not found, please upload again', + }; + } + + if (body.indexOf('2207008') > -1) { + return { + type: 'bad-body' as const, + value: 'Media builder expired, please try again', + }; + } + + // Content validation errors + if (body.indexOf('2207028') > -1) { + return { + type: 'bad-body' as const, + value: 'Carousel validation failed', + }; + } + + if (body.indexOf('2207010') > -1) { + return { + type: 'bad-body' as const, + value: 'Caption is too long', + }; + } + + // Product tagging errors + if (body.indexOf('2207035') > -1) { + return { + type: 'bad-body' as const, + value: 'Product tag positions not supported for videos', + }; + } + + if (body.indexOf('2207036') > -1) { + return { + type: 'bad-body' as const, + value: 'Product tag positions required for photos', + }; + } + + if (body.indexOf('2207037') > -1) { + return { + type: 'bad-body' as const, + value: 'Product tag validation failed', + }; + } + + if (body.indexOf('2207040') > -1) { + return { + type: 'bad-body' as const, + value: 'Too many product tags', + }; + } + + // Image format/size errors + if (body.indexOf('2207004') > -1) { + return { + type: 'bad-body' as const, + value: 'Image is too large', + }; + } + + if (body.indexOf('2207005') > -1) { + return { + type: 'bad-body' as const, + value: 'Unsupported image format', + }; + } + + if (body.indexOf('2207009') > -1) { + return { + type: 'bad-body' as const, + value: 'Aspect ratio not supported, must be between 4:5 to 1.91:1', + }; + } + + if (body.indexOf('Page request limit reached') > -1) { + return { + type: 'bad-body' as const, + value: 'Page posting for today is limited, please try again tomorrow', + }; + } + + if (body.indexOf('2207042') > -1) { + return { + type: 'bad-body' as const, + value: + 'You have reached the maximum of 25 posts per day, allowed for your account', + }; + } + + if (body.indexOf('Not enough permissions to post') > -1) { + return { + type: 'bad-body' as const, + value: 'Not enough permissions to post', + }; + } + + if (body.indexOf('36003') > -1) { + return { + type: 'bad-body' as const, + value: 'Aspect ratio not supported, must be between 4:5 to 1.91:1', + }; + } + + if (body.indexOf('190,') > -1) { + return { + type: 'bad-body' as const, + value: + 'The account is missing some permissions to perform this action, please re-add the account and allow all permissions', + }; + } + + if (body.indexOf('36001') > -1) { + return { + type: 'bad-body' as const, + value: 'Invalid Instagram image resolution max: 1920x1080px', + }; + } + + if (body.indexOf('2207051') > -1) { + return { + type: 'bad-body' as const, + value: 'Instagram blocked your request', + }; + } + + if (body.indexOf('2207001') > -1) { + return { + type: 'bad-body' as const, + value: + 'Instagram detected that your post is spam, please try again with different content', + }; + } + + if (body.indexOf('2207082') > -1) { + return { + type: 'retry' as const, + value: 'Could not upload your media', + } + } + + if (body.indexOf('2207077') > -1) { + return { + type: 'bad-body' as const, + value: 'Instagram Video download failed', + }; + } + + if (body.indexOf('too little or too many attachments') > -1) { + return { + type: 'bad-body' as const, + value: 'Instagram carousel should have between 2 and 10 media attachments', + } + } + + if (body.indexOf('2207027') > -1) { + return { + type: 'bad-body' as const, + value: 'Unknown error, please try again later or contact support', + }; + } + + if (body.indexOf('param collaborators is not allowed') > -1) { + return { + type: 'bad-body' as const, + value: 'Collaborators are not allowed for carousel', + }; + } + + return undefined; + } + + async reConnect( + id: string, + requiredId: string, + token: string + ): Promise> { + const [accessToken, userToken] = token.split('___'); + const findPage = (await this.pages(accessToken)).find( + (p) => p.id === requiredId + ); + + const information = await this.fetchPageInformation(accessToken, { + id: requiredId, + pageId: findPage?.pageId!, + }); + + return { + id: information.id, + name: information.name, + accessToken: information.access_token, + picture: information.picture, + username: information.username, + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: + 'https://www.facebook.com/v20.0/dialog/oauth' + + `?client_id=${process.env.FACEBOOK_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/instagram` + )}` + + `&state=${state}` + + `&scope=${encodeURIComponent(this.scopes.join(','))}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh: string; + }) { + const getAccessToken = await ( + await fetch( + 'https://graph.facebook.com/v20.0/oauth/access_token' + + `?client_id=${process.env.FACEBOOK_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/instagram${ + params.refresh ? `?refresh=${params.refresh}` : '' + }` + )}` + + `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + + `&code=${params.code}` + ) + ).json(); + + const { access_token, expires_in, ...all } = await ( + await fetch( + 'https://graph.facebook.com/v20.0/oauth/access_token' + + '?grant_type=fb_exchange_token' + + `&client_id=${process.env.FACEBOOK_APP_ID}` + + `&client_secret=${process.env.FACEBOOK_APP_SECRET}` + + `&fb_exchange_token=${getAccessToken.access_token}` + ) + ).json(); + + const { data } = await ( + await fetch( + `https://graph.facebook.com/v20.0/me/permissions?access_token=${access_token}` + ) + ).json(); + + const permissions = data + .filter((d: any) => d.status === 'granted') + .map((p: any) => p.permission); + this.checkScopes(this.scopes, permissions); + + const { id, name, picture } = await ( + await fetch( + `https://graph.facebook.com/v20.0/me?fields=id,name,picture&access_token=${access_token}` + ) + ).json(); + + return { + id, + name, + accessToken: access_token, + refreshToken: access_token, + expiresIn: dayjs().add(59, 'days').unix() - dayjs().unix(), + picture: picture?.data?.url || '', + username: '', + }; + } + + async pages(token: string) { + const [accessToken, userToken] = token.split('___'); + const seenPageIds = new Set(); + const allFacebookPages: any[] = []; + + const fetchPaginated = async (startUrl: string) => { + let nextUrl: string | undefined = startUrl; + while (nextUrl) { + const response = await (await fetch(nextUrl)).json(); + if (response.data) { + for (const page of response.data) { + if (!seenPageIds.has(page.id)) { + seenPageIds.add(page.id); + allFacebookPages.push(page); + } + } + } + nextUrl = response.paging?.next; + } + }; + + // Fetch pages the user explicitly shared during the OAuth dialog + await fetchPaginated( + `https://graph.facebook.com/v20.0/me/accounts?fields=id,instagram_business_account,username,name,picture.type(large)&limit=100&access_token=${accessToken}` + ); + + // Also fetch pages via Business Manager API to discover pages + // not selected during the OAuth page selection step + try { + let bizUrl: + | string + | undefined = `https://graph.facebook.com/v20.0/me/businesses?access_token=${accessToken}`; + + while (bizUrl) { + const bizResponse = await (await fetch(bizUrl)).json(); + if (bizResponse.data) { + for (const business of bizResponse.data) { + try { + await fetchPaginated( + `https://graph.facebook.com/v20.0/${business.id}/owned_pages?fields=id,instagram_business_account,username,name,picture.type(large)&limit=100&access_token=${accessToken}` + ); + } catch { + // Continue with other businesses + } + + try { + await fetchPaginated( + `https://graph.facebook.com/v20.0/${business.id}/client_pages?fields=id,instagram_business_account,username,name,picture.type(large)&limit=100&access_token=${accessToken}` + ); + } catch { + // Continue with other businesses + } + } + } + bizUrl = bizResponse.paging?.next; + } + } catch { + // Business Manager API not available for all users + } + + const onlyConnectedAccounts = await Promise.all( + allFacebookPages + .filter((f: any) => f.instagram_business_account) + .map(async (p: any) => { + return { + pageId: p.id, + ...(await ( + await fetch( + `https://graph.facebook.com/v20.0/${p.instagram_business_account.id}?fields=name,profile_picture_url&access_token=${accessToken}` + ) + ).json()), + id: p.instagram_business_account.id, + }; + }) + ); + + return onlyConnectedAccounts.map((p: any) => ({ + pageId: p.pageId, + id: p.id, + name: p.name, + picture: { data: { url: p.profile_picture_url } }, + })); + } + + async fetchPageInformation( + token: string, + data: { pageId: string; id: string } + ) { + const [accessToken, userToken] = token.split('___'); + const { access_token, ...all } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${data.pageId}?fields=access_token,name,picture.type(large)&access_token=${accessToken}` + ) + ).json(); + + const { id, name, profile_picture_url, username } = await ( + await fetch( + `https://graph.facebook.com/v20.0/${data.id}?fields=username,name,profile_picture_url&access_token=${accessToken}` + ) + ).json(); + + return { + id, + name, + picture: profile_picture_url, + access_token: access_token + '___' + accessToken, + username, + }; + } + + async post( + id: string, + token: string, + postDetails: PostDetails[], + integration: Integration, + type = 'graph.facebook.com' + ): Promise { + const [accessToken, userToken] = token.split('___'); + const [firstPost] = postDetails; + console.log('in progress', id); + const isStory = firstPost.settings.post_type === 'story'; + const isTrialReel = this.assetBoolean(firstPost.settings.is_trial_reel); + const medias = await Promise.all( + firstPost?.media?.map(async (m) => { + const caption = + firstPost.media?.length === 1 + ? `&caption=${encodeURIComponent(firstPost.message)}` + : ``; + const isCarousel = + (firstPost?.media?.length || 0) > 1 && !isStory + ? `&is_carousel_item=true` + : ``; + const mediaType = hasExtension(m.path, 'mp4') + ? firstPost?.media?.length === 1 + ? isStory + ? `video_url=${m.path}&media_type=STORIES` + : `video_url=${m.path}&media_type=REELS&thumb_offset=${ + m?.thumbnailTimestamp || 0 + }` + : isStory + ? `video_url=${m.path}&media_type=STORIES` + : `video_url=${m.path}&media_type=VIDEO&thumb_offset=${ + m?.thumbnailTimestamp || 0 + }` + : isStory + ? `image_url=${m.path}&media_type=STORIES` + : `image_url=${m.path}`; + + const trialParams = isTrialReel + ? `&trial_params=${encodeURIComponent( + JSON.stringify({ + graduation_strategy: + firstPost.settings.graduation_strategy || 'MANUAL', + }) + )}` + : ``; + + const collaborators = + firstPost?.settings?.collaborators?.length && !isStory + ? `&collaborators=${JSON.stringify( + firstPost?.settings?.collaborators.map((p) => p.label) + )}` + : ``; + + // audio_configuration is only supported for Reels (single video, not a story) + // and only with Facebook Login (not Instagram Login / graph.instagram.com) + const audioConfiguration = + firstPost?.settings?.audio?.id && + type === 'graph.facebook.com' && + !isStory && + firstPost?.media?.length === 1 && + hasExtension(m.path, 'mp4') + ? `&audio_configuration=${encodeURIComponent( + JSON.stringify({ + audio_id: firstPost.settings.audio.id, + ...(typeof firstPost.settings.audio.audio_volume !== + 'undefined' + ? { audio_volume: +firstPost.settings.audio.audio_volume } + : {}), + ...(typeof firstPost.settings.audio.video_volume !== + 'undefined' + ? { video_volume: +firstPost.settings.audio.video_volume } + : {}), + }) + )}` + : ``; + + const { id: photoId } = await ( + await this.fetch( + `https://${type}/v20.0/${id}/media?${mediaType}${isCarousel}${collaborators}${trialParams}${audioConfiguration}&access_token=${accessToken}${caption}`, + { + method: 'POST', + } + ) + ).json(); + console.log('in progress2', id); + + let status = 'IN_PROGRESS'; + let attempts = 0; + const maxAttempts = 18; // ~9 minutes at 30s interval + while (status === 'IN_PROGRESS') { + if (attempts++ >= maxAttempts) { + throw new Error('Media processing timed out'); + } + + const { status_code } = await ( + await this.fetch( + `https://${type}/v20.0/${photoId}?access_token=${ + userToken || accessToken + }&fields=status_code`, + undefined, + '', + 0, + true + ) + ).json(); + await timer(30000); + status = status_code; + } + console.log('in progress3', id); + + return photoId; + }) || [] + ); + + if (isStory && medias.length > 1) { + // Stories don't support carousels - publish each media as a separate story + let lastMediaId = ''; + let lastPermalink = ''; + for (const mediaCreationId of medias) { + const { id: mediaId } = await ( + await this.fetch( + `https://${type}/v20.0/${id}/media_publish?creation_id=${mediaCreationId}&access_token=${accessToken}&field=id`, + { + method: 'POST', + } + ) + ).json(); + lastMediaId = mediaId; + + const { permalink } = await ( + await this.fetch( + `https://${type}/v20.0/${mediaId}?fields=permalink&access_token=${ + userToken || accessToken + }` + ) + ).json(); + lastPermalink = permalink; + } + + return [ + { + id: firstPost.id, + postId: lastMediaId, + releaseURL: lastPermalink, + status: 'success', + }, + ]; + } else if (medias.length === 1) { + const { id: mediaId } = await ( + await this.fetch( + `https://${type}/v20.0/${id}/media_publish?creation_id=${medias[0]}&access_token=${accessToken}&field=id`, + { + method: 'POST', + } + ) + ).json(); + + const { permalink } = await ( + await this.fetch( + `https://${type}/v20.0/${mediaId}?fields=permalink&access_token=${ + userToken || accessToken + }` + ) + ).json(); + + return [ + { + id: firstPost.id, + postId: mediaId, + releaseURL: permalink, + status: 'success', + }, + ]; + } else { + const { id: containerId, ...all3 } = await ( + await this.fetch( + `https://${type}/v20.0/${id}/media?caption=${encodeURIComponent( + firstPost?.message + )}&media_type=CAROUSEL&children=${encodeURIComponent( + medias.join(',') + )}&access_token=${accessToken}`, + { + method: 'POST', + } + ) + ).json(); + + let status = 'IN_PROGRESS'; + let attempts = 0; + const maxAttempts = 18; // ~9 minutes at 30s interval + while (status === 'IN_PROGRESS') { + if (attempts++ >= maxAttempts) { + throw new Error('Media processing timed out'); + } + + const { status_code } = await ( + await this.fetch( + `https://${type}/v20.0/${containerId}?fields=status_code&access_token=${ + userToken || accessToken + }`, + undefined, + '', + 0, + true + ) + ).json(); + await timer(30000); + status = status_code; + } + + const { id: mediaId, ...all4 } = await ( + await this.fetch( + `https://${type}/v20.0/${id}/media_publish?creation_id=${containerId}&access_token=${accessToken}&field=id`, + { + method: 'POST', + } + ) + ).json(); + + const { permalink } = await ( + await this.fetch( + `https://${type}/v20.0/${mediaId}?fields=permalink&access_token=${ + userToken || accessToken + }` + ) + ).json(); + + return [ + { + id: firstPost.id, + postId: mediaId, + releaseURL: permalink, + status: 'success', + }, + ]; + } + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + token: string, + postDetails: PostDetails[], + integration: Integration, + type = 'graph.facebook.com' + ): Promise { + const [accessToken, userToken] = token.split('___'); + 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=${ + userToken || accessToken + }` + ) + ).json(); + + return [ + { + id: commentPost.id, + postId: commentId, + releaseURL: permalink, + status: 'success', + }, + ]; + } + + private setTitle(name: string) { + switch (name) { + case 'likes': { + return 'Likes'; + } + + case 'followers': { + return 'Followers'; + } + + case 'reach': { + return 'Reach'; + } + + case 'follower_count': { + return 'Follower Count'; + } + + case 'views': { + return 'Views'; + } + + case 'comments': { + return 'Comments'; + } + + case 'shares': { + return 'Shares'; + } + + case 'saves': { + return 'Saves'; + } + + case 'replies': { + return 'Replies'; + } + } + + return ''; + } + + async analytics( + id: string, + token: string, + date: number, + type = 'graph.facebook.com' + ): Promise { + const [accessToken, userToken] = token.split('___'); + const until = dayjs().startOf('day').unix(); + const since = dayjs().subtract(date, 'day').unix(); + + const { data, ...all } = await ( + await fetch( + `https://${type}/v21.0/${id}/insights?metric=follower_count,reach&access_token=${accessToken}&period=day&since=${since}&until=${until}` + ) + ).json(); + + const { data: data2, ...all2 } = await ( + await fetch( + `https://${type}/v21.0/${id}/insights?metric_type=total_value&metric=likes,views,comments,shares,saves,replies&access_token=${accessToken}&period=day&since=${since}&until=${until}` + ) + ).json(); + const analytics = []; + + analytics.push( + ...(data?.map((d: any) => ({ + label: this.setTitle(d.name), + percentageChange: 5, + data: d.values.map((v: any) => ({ + total: v.value, + date: dayjs(v.end_time).format('YYYY-MM-DD'), + })), + })) || []) + ); + + analytics.push( + ...data2.map((d: any) => ({ + label: this.setTitle(d.name), + percentageChange: 5, + data: [ + { + total: d.total_value.value, + date: dayjs().format('YYYY-MM-DD'), + }, + ], + })) + ); + + return analytics; + } + + music(accessToken: string, data: { q: string }) { + return this.fetch( + `https://graph.facebook.com/v20.0/music/search?q=${encodeURIComponent( + data.q + )}&access_token=${accessToken}` + ); + } + + // https://developers.facebook.com/docs/instagram-platform/content-publishing/audio-api/ + // empty search_query returns trending audio + @Tool({ + description: + 'Search audio (music or original sounds) to attach to a Reel via the "audio" setting, an empty query returns trending audio', + dataSchema: [ + { + key: 'q', + type: 'string', + description: 'Search query, leave empty for trending audio', + }, + { + key: 'type', + type: 'string', + description: 'Either "music" or "original_sound", defaults to "music"', + }, + ], + }) + async audioSearch( + token: string, + data: { q?: string; type?: 'music' | 'original_sound' }, + internalId?: string + ) { + const [accessToken, userToken] = token.split('___'); + const audioType = + data?.type === 'original_sound' ? 'original_sound' : 'music'; + + const { audio } = await ( + await this.fetch( + `https://graph.facebook.com/v22.0/ig_audio?audio_type=${audioType}&user_id=${internalId}${ + data?.q ? `&search_query=${encodeURIComponent(data.q)}` : '' + }&access_token=${userToken || accessToken}` + ) + ).json(); + + return (audio || []).map((audio: any) => ({ + id: audio.audio_id, + title: audio.title || '', + artist: audio.display_artist || audio.ig_username || '', + image: + audio.cover_artwork_thumbnail_uri || + audio.cover_artwork_thumbnail_url || + audio.profile_picture_url || + '', + duration: audio.duration_in_ms || 0, + previewUrl: audio.download_url || '', + })); + } + + async postAnalytics( + integrationId: string, + token: string, + postId: string, + date: number, + type = 'graph.facebook.com' + ): Promise { + const [accessToken, userToken] = token.split('___'); + const today = dayjs().format('YYYY-MM-DD'); + + try { + // Fetch media insights from Instagram Graph API + const { data } = await ( + await fetch( + `https://${type}/v21.0/${postId}/insights?metric=views,reach,saved,likes,comments,shares&access_token=${accessToken}` + ) + ).json(); + + if (!data || data.length === 0) { + return []; + } + + const result: AnalyticsData[] = []; + + for (const metric of data) { + const value = metric.values?.[0]?.value; + if (value === undefined) continue; + + let label = ''; + + switch (metric.name) { + case 'views': + label = 'Views'; + break; + case 'reach': + label = 'Reach'; + break; + case 'engagement': + label = 'Engagement'; + break; + case 'saved': + label = 'Saves'; + break; + case 'likes': + label = 'Likes'; + break; + case 'comments': + label = 'Comments'; + break; + case 'shares': + label = 'Shares'; + break; + } + + if (label) { + result.push({ + label, + percentageChange: 0, + data: [{ total: String(value), date: today }], + }); + } + } + + return result; + } catch (err) { + console.error('Error fetching Instagram post analytics:', err); + return []; + } + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..039690cea0a656d85de0494c320fa11785db8bb7 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/instagram.standalone.provider.ts @@ -0,0 +1,236 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import dayjs from 'dayjs'; +import { + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { InstagramDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/instagram.dto'; +import { InstagramProvider } from '@gitroom/nestjs-libraries/integrations/social/instagram.provider'; +import { Integration } from '@prisma/client'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; + +const instagramProvider = new InstagramProvider(); + +@Rules( + "Instagram should have at least one attachment, if it's a story, it can have only one picture" +) +export class InstagramStandaloneProvider + extends SocialAbstract + implements SocialProvider +{ + identifier = 'instagram-standalone'; + name = 'Instagram\n(Standalone)'; + isBetweenSteps = false; + refreshCron = true; + scopes = [ + 'instagram_business_basic', + 'instagram_business_content_publish', + 'instagram_business_manage_comments', + 'instagram_business_manage_insights', + ]; + override maxConcurrentJob = 200; // Instagram standalone has stricter limits + dto = InstagramDto; + + editor = 'normal' as const; + maxLength() { + return 2200; + } + + override async checkValidity( + [firstPost]: Array, + settings: any + ): Promise { + if (!firstPost?.length) { + return 'Should have at least one media'; + } + if (this.assetBoolean(settings?.is_trial_reel)) { + if ((firstPost?.length ?? 0) > 1) { + return 'Trial Reels can only have one video'; + } + const hasVideo = firstPost?.some( + (f) => (f?.path?.indexOf?.('mp4') ?? -1) > -1 + ); + if (!hasVideo) { + return 'Trial Reels must be a video'; + } + } + return true; + } + + public override handleErrors( + body: string, + status: number + ): + | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | undefined { + return instagramProvider.handleErrors(body, status); + } + + async refreshToken(refresh_token: string): Promise { + const { access_token } = await ( + await fetch( + `https://graph.instagram.com/refresh_access_token?grant_type=ig_refresh_token&access_token=${refresh_token}` + ) + ).json(); + + const { + user_id, + name, + username, + profile_picture_url = '', + } = await ( + await fetch( + `https://graph.instagram.com/v21.0/me?fields=user_id,username,name,profile_picture_url&access_token=${access_token}` + ) + ).json(); + + return { + id: user_id, + name, + accessToken: access_token, + refreshToken: access_token, + expiresIn: dayjs().add(58, 'days').unix() - dayjs().unix(), + picture: profile_picture_url || '', + username, + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: + `https://www.instagram.com/oauth/authorize?enable_fb_login=0&client_id=${ + process.env.INSTAGRAM_APP_ID + }&redirect_uri=${encodeURIComponent( + `${ + process?.env.FRONTEND_URL?.indexOf('https') == -1 + ? `https://redirectmeto.com/${process?.env.FRONTEND_URL}` + : `${process?.env.FRONTEND_URL}` + }/integrations/social/instagram-standalone` + )}&response_type=code&scope=${encodeURIComponent( + this.scopes.join(',') + )}` + `&state=${state}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh: string; + }) { + const formData = new FormData(); + formData.append('client_id', process.env.INSTAGRAM_APP_ID!); + formData.append('client_secret', process.env.INSTAGRAM_APP_SECRET!); + formData.append('grant_type', 'authorization_code'); + formData.append( + 'redirect_uri', + `${ + process?.env.FRONTEND_URL?.indexOf('https') == -1 + ? `https://redirectmeto.com/${process?.env.FRONTEND_URL}` + : `${process?.env.FRONTEND_URL}` + }/integrations/social/instagram-standalone` + ); + formData.append('code', params.code); + + const getAccessToken = await ( + await fetch('https://api.instagram.com/oauth/access_token', { + method: 'POST', + body: formData, + }) + ).json(); + + const { access_token, expires_in, ...all } = await ( + await fetch( + 'https://graph.instagram.com/access_token' + + '?grant_type=ig_exchange_token' + + `&client_id=${process.env.INSTAGRAM_APP_ID}` + + `&client_secret=${process.env.INSTAGRAM_APP_SECRET}` + + `&access_token=${getAccessToken.access_token}` + ) + ).json(); + + this.checkScopes(this.scopes, getAccessToken.permissions); + + const { user_id, name, username, profile_picture_url } = await ( + await fetch( + `https://graph.instagram.com/v21.0/me?fields=user_id,username,name,profile_picture_url&access_token=${access_token}` + ) + ).json(); + + return { + id: user_id, + name, + accessToken: access_token, + refreshToken: access_token, + expiresIn: dayjs().add(58, 'days').unix() - dayjs().unix(), + picture: profile_picture_url, + username, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + return instagramProvider.post( + id, + accessToken, + postDetails, + integration, + 'graph.instagram.com' + ); + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + return instagramProvider.comment( + id, + postId, + lastCommentId, + accessToken, + postDetails, + integration, + 'graph.instagram.com' + ); + } + + async analytics(id: string, accessToken: string, date: number) { + return instagramProvider.analytics( + id, + accessToken, + date, + 'graph.instagram.com' + ); + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ) { + return instagramProvider.postAnalytics( + integrationId, + accessToken, + postId, + date, + 'graph.instagram.com' + ); + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts b/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c2e66ef01c0dc97c0751d26fadbbd1d2eded745 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/kick.provider.ts @@ -0,0 +1,227 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +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'; +import { KickDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/kick.dto'; +import { createHash, randomBytes } from 'crypto'; + +export class KickProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; + identifier = 'kick'; + name = 'Kick'; + isBetweenSteps = false; + editor = 'normal' as const; + scopes = ['chat:write', 'user:read', 'channel:read']; + dto = KickDto; + + maxLength() { + return 500; // Kick chat message max length + } + + private generatePKCE() { + const codeVerifier = randomBytes(64).toString('base64url'); + const challenge = Buffer.from( + createHash('sha256').update(codeVerifier).digest() + ) + .toString('base64') + .replace(/=*$/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + return { codeVerifier, codeChallenge: challenge }; + } + + async refreshToken(refreshToken: string): Promise { + const response = await this.fetch('https://id.kick.com/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: process.env.KICK_CLIENT_ID!, + client_secret: process.env.KICK_SECRET!, + refresh_token: refreshToken, + }), + }); + + const { access_token, refresh_token, expires_in } = await response.json(); + + // Get user info + const userInfo = await this.getUserInfo(access_token); + + return { + refreshToken: refresh_token, + expiresIn: expires_in, + accessToken: access_token, + id: userInfo.id, + name: userInfo.name, + picture: userInfo.picture || '', + username: userInfo.username, + }; + } + + async generateAuthUrl() { + const state = makeId(32); + const { codeVerifier, codeChallenge } = this.generatePKCE(); + + const redirectUri = `${process.env.FRONTEND_URL}/integrations/social/kick`; + + const url = + `https://id.kick.com/oauth/authorize` + + `?response_type=code` + + `&client_id=${process.env.KICK_CLIENT_ID}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=${encodeURIComponent(this.scopes.join(' '))}` + + `&state=${state}` + + `&code_challenge=${codeChallenge}` + + `&code_challenge_method=S256`; + + return { + url, + codeVerifier, + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const redirectUri = `${process.env.FRONTEND_URL}/integrations/social/kick${ + params.refresh ? `?refresh=${params.refresh}` : '' + }`; + + const tokenResponse = await this.fetch('https://id.kick.com/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: process.env.KICK_CLIENT_ID!, + client_secret: process.env.KICK_SECRET!, + redirect_uri: redirectUri, + code: params.code, + code_verifier: params.codeVerifier, + }), + }); + + const { access_token, refresh_token, expires_in, scope } = + await tokenResponse.json(); + + // Get user info + const userInfo = await this.getUserInfo(access_token); + + return { + id: userInfo.id, + name: userInfo.name, + accessToken: access_token, + refreshToken: refresh_token, + expiresIn: expires_in, + picture: userInfo.picture || '', + username: userInfo.username, + }; + } + + private async getUserInfo( + accessToken: string + ): Promise<{ id: string; name: string; username: string; picture?: string }> { + // Use token introspect to get basic info, then fetch user details + // Try to get full user info from the API + const userResponse = await fetch('https://api.kick.com/public/v1/users', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + const userData = await userResponse.json(); + const user = userData.data?.[0] || userData.data; + return { + id: String(user.user_id || user.id), + name: user.name, + username: user.name, + picture: user.profile_picture || '', + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [firstPost] = postDetails; + + // Post chat message to Kick + // Note: Kick chat doesn't support media attachments directly in messages + const response = await this.fetch('https://api.kick.com/public/v1/chat', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: 'user', + content: firstPost.message.substring(0, 500), // Ensure max length + broadcaster_user_id: parseInt(id, 10), + }), + }); + + const data = await response.json(); + + return [ + { + id: firstPost.id, + postId: data.data?.message_id || data.message_id || makeId(10), + releaseURL: `https://kick.com/${integration.profile || 'channel'}`, + status: data.data?.is_sent || data.is_sent ? 'posted' : 'error', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [commentPost] = postDetails; + + // Kick supports reply_to_message_id for replies + const response = await this.fetch('https://api.kick.com/public/v1/chat', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + type: 'user', + content: commentPost.message.substring(0, 500), + broadcaster_user_id: parseInt(id, 10), + reply_to_message_id: lastCommentId || postId, + }), + }); + + const data = await response.json(); + + return [ + { + id: commentPost.id, + postId: data.data?.message_id || data.message_id || makeId(10), + releaseURL: `https://kick.com/${integration.profile || 'channel'}`, + status: data.data?.is_sent || data.is_sent ? 'posted' : 'error', + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..a46b38452ba67d0a0e38c33a89b52ff230ee5bbc --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/lemmy.provider.ts @@ -0,0 +1,326 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; +import { LemmySettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/lemmy.dto'; +import { getSsrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +export class LemmyProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; // Lemmy instances typically have moderate limits + identifier = 'lemmy'; + name = 'Lemmy'; + isBetweenSteps = false; + scopes = [] as string[]; + editor = 'normal' as const; + maxLength() { + return 10000; + } + dto = LemmySettingsDto; + + override async checkValidity( + items: Array + ): Promise { + const [firstItems] = items ?? []; + if ( + firstItems?.length && + (firstItems?.[0]?.path?.indexOf?.('png') ?? -1) === -1 && + (firstItems?.[0]?.path?.indexOf?.('jpg') ?? -1) === -1 && + (firstItems?.[0]?.path?.indexOf?.('jpef') ?? -1) === -1 && + (firstItems?.[0]?.path?.indexOf?.('gif') ?? -1) === -1 + ) { + return 'You can set only one picture for a cover'; + } + return true; + } + + async customFields() { + return [ + { + key: 'service', + label: 'Service', + defaultValue: 'https://lemmy.world', + validation: `/^https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)$/`, + type: 'text' as const, + }, + { + key: 'identifier', + label: 'Identifier', + validation: `/^.{3,}$/`, + type: 'text' as const, + }, + { + key: 'password', + label: 'Password', + validation: `/^.{3,}$/`, + type: 'password' as const, + }, + ]; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body = JSON.parse(Buffer.from(params.code, 'base64').toString()); + + const load = await fetch(body.service + '/api/v3/user/login', { + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + body: JSON.stringify({ + username_or_email: body.identifier, + password: body.password, + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (load.status === 401) { + return 'Invalid credentials'; + } + + const { jwt } = await load.json(); + + try { + const user = await ( + await fetch(body.service + `/api/v3/user?username=${body.identifier}`, { + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + headers: { + Authorization: `Bearer ${jwt}`, + }, + }) + ).json(); + + return { + refreshToken: jwt!, + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + accessToken: jwt!, + id: String(user.person_view.person.id), + name: + user.person_view.person.display_name || + user.person_view.person.name || + '', + picture: user?.person_view?.person?.avatar || '', + username: body.identifier || '', + }; + } catch (e) { + console.log(e); + return 'Invalid credentials'; + } + } + + private async getJwtAndService(integration: Integration): Promise<{ jwt: string; service: string }> { + const body = JSON.parse( + AuthService.fixedDecryption(integration.customInstanceDetails!) + ); + + const { jwt } = await ( + await fetch(body.service + '/api/v3/user/login', { + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + body: JSON.stringify({ + username_or_email: body.identifier, + password: body.password, + }), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + ).json(); + + return { jwt, service: body.service }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [firstPost] = postDetails; + const { jwt, service } = await this.getJwtAndService(integration); + + const valueArray: PostResponse[] = []; + + for (const lemmy of firstPost.settings.subreddit) { + console.log({ + community_id: +lemmy.value.id, + name: lemmy.value.title, + body: firstPost.message, + ...(lemmy.value.url ? { url: lemmy.value.url } : {}), + ...(firstPost.media?.length + ? { custom_thumbnail: firstPost.media[0].path } + : {}), + nsfw: false, + }); + const { post_view } = await ( + await fetch(service + '/api/v3/post', { + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + body: JSON.stringify({ + community_id: +lemmy.value.id, + name: lemmy.value.title, + body: firstPost.message, + ...(lemmy.value.url + ? { + url: + lemmy.value.url.indexOf('http') === -1 + ? `https://${lemmy.value.url}` + : lemmy.value.url, + } + : {}), + ...(firstPost.media?.length + ? { custom_thumbnail: firstPost.media[0].path } + : {}), + nsfw: false, + }), + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + }) + ).json(); + + valueArray.push({ + postId: post_view.post.id, + releaseURL: service + '/post/' + post_view.post.id, + id: firstPost.id, + status: 'published', + }); + } + + return [ + { + id: firstPost.id, + postId: valueArray.map((p) => String(p.postId)).join(','), + releaseURL: valueArray.map((p) => p.releaseURL).join(','), + status: 'published', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [commentPost] = postDetails; + const { jwt, service } = await this.getJwtAndService(integration); + + // postId can be comma-separated if posted to multiple communities + const postIds = postId.split(','); + const valueArray: PostResponse[] = []; + + for (const singlePostId of postIds) { + const { comment_view } = await ( + await fetch(service + '/api/v3/comment', { + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + body: JSON.stringify({ + post_id: +singlePostId, + content: commentPost.message, + }), + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + 'Content-Type': 'application/json', + }, + }) + ).json(); + + valueArray.push({ + postId: String(comment_view.comment.id), + releaseURL: service + '/comment/' + comment_view.comment.id, + id: commentPost.id, + status: 'published', + }); + } + + return [ + { + id: commentPost.id, + postId: valueArray.map((p) => p.postId).join(','), + releaseURL: valueArray.map((p) => p.releaseURL).join(','), + status: 'published', + }, + ]; + } + + @Tool({ + description: 'Search for Lemmy communities by keyword', + dataSchema: [ + { + key: 'word', + type: 'string', + description: 'Keyword to search for', + }, + ], + }) + async subreddits( + accessToken: string, + data: any, + id: string, + integration: Integration + ) { + const { jwt, service } = await this.getJwtAndService(integration); + + const { communities } = await ( + await fetch( + service + `/api/v3/search?type_=Communities&sort=Active&q=${data.word}`, + { + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + headers: { + Authorization: `Bearer ${jwt}`, + }, + } + ) + ).json(); + + return communities.map((p: any) => ({ + title: p.community.title, + name: p.community.title, + id: p.community.id, + })); + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c37b67a3b0e36cc883f9a588ac25cf0df5c2cde --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.page.provider.ts @@ -0,0 +1,919 @@ +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { LinkedinProvider } from '@gitroom/nestjs-libraries/integrations/social/linkedin.provider'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { Plug } from '@gitroom/helpers/decorators/plug.decorator'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; + +@Rules( + 'LinkedIn can have maximum one attachment when selecting video, when choosing a carousel on LinkedIn minimum amount of attachment must be two, and only pictures, if uploading a video, LinkedIn can have only one attachment' +) +export class LinkedinPageProvider + extends LinkedinProvider + implements SocialProvider +{ + override identifier = 'linkedin-page'; + override name = 'LinkedIn Page'; + override isBetweenSteps = true; + override refreshWait = true; + override maxConcurrentJob = 2; // LinkedIn Page has professional posting limits + override scopes = [ + 'openid', + 'profile', + 'w_member_social', + 'r_basicprofile', + 'rw_organization_admin', + 'w_organization_social', + 'r_organization_social', + ]; + + override editor = 'normal' as const; + + override async refreshToken( + refresh_token: string + ): Promise { + const { + access_token: accessToken, + expires_in, + refresh_token: refreshToken, + } = await ( + await fetch('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token, + client_id: process.env.LINKEDIN_CLIENT_ID!, + client_secret: process.env.LINKEDIN_CLIENT_SECRET!, + }), + }) + ).json(); + + const { vanityName } = await ( + await fetch('https://api.linkedin.com/v2/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + const { + name, + sub: id, + picture, + } = await ( + await fetch('https://api.linkedin.com/v2/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return { + id, + accessToken, + refreshToken, + expiresIn: expires_in, + name, + picture, + username: vanityName, + }; + } + + override async addComment( + integration: Integration, + originalIntegration: Integration, + postId: string, + information: any, + ) { + return super.addComment( + integration, + originalIntegration, + postId, + information, + false + ); + } + + override async repostPostUsers( + integration: Integration, + originalIntegration: Integration, + postId: string, + information: any + ) { + return super.repostPostUsers( + integration, + originalIntegration, + postId, + information, + false + ); + } + + override async generateAuthUrl() { + const state = makeId(6); + const codeVerifier = makeId(30); + const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&prompt=none&client_id=${ + process.env.LINKEDIN_CLIENT_ID + }&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/linkedin-page` + )}&state=${state}&scope=${encodeURIComponent(this.scopes.join(' '))}`; + return { + url, + codeVerifier, + state, + }; + } + + async companies(accessToken: string) { + const { elements, ...all } = await ( + await fetch( + 'https://api.linkedin.com/v2/organizationalEntityAcls?q=roleAssignee&role=ADMINISTRATOR&projection=(elements*(organizationalTarget~(localizedName,vanityName,logoV2(original~:playableStreams))))', + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202601', + }, + } + ) + ).json(); + + return (elements || []).map((e: any) => ({ + id: e.organizationalTarget.split(':').pop(), + page: e.organizationalTarget.split(':').pop(), + username: e['organizationalTarget~'].vanityName, + name: e['organizationalTarget~'].localizedName, + picture: + e['organizationalTarget~'].logoV2?.['original~']?.elements?.[0] + ?.identifiers?.[0]?.identifier, + })); + } + + async reConnect( + id: string, + requiredId: string, + accessToken: string + ): Promise> { + const information = await this.fetchPageInformation(accessToken, { + page: requiredId, + }); + + return { + id: information.id, + name: information.name, + accessToken: information.access_token, + picture: information.picture, + username: information.username, + }; + } + + async fetchPageInformation(accessToken: string, params: { page: string }) { + const pageId = params.page; + const data = await ( + await fetch( + `https://api.linkedin.com/v2/organizations/${pageId}?projection=(id,localizedName,vanityName,logoV2(original~:playableStreams))`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).json(); + + return { + id: data.id, + name: data.localizedName, + access_token: accessToken, + picture: + data?.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0].identifier, + username: data.vanityName, + }; + } + + override async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body = new URLSearchParams(); + body.append('grant_type', 'authorization_code'); + body.append('code', params.code); + body.append( + 'redirect_uri', + `${process.env.FRONTEND_URL}/integrations/social/linkedin-page` + ); + body.append('client_id', process.env.LINKEDIN_CLIENT_ID!); + body.append('client_secret', process.env.LINKEDIN_CLIENT_SECRET!); + + const { + access_token: accessToken, + expires_in: expiresIn, + refresh_token: refreshToken, + scope, + } = await ( + await fetch('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }) + ).json(); + + this.checkScopes(this.scopes, scope); + + const { + name, + sub: id, + picture, + } = await ( + await fetch('https://api.linkedin.com/v2/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + const { vanityName } = await ( + await fetch('https://api.linkedin.com/v2/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return { + id: id, + accessToken, + refreshToken, + expiresIn, + name, + picture, + username: vanityName, + }; + } + + override async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + 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 { + return super.comment( + id, + postId, + lastCommentId, + accessToken, + postDetails, + integration, + 'company' + ); + } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const endDate = dayjs().unix() * 1000; + const startDate = dayjs().subtract(date, 'days').unix() * 1000; + + const { elements }: { elements: Root[]; paging: any } = await ( + await fetch( + `https://api.linkedin.com/v2/organizationPageStatistics?q=organization&organization=${encodeURIComponent( + `urn:li:organization:${id}` + )}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Linkedin-Version': '202601', + 'X-Restli-Protocol-Version': '2.0.0', + }, + } + ) + ).json(); + + const { elements: elements2 }: { elements: Root[]; paging: any } = await ( + await fetch( + `https://api.linkedin.com/v2/organizationalEntityFollowerStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent( + `urn:li:organization:${id}` + )}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Linkedin-Version': '202601', + 'X-Restli-Protocol-Version': '2.0.0', + }, + } + ) + ).json(); + + const { elements: elements3 }: { elements: Root[]; paging: any } = await ( + await fetch( + `https://api.linkedin.com/v2/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent( + `urn:li:organization:${id}` + )}&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Linkedin-Version': '202601', + 'X-Restli-Protocol-Version': '2.0.0', + }, + } + ) + ).json(); + + const analytics = [...elements2, ...elements, ...elements3].reduce( + (all, current) => { + if ( + typeof current?.totalPageStatistics?.views?.allPageViews + ?.pageViews !== 'undefined' + ) { + all['Page Views'].push({ + total: current.totalPageStatistics.views.allPageViews.pageViews, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + } + + if ( + typeof current?.followerGains?.organicFollowerGain !== 'undefined' + ) { + all['Organic Followers'].push({ + total: current?.followerGains?.organicFollowerGain, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + } + + if (typeof current?.followerGains?.paidFollowerGain !== 'undefined') { + all['Paid Followers'].push({ + total: current?.followerGains?.paidFollowerGain, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + } + + if (typeof current?.totalShareStatistics !== 'undefined') { + all['Clicks'].push({ + total: current?.totalShareStatistics.clickCount, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + + all['Shares'].push({ + total: current?.totalShareStatistics.shareCount, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + + all['Engagement'].push({ + total: current?.totalShareStatistics.engagement, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + + all['Comments'].push({ + total: current?.totalShareStatistics.commentCount, + date: dayjs(current.timeRange.start).format('YYYY-MM-DD'), + }); + } + + return all; + }, + { + 'Page Views': [] as any[], + Clicks: [] as any[], + Shares: [] as any[], + Engagement: [] as any[], + Comments: [] as any[], + 'Organic Followers': [] as any[], + 'Paid Followers': [] as any[], + } + ); + + return Object.keys(analytics).map((key) => ({ + label: key, + data: analytics[ + key as 'Page Views' | 'Organic Followers' | 'Paid Followers' + ], + percentageChange: 5, + })); + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + const endDate = dayjs().unix() * 1000; + const startDate = dayjs().subtract(date, 'days').unix() * 1000; + + // Fetch share statistics for the specific post + const shareStatsUrl = `https://api.linkedin.com/v2/organizationalEntityShareStatistics?q=organizationalEntity&organizationalEntity=${encodeURIComponent( + `urn:li:organization:${integrationId}` + )}&shares=List(${encodeURIComponent(postId)})&timeIntervals=(timeRange:(start:${startDate},end:${endDate}),timeGranularityType:DAY)`; + + const { elements: shareElements }: { elements: PostShareStatElement[] } = + await ( + await fetch(shareStatsUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'LinkedIn-Version': '202601', + 'X-Restli-Protocol-Version': '2.0.0', + }, + }) + ).json(); + + // Also fetch social actions (likes, comments, shares) for the specific post + let socialActions: SocialActionsResponse | null = null; + try { + const socialActionsUrl = `https://api.linkedin.com/v2/socialActions/${encodeURIComponent( + postId + )}`; + socialActions = await ( + await fetch(socialActionsUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'LinkedIn-Version': '202601', + 'X-Restli-Protocol-Version': '2.0.0', + }, + }) + ).json(); + } catch (e) { + // Social actions may not be available for all posts + } + + // Process share statistics into time series data + const analytics = (shareElements || []).reduce( + (all, current) => { + if (typeof current?.totalShareStatistics !== 'undefined') { + const dateStr = dayjs(current.timeRange.start).format('YYYY-MM-DD'); + + all['Impressions'].push({ + total: current.totalShareStatistics.impressionCount || 0, + date: dateStr, + }); + + all['Unique Impressions'].push({ + total: current.totalShareStatistics.uniqueImpressionsCount || 0, + date: dateStr, + }); + + all['Clicks'].push({ + total: current.totalShareStatistics.clickCount || 0, + date: dateStr, + }); + + all['Likes'].push({ + total: current.totalShareStatistics.likeCount || 0, + date: dateStr, + }); + + all['Comments'].push({ + total: current.totalShareStatistics.commentCount || 0, + date: dateStr, + }); + + all['Shares'].push({ + total: current.totalShareStatistics.shareCount || 0, + date: dateStr, + }); + + all['Engagement'].push({ + total: current.totalShareStatistics.engagement || 0, + date: dateStr, + }); + } + return all; + }, + { + Impressions: [] as { total: number; date: string }[], + 'Unique Impressions': [] as { total: number; date: string }[], + Clicks: [] as { total: number; date: string }[], + Likes: [] as { total: number; date: string }[], + Comments: [] as { total: number; date: string }[], + Shares: [] as { total: number; date: string }[], + Engagement: [] as { total: number; date: string }[], + } + ); + + // If no time series data but we have social actions, create a single data point + if ( + Object.values(analytics).every((arr) => arr.length === 0) && + socialActions + ) { + const today = dayjs().format('YYYY-MM-DD'); + analytics['Likes'].push({ + total: socialActions.likesSummary?.totalLikes || 0, + date: today, + }); + analytics['Comments'].push({ + total: socialActions.commentsSummary?.totalFirstLevelComments || 0, + date: today, + }); + } + + // Filter out empty analytics + const result = Object.entries(analytics) + .filter(([_, data]) => data.length > 0) + .map(([label, data]) => ({ + label, + data, + percentageChange: 0, + })); + + return result as any; + } + + @Plug({ + identifier: 'linkedin-page-autoRepostPost', + title: 'Auto Repost Posts', + description: + 'When a post reached a certain number of likes, repost it to increase engagement (1 week old posts)', + runEveryMilliseconds: 21600000, + totalRuns: 3, + fields: [ + { + name: 'likesAmount', + type: 'number', + placeholder: 'Amount of likes', + description: 'The amount of likes to trigger the repost', + validation: /^\d+$/, + }, + ], + }) + async autoRepostPost( + integration: Integration, + id: string, + fields: { likesAmount: string } + ) { + const { + likesSummary: { totalLikes }, + } = await ( + await this.fetch( + `https://api.linkedin.com/v2/socialActions/${encodeURIComponent(id)}`, + { + method: 'GET', + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'Content-Type': 'application/json', + 'LinkedIn-Version': '202601', + Authorization: `Bearer ${integration.token}`, + }, + } + ) + ).json(); + + if (totalLikes >= +fields.likesAmount) { + await timer(2000); + await this.fetch(`https://api.linkedin.com/rest/posts`, { + body: JSON.stringify({ + author: `urn:li:organization:${integration.internalId}`, + commentary: '', + visibility: 'PUBLIC', + distribution: { + feedDistribution: 'MAIN_FEED', + targetEntities: [], + thirdPartyDistributionChannels: [], + }, + lifecycleState: 'PUBLISHED', + isReshareDisabledByAuthor: false, + reshareContext: { + parent: id, + }, + }), + method: 'POST', + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'Content-Type': 'application/json', + 'LinkedIn-Version': '202601', + Authorization: `Bearer ${integration.token}`, + }, + }); + return true; + } + + return false; + } + + @Plug({ + identifier: 'linkedin-page-autoPlugPost', + title: 'Auto plug post', + description: + 'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion', + runEveryMilliseconds: 21600000, + totalRuns: 3, + fields: [ + { + name: 'likesAmount', + type: 'number', + placeholder: 'Amount of likes', + description: 'The amount of likes to trigger the repost', + validation: /^\d+$/, + }, + { + name: 'post', + type: 'richtext', + placeholder: 'Post to plug', + description: 'Message content to plug', + validation: /^[\s\S]{3,}$/g, + }, + ], + }) + async autoPlugPost( + integration: Integration, + id: string, + fields: { likesAmount: string; post: string } + ) { + const { + likesSummary: { totalLikes }, + } = await ( + await this.fetch( + `https://api.linkedin.com/v2/socialActions/${encodeURIComponent(id)}`, + { + method: 'GET', + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'Content-Type': 'application/json', + 'LinkedIn-Version': '202601', + Authorization: `Bearer ${integration.token}`, + }, + } + ) + ).json(); + + if (totalLikes >= fields.likesAmount) { + await timer(2000); + await this.fetch( + `https://api.linkedin.com/v2/socialActions/${decodeURIComponent( + id + )}/comments`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${integration.token}`, + }, + body: JSON.stringify({ + actor: `urn:li:organization:${integration.internalId}`, + object: id, + message: { + text: this.fixText(fields.post), + }, + }), + } + ); + return true; + } + + return false; + } +} + +export interface Root { + pageStatisticsByIndustryV2: any[]; + pageStatisticsBySeniority: any[]; + organization: string; + pageStatisticsByGeoCountry: any[]; + pageStatisticsByTargetedContent: any[]; + totalPageStatistics: TotalPageStatistics; + pageStatisticsByStaffCountRange: any[]; + pageStatisticsByFunction: any[]; + pageStatisticsByGeo: any[]; + followerGains: { organicFollowerGain: number; paidFollowerGain: number }; + timeRange: TimeRange; + totalShareStatistics: { + uniqueImpressionsCount: number; + shareCount: number; + engagement: number; + clickCount: number; + likeCount: number; + impressionCount: number; + commentCount: number; + }; +} + +export interface TotalPageStatistics { + clicks: Clicks; + views: Views; +} + +export interface Clicks { + mobileCustomButtonClickCounts: any[]; + desktopCustomButtonClickCounts: any[]; +} + +export interface Views { + mobileProductsPageViews: MobileProductsPageViews; + allDesktopPageViews: AllDesktopPageViews; + insightsPageViews: InsightsPageViews; + mobileAboutPageViews: MobileAboutPageViews; + allMobilePageViews: AllMobilePageViews; + productsPageViews: ProductsPageViews; + desktopProductsPageViews: DesktopProductsPageViews; + jobsPageViews: JobsPageViews; + peoplePageViews: PeoplePageViews; + overviewPageViews: OverviewPageViews; + mobileOverviewPageViews: MobileOverviewPageViews; + lifeAtPageViews: LifeAtPageViews; + desktopOverviewPageViews: DesktopOverviewPageViews; + mobileCareersPageViews: MobileCareersPageViews; + allPageViews: AllPageViews; + careersPageViews: CareersPageViews; + mobileJobsPageViews: MobileJobsPageViews; + mobileLifeAtPageViews: MobileLifeAtPageViews; + desktopJobsPageViews: DesktopJobsPageViews; + desktopPeoplePageViews: DesktopPeoplePageViews; + aboutPageViews: AboutPageViews; + desktopAboutPageViews: DesktopAboutPageViews; + mobilePeoplePageViews: MobilePeoplePageViews; + desktopCareersPageViews: DesktopCareersPageViews; + desktopInsightsPageViews: DesktopInsightsPageViews; + desktopLifeAtPageViews: DesktopLifeAtPageViews; + mobileInsightsPageViews: MobileInsightsPageViews; +} + +export interface MobileProductsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface AllDesktopPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface InsightsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileAboutPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface AllMobilePageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface ProductsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopProductsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface JobsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface PeoplePageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface OverviewPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileOverviewPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface LifeAtPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopOverviewPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileCareersPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface AllPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface CareersPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileJobsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileLifeAtPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopJobsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopPeoplePageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface AboutPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopAboutPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobilePeoplePageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopCareersPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopInsightsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface DesktopLifeAtPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface MobileInsightsPageViews { + pageViews: number; + uniquePageViews: number; +} + +export interface TimeRange { + start: number; + end: number; +} + +// Post analytics interfaces +export interface PostShareStatElement { + organizationalEntity: string; + share: string; + totalShareStatistics: { + uniqueImpressionsCount: number; + shareCount: number; + engagement: number; + clickCount: number; + likeCount: number; + impressionCount: number; + commentCount: number; + }; + timeRange: TimeRange; +} + +export interface SocialActionsResponse { + likesSummary?: { + totalLikes: number; + likedByCurrentUser: boolean; + }; + commentsSummary?: { + totalFirstLevelComments: number; + commentsState: string; + }; +} diff --git a/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..212c426128b9f45b89c0a26dd0f77ebb75ba6861 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/linkedin.provider.ts @@ -0,0 +1,982 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import sharp from 'sharp'; +import { lookup } from 'mime-types'; +import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { + BadBody, + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { Integration } from '@prisma/client'; +import { PostPlug } from '@gitroom/helpers/decorators/post.plug'; +import { LinkedinDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/linkedin.dto'; +import imageToPDF from 'image-to-pdf'; +import { Readable } from 'stream'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; + +@Rules( + 'LinkedIn can have maximum one attachment when selecting video, when choosing a carousel on LinkedIn minimum amount of attachment must be two, and only pictures, if uploading a video, LinkedIn can have only one attachment' +) +export class LinkedinProvider extends SocialAbstract implements SocialProvider { + identifier = 'linkedin'; + name = 'LinkedIn'; + oneTimeToken = true; + + isBetweenSteps = false; + scopes = [ + 'openid', + 'profile', + 'w_member_social', + 'r_basicprofile', + 'rw_organization_admin', + 'w_organization_social', + 'r_organization_social', + ]; + override maxConcurrentJob = 2; + refreshWait = true; + editor = 'normal' as const; + maxLength() { + return 3000; + } + + override async checkValidity( + posts: Array, + vals: any + ): Promise { + const [firstPost, ...restPosts] = posts ?? []; + + if ( + this.assetBoolean(vals?.post_as_images_carousel) && + ((firstPost?.length ?? 0) < 2 || + firstPost?.some((p) => (p?.path?.indexOf?.('mp4') ?? -1) > -1)) + ) { + return 'Carousel can only be created with 2 or more images and no videos.'; + } + + if ( + (firstPost?.length ?? 0) > 1 && + firstPost?.some((p) => (p?.path?.indexOf?.('mp4') ?? -1) > -1) + ) { + return 'Can have maximum 1 media when selecting a video.'; + } + if (restPosts?.some((p) => (p?.length ?? 0) > 0)) { + return 'Comments can only contain text.'; + } + return true; + } + + override handleErrors( + body: string + ): + | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | undefined { + if (body.indexOf('Unable to obtain activity') > -1) { + return { + type: 'retry', + value: 'Unable to obtain activity', + }; + } + + if (body.indexOf('resource is forbidden') > -1 || body.indexOf('Service Unavailable') > -1) { + return { + type: 'retry', + value: 'Resource is forbidden', + }; + } + + return undefined; + } + + async refreshToken(refresh_token: string): Promise { + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_in, + } = await ( + await fetch('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token, + client_id: process.env.LINKEDIN_CLIENT_ID!, + client_secret: process.env.LINKEDIN_CLIENT_SECRET!, + }), + }) + ).json(); + + const { vanityName } = await ( + await fetch('https://api.linkedin.com/v2/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + const { + name, + sub: id, + picture, + } = await ( + await fetch('https://api.linkedin.com/v2/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return { + id, + accessToken, + refreshToken, + expiresIn: expires_in, + name, + picture: picture || '', + username: vanityName, + }; + } + + async generateAuthUrl() { + const state = makeId(6); + const codeVerifier = makeId(30); + const url = `https://www.linkedin.com/oauth/v2/authorization?response_type=code&client_id=${ + process.env.LINKEDIN_CLIENT_ID + }&prompt=none&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/linkedin` + )}&state=${state}&scope=${encodeURIComponent(this.scopes.join(' '))}`; + return { + url, + codeVerifier, + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body = new URLSearchParams(); + body.append('grant_type', 'authorization_code'); + body.append('code', params.code); + body.append( + 'redirect_uri', + `${process.env.FRONTEND_URL}/integrations/social/linkedin${ + params.refresh ? `?refresh=${params.refresh}` : '' + }` + ); + body.append('client_id', process.env.LINKEDIN_CLIENT_ID!); + body.append('client_secret', process.env.LINKEDIN_CLIENT_SECRET!); + + const { + access_token: accessToken, + expires_in: expiresIn, + refresh_token: refreshToken, + scope, + } = await ( + await fetch('https://www.linkedin.com/oauth/v2/accessToken', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }) + ).json(); + + this.checkScopes(this.scopes, scope); + + const { + name, + sub: id, + picture, + } = await ( + await fetch('https://api.linkedin.com/v2/userinfo', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + const { vanityName } = await ( + await fetch('https://api.linkedin.com/v2/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return { + id, + accessToken, + refreshToken, + expiresIn, + name, + picture, + username: vanityName, + }; + } + + async company(token: string, data: { url: string }) { + const { url } = data; + const getCompanyVanity = url.match( + /^https?:\/\/(?:www\.)?linkedin\.com\/company\/([^/]+)\/?$/ + ); + if (!getCompanyVanity || !getCompanyVanity?.length) { + throw new Error('Invalid LinkedIn company URL'); + } + + const { elements } = await ( + await fetch( + `https://api.linkedin.com/v2/organizations?q=vanityName&vanityName=${getCompanyVanity[1]}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202601', + Authorization: `Bearer ${token}`, + }, + } + ) + ).json(); + + return { + options: elements.map((e: { localizedName: string; id: string }) => ({ + label: e.localizedName, + value: `@[${e.localizedName}](urn:li:organization:${e.id})`, + }))?.[0], + }; + } + + protected async uploadPicture( + fileName: string, + accessToken: string, + personId: string, + picture: any, + type = 'personal' as 'company' | 'personal' + ) { + // Determine the appropriate endpoint based on file type + const isVideo = hasExtension(fileName, 'mp4'); + const isPdf = hasExtension(fileName, 'pdf'); + + let endpoint: string; + if (isVideo) { + endpoint = 'videos'; + } else if (isPdf) { + endpoint = 'documents'; + } else { + endpoint = 'images'; + } + + const { + value: { uploadUrl, image, video, document, uploadInstructions, ...all }, + } = await ( + await this.fetch( + `https://api.linkedin.com/rest/${endpoint}?action=initializeUpload`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202601', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + initializeUploadRequest: { + owner: + type === 'personal' + ? `urn:li:person:${personId}` + : `urn:li:organization:${personId}`, + ...(isVideo + ? { + fileSizeBytes: picture.length, + uploadCaptions: false, + uploadThumbnail: false, + } + : {}), + }, + }), + } + ) + ).json(); + + const sendUrlRequest = uploadInstructions?.[0]?.uploadUrl || uploadUrl; + const finalOutput = video || image || document; + + const etags = []; + if (isVideo) { + // Only the Videos API uses multipart chunked uploads. Each 2MB part is + // PUT separately and the returned etags are passed to finalizeUpload. + for (let i = 0; i < picture.length; i += 1024 * 1024 * 2) { + const upload = await this.fetch( + sendUrlRequest, + { + method: 'PUT', + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202601', + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/octet-stream', + }, + body: picture.slice(i, i + 1024 * 1024 * 2), + }, + 'linkedin', + 0, + true + ); + + etags.push(upload.headers.get('etag')); + } + } else { + // Images and documents (PDF carousels) use a single-shot upload URL and + // must be sent as one PUT of the whole file. Chunking here would send + // multiple overwriting PUTs to the same URL, leaving LinkedIn with only + // the final chunk and corrupting anything larger than 2MB. + await this.fetch( + sendUrlRequest, + { + method: 'PUT', + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202601', + Authorization: `Bearer ${accessToken}`, + ...(isPdf ? { 'Content-Type': 'application/pdf' } : {}), + }, + body: picture, + }, + 'linkedin', + 0, + true + ); + } + + if (isVideo) { + await this.fetch( + 'https://api.linkedin.com/rest/videos?action=finalizeUpload', + { + method: 'POST', + body: JSON.stringify({ + finalizeUploadRequest: { + video, + uploadToken: '', + uploadedPartIds: etags, + }, + }), + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202601', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + // After finalizing, the video is processed asynchronously. We have to + // wait until it reaches the AVAILABLE status before attaching it to a + // post, otherwise LinkedIn rejects the post with a processing error. + await this.waitForMediaToBeReady(video, accessToken, 'videos'); + } else if (isPdf) { + // Documents (PDF carousels) are processed asynchronously too and must be + // AVAILABLE before being attached to a post, otherwise LinkedIn rejects + // the post or publishes a broken carousel. + if (type === 'company') { + // Organization-owned documents can be polled for their status. + await this.waitForMediaToBeReady(document, accessToken, 'documents'); + } else { + // A w_member_social (personal) token is write-only for rest/documents + // and can't perform the GET, so polling would always be forbidden. + // Give LinkedIn a moment to finish processing before attaching it. + await timer(10000); + } + } else { + // Images are also processed asynchronously and must be AVAILABLE before + // being attached to a post. + if (type === 'company') { + // Organization-owned images can be polled for their processing status. + await this.waitForMediaToBeReady(image, accessToken, 'images'); + } else { + // A w_member_social (personal) token is write-only for rest/images and + // can't perform the GET, so polling would always be forbidden. Images + // are quick to process, so we just give LinkedIn a moment before + // attaching it to the post. + await timer(10000); + } + } + + return finalOutput; + } + + // Polls the "Get a Video"/"Get an Image" API until the media finishes + // processing (status === AVAILABLE). + // videos: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/videos-api#get-a-video + // images: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/images-api#get-a-single-image + private async waitForMediaToBeReady( + urn: string, + accessToken: string, + type: 'videos' | 'images' | 'documents', + maxAttempts = 20, + intervalMs = 30000 + ): Promise { + const label = + type === 'videos' ? 'video' : type === 'documents' ? 'document' : 'image'; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const json = await ( + await this.fetch( + `https://api.linkedin.com/rest/${type}/${encodeURIComponent(urn)}`, + { + method: 'GET', + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'LinkedIn-Version': '202601', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).json(); + + const { status, processingFailureReason } = json; + + if (status === 'AVAILABLE') { + return; + } + + if (status === 'PROCESSING_FAILED') { + throw new BadBody( + this.identifier, + JSON.stringify(json), + '{}', + `LinkedIn ${label} processing failed${ + processingFailureReason ? `: ${processingFailureReason}` : '' + }` + ); + } + + // status is PROCESSING or WAITING_UPLOAD, keep polling. + await timer(intervalMs); + } + + throw new BadBody( + this.identifier, + '{}', + '{}', + `Timed out waiting for LinkedIn ${label} to be ready` + ); + } + + protected fixText(text: string) { + const pattern = /@\[.+?]\(urn:li:organization.+?\)/g; + const matches = text.match(pattern) || []; + const splitAll = text.split(pattern); + const splitTextReformat = splitAll.map((p) => { + return p + .replace(/\\/g, '\\\\') + .replace(//g, '\\>') + .replace(/#/g, '\\#') + .replace(/~/g, '\\~') + .replace(/_/g, '\\_') + .replace(/\|/g, '\\|') + .replace(/\[/g, '\\[') + .replace(/]/g, '\\]') + .replace(/\*/g, '\\*') + .replace(/\(/g, '\\(') + .replace(/\)/g, '\\)') + .replace(/\{/g, '\\{') + .replace(/}/g, '\\}') + .replace(/@/g, '\\@'); + }); + + const connectAll = splitTextReformat.reduce((all, current) => { + const match = matches.shift(); + all.push(current); + if (match) { + all.push(match); + } + return all; + }, [] as string[]); + + return connectAll.join(''); + } + + private async convertImagesToPdfCarousel( + postDetails: PostDetails[], + firstPost: PostDetails + ): Promise[]> { + if (!firstPost.media?.length) { + return postDetails; + } + + // Fetch all images and get their dimensions + const images = await Promise.all( + firstPost.media.map(async (media) => { + const raw = await readOrFetch(media.path); + const image = sharp(raw, { animated: false }).toFormat('jpeg'); + const { width, height } = await image.metadata(); + const buffer = await image.toBuffer(); + return { buffer, width: width || 0, height: height || 0 }; + }) + ); + + // Find the largest image by area to use as the PDF page size + const largest = images.reduce((max, img) => + img.width * img.height > max.width * max.height ? img : max + ); + + const imageBuffers = images.map((img) => img.buffer); + + // Create a PDF sized to the largest image; it fills the page, + // smaller images are fitted and centered within the same dimensions + const pdfStream = imageToPDF( + imageBuffers, + [largest.width, largest.height] + ) as unknown as Readable; + const pdfBuffer = await this.streamToBuffer(pdfStream); + + // Replace the first post's media with the single PDF + const [first, ...rest] = postDetails; + return [ + { + ...first, + media: [ + { + type: 'image' as const, + path: 'carousel.pdf', + buffer: pdfBuffer, + } as any, + ], + }, + ...rest, + ]; + } + + private async streamToBuffer(stream: Readable): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + stream.on('data', (chunk) => chunks.push(chunk)); + stream.on('end', () => resolve(Buffer.concat(chunks))); + stream.on('error', reject); + }); + } + + private async processMediaForPosts( + postDetails: PostDetails[], + accessToken: string, + personId: string, + type: 'company' | 'personal' + ): Promise> { + const mediaUploads = await Promise.all( + postDetails.flatMap( + (post) => + post.media?.map(async (media) => { + let mediaBuffer: Buffer; + + // Check if media has a buffer (from PDF conversion) + if ( + media && + typeof media === 'object' && + 'buffer' in media && + Buffer.isBuffer(media.buffer) + ) { + mediaBuffer = (media as any).buffer; + } else { + mediaBuffer = await this.prepareMediaBuffer(media.path); + } + + const uploadedMediaId = await this.uploadPicture( + media.path, + accessToken, + personId, + mediaBuffer, + type + ); + + return { + id: uploadedMediaId, + postId: post.id, + }; + }) || [] + ) + ); + + return mediaUploads.reduce((acc, upload) => { + if (!upload?.id) return acc; + + acc[upload.postId] = acc[upload.postId] || []; + acc[upload.postId].push(upload.id); + return acc; + }, {} as Record); + } + + private async prepareMediaBuffer(mediaUrl: string): Promise { + const isVideo = hasExtension(mediaUrl, 'mp4'); + const isGif = lookup(mediaUrl) === 'image/gif'; + + // GIFs and videos pass through untouched (sharp would break animation). + if (isVideo || isGif) { + return Buffer.from(await readOrFetch(mediaUrl)); + } + + const mime = lookup(mediaUrl); + // PNG and JPEG (covers both .jpg and .jpeg) keep their original format; + // anything else (webp, tiff, ...) is converted to jpeg for compatibility. + const keepFormat = mime === 'image/png' || mime === 'image/jpeg'; + + // Always downscale to stay under LinkedIn's 36,152,320-pixel cap: fit within + // a 6000x6000 box (max 36,000,000 px for any aspect ratio) while preserving + // the aspect ratio and never enlarging smaller images (downscale-only). + // NOTE: this guard is required for self-hosted instances running with + // DISABLE_IMAGE_COMPRESSION=true, where the frontend no longer shrinks + // uploads and full-size images reach LinkedIn directly. Do not remove it on + // the assumption that the frontend compression already caps dimensions. + const pipeline = sharp(await readOrFetch(mediaUrl), { animated: false }).resize({ + width: 6000, + height: 6000, + fit: 'inside', + withoutEnlargement: true, + }); + + return await (keepFormat ? pipeline : pipeline.toFormat('jpeg')).toBuffer(); + } + + private buildPostContent(isPdf: boolean, mediaIds: string[], pdfTitle?: string) { + if (mediaIds.length === 0) { + return {}; + } + + if (mediaIds.length === 1) { + return { + content: { + media: { + ...(isPdf ? { title: pdfTitle || 'slides' } : {}), + id: mediaIds[0], + }, + }, + }; + } + + return { + content: { + multiImage: { + images: mediaIds.map((id) => ({ id })), + }, + }, + }; + } + + private createLinkedInPostPayload( + id: string, + type: 'company' | 'personal', + message: string, + mediaIds: string[], + isPdf: boolean, + pdfTitle?: string + ) { + const author = + type === 'personal' ? `urn:li:person:${id}` : `urn:li:organization:${id}`; + + return { + author, + commentary: this.fixText(message), + visibility: 'PUBLIC', + distribution: { + feedDistribution: 'MAIN_FEED', + targetEntities: [] as string[], + thirdPartyDistributionChannels: [] as string[], + }, + ...this.buildPostContent(isPdf, mediaIds, pdfTitle), + lifecycleState: 'PUBLISHED', + isReshareDisabledByAuthor: false, + }; + } + + private async createMainPost( + id: string, + accessToken: string, + firstPost: PostDetails, + mediaIds: string[], + type: 'company' | 'personal', + isPdf: boolean + ): Promise { + const pdfTitle = isPdf + ? firstPost.settings?.carousel_name || 'slides' + : undefined; + + const postPayload = this.createLinkedInPostPayload( + id, + type, + firstPost.message, + mediaIds, + isPdf, + pdfTitle + ); + + const response = await this.fetch(`https://api.linkedin.com/rest/posts`, { + method: 'POST', + headers: { + 'LinkedIn-Version': '202601', + 'X-Restli-Protocol-Version': '2.0.0', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(postPayload), + }); + + if (response.status !== 201 && response.status !== 200) { + throw new BadBody( + this.identifier, + '{}', + JSON.stringify(postPayload), + 'Error posting to LinkedIn' + ); + } + + return response.headers.get('x-restli-id')!; + } + + private async createCommentPost( + id: string, + accessToken: string, + post: PostDetails, + parentPostId: string, + type: 'company' | 'personal' + ): Promise { + const actor = + type === 'personal' ? `urn:li:person:${id}` : `urn:li:organization:${id}`; + + const response = await this.fetch( + `https://api.linkedin.com/rest/socialActions/${encodeURIComponent( + parentPostId + )}/comments`, + { + method: 'POST', + headers: { + 'LinkedIn-Version': '202306', + 'X-Restli-Protocol-Version': '2.0.0', + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + actor, + object: parentPostId, + message: { + text: this.fixText(post.message), + }, + }), + } + ); + + const { object } = await response.json(); + return object; + } + + private createPostResponse( + postId: string, + originalPostId: string, + isMainPost: boolean = false + ): PostResponse { + const baseUrl = isMainPost + ? 'https://www.linkedin.com/feed/update/' + : 'https://www.linkedin.com/embed/feed/update/'; + + return { + status: 'posted', + postId, + id: originalPostId, + releaseURL: `${baseUrl}${postId}`, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration, + type = 'personal' as 'company' | 'personal' + ): Promise { + let processedPostDetails = postDetails; + const [firstPost] = postDetails; + + // Check if we should convert images to PDF carousel + if (this.assetBoolean(firstPost.settings?.post_as_images_carousel)) { + processedPostDetails = await this.convertImagesToPdfCarousel( + postDetails, + firstPost + ); + } + + const [processedFirstPost] = processedPostDetails; + + // Process and upload media for the first post only + const uploadedMedia = await this.processMediaForPosts( + [processedFirstPost], + accessToken, + id, + type + ); + + // Get media IDs for the main post + const mainPostMediaIds = ( + uploadedMedia[processedFirstPost.id] || [] + ).filter(Boolean); + + // Create the main LinkedIn post + const mainPostId = await this.createMainPost( + id, + accessToken, + processedFirstPost, + mainPostMediaIds, + type, + this.assetBoolean(firstPost.settings?.post_as_images_carousel) + ); + + // Return response for main post only + return [this.createPostResponse(mainPostId, processedFirstPost.id, true)]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration, + type = 'personal' as 'company' | 'personal' + ): Promise { + const [commentPost] = postDetails; + + const commentPostId = await this.createCommentPost( + id, + accessToken, + commentPost, + postId, + type + ); + + return [this.createPostResponse(commentPostId, commentPost.id, false)]; + } + + @PostPlug({ + identifier: 'linkedin-add-comment', + title: 'Add comments by a different account', + description: 'Add accounts to comment on your post', + pickIntegration: ['linkedin', 'linkedin-page'], + fields: [ + { + name: 'comment', + description: 'The comment to add to the post', + type: 'textarea', + placeholder: 'Enter your comment here', + }, + ], + }) + async addComment( + integration: Integration, + originalIntegration: Integration, + postId: string, + information: any, + isPersonal = true + ) { + return this.comment( + integration.internalId, + postId, + undefined, + integration.token, + [ + { + id: makeId(10), + message: information.comment, + media: [], + settings: { + post_as_images_carousel: false, + }, + }, + ], + integration, + isPersonal ? 'personal' : 'company' + ); + } + + @PostPlug({ + identifier: 'linkedin-repost-post-users', + title: 'Add Re-posters', + description: 'Add accounts to repost your post', + pickIntegration: ['linkedin', 'linkedin-page'], + fields: [], + }) + async repostPostUsers( + integration: Integration, + originalIntegration: Integration, + postId: string, + information: any, + isPersonal = true + ) { + await this.fetch(`https://api.linkedin.com/rest/posts`, { + body: JSON.stringify({ + author: + (isPersonal ? 'urn:li:person:' : `urn:li:organization:`) + + `${integration.internalId}`, + commentary: '', + visibility: 'PUBLIC', + distribution: { + feedDistribution: 'MAIN_FEED', + targetEntities: [], + thirdPartyDistributionChannels: [], + }, + lifecycleState: 'PUBLISHED', + isReshareDisabledByAuthor: false, + reshareContext: { + parent: postId, + }, + }), + method: 'POST', + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'Content-Type': 'application/json', + 'LinkedIn-Version': '202601', + Authorization: `Bearer ${integration.token}`, + }, + }); + } + + override async mention(token: string, data: { query: string }) { + const { elements } = await ( + await fetch( + `https://api.linkedin.com/v2/organizations?q=vanityName&vanityName=${encodeURIComponent( + data.query + )}&projection=(elements*(id,localizedName,logoV2(original~:playableStreams)))`, + { + headers: { + 'X-Restli-Protocol-Version': '2.0.0', + 'Content-Type': 'application/json', + 'LinkedIn-Version': '202601', + Authorization: `Bearer ${token}`, + }, + } + ) + ).json(); + + return elements.map((p: any) => ({ + id: String(p.id), + label: p.localizedName, + image: + p.logoV2?.['original~']?.elements?.[0]?.identifiers?.[0]?.identifier || + '', + })); + } + + mentionFormat(idOrHandle: string, name: string) { + return `@[${name.replace('@', '')}](urn:li:organization:${idOrHandle})`; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..371126c2296facdaf20bb35241e3a1dc451f1d0d --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/listmonk.provider.ts @@ -0,0 +1,277 @@ +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { SocialAbstract } from '../social.abstract'; +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from './social.integrations.interface'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { ListmonkDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/listmonk.dto'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; +import slugify from 'slugify'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +export class ListmonkProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 100; // Bluesky has moderate rate limits + identifier = 'listmonk'; + name = 'ListMonk'; + isBetweenSteps = false; + scopes = [] as string[]; + editor = 'html' as const; + dto = ListmonkDto; + + maxLength() { + return 100000000; + } + + async customFields() { + return [ + { + key: 'url', + label: 'URL', + defaultValue: '', + validation: `/^(https?:\\/\\/)(?:\\S+(?::\\S*)?@)?(?:(?:localhost)|(?:\\d{1,3}(?:\\.\\d{1,3}){3})|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63})(?::\\d{2,5})?(?:\\/[^\\s?#]*)?(?:\\?[^\\s#]*)?(?:#[^\\s]*)?$/`, + type: 'text' as const, + }, + { + key: 'username', + label: 'Username', + validation: `/^.+$/`, + type: 'text' as const, + }, + { + key: 'password', + label: 'Password', + validation: `/^.{3,}$/`, + type: 'password' as const, + }, + ]; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body: { url: string; username: string; password: string } = + JSON.parse(Buffer.from(params.code, 'base64').toString()); + + console.log(body); + try { + const basic = Buffer.from(body.username + ':' + body.password).toString( + 'base64' + ); + + const { data } = await ( + await this.fetch(body.url + '/api/settings', { + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: 'Basic ' + basic, + }, + }) + ).json(); + + return { + refreshToken: basic, + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + accessToken: basic, + id: Buffer.from(body.url).toString('base64'), + name: data['app.site_name'], + picture: data['app.logo_url'] || '', + username: data['app.site_name'], + }; + } catch (e) { + console.log(e); + return 'Invalid credentials'; + } + } + + @Tool({ description: 'List of available lists', dataSchema: [] }) + async list( + token: string, + data: any, + internalId: string, + integration: Integration + ) { + const body: { url: string; username: string; password: string } = + JSON.parse( + AuthService.fixedDecryption(integration.customInstanceDetails!) + ); + + const auth = Buffer.from(`${body.username}:${body.password}`).toString( + 'base64' + ); + + const postTypes = await ( + await this.fetch(`${body.url}/api/lists`, { + headers: { + Authorization: `Basic ${auth}`, + }, + }) + ).json(); + + return postTypes.data.results.map((p: any) => ({ id: p.id, name: p.name })); + } + + @Tool({ description: 'List of available templates', dataSchema: [] }) + async templates( + token: string, + data: any, + internalId: string, + integration: Integration + ) { + const body: { url: string; username: string; password: string } = + JSON.parse( + AuthService.fixedDecryption(integration.customInstanceDetails!) + ); + + const auth = Buffer.from(`${body.username}:${body.password}`).toString( + 'base64' + ); + + const postTypes = await ( + await this.fetch(`${body.url}/api/templates`, { + headers: { + Authorization: `Basic ${auth}`, + }, + }) + ).json(); + + return [ + { id: 0, name: 'Default' }, + ...postTypes.data.map((p: any) => ({ id: p.id, name: p.name })), + ]; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const body: { url: string; username: string; password: string } = + JSON.parse( + AuthService.fixedDecryption(integration.customInstanceDetails!) + ); + + const auth = Buffer.from(`${body.username}:${body.password}`).toString( + 'base64' + ); + + const sendBody = ` + + + +
+ ${postDetails[0].message} +
+`; + + const { + data: { uuid: postId, id: campaignId }, + } = await ( + await this.fetch(body.url + '/api/campaigns', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Basic ${auth}`, + }, + body: JSON.stringify({ + name: slugify(postDetails[0].settings.subject, { + lower: true, + strict: true, + trim: true, + }), + type: 'regular', + content_type: 'html', + subject: postDetails[0].settings.subject, + lists: [+postDetails[0].settings.list], + body: sendBody, + ...(+postDetails?.[0]?.settings?.template + ? { template_id: +postDetails[0].settings.template } + : {}), + }), + }) + ).json(); + + await this.fetch(body.url + `/api/campaigns/${campaignId}/status`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Basic ${auth}`, + }, + body: JSON.stringify({ + status: 'running', + }), + }); + + return [ + { + id: postDetails[0].id, + status: 'completed', + releaseURL: `${body.url}/api/campaigns/${campaignId}/preview`, + postId, + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..9680088406cd612c496ce0d059327141a3581f3d --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.custom.provider.ts @@ -0,0 +1,106 @@ +import { + ClientInformation, + PostDetails, + PostResponse, +} 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 { getSsrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import { Integration } from '@prisma/client'; + +export class MastodonCustomProvider extends MastodonProvider { + override identifier = 'mastodon-custom'; + override name = 'M. Instance'; + override maxConcurrentJob = 5; // Custom Mastodon instances typically have generous limits + editor = 'normal' as const; + + async externalUrl(url: string) { + const form = new FormData(); + form.append('client_name', 'Postiz'); + form.append( + 'redirect_uris', + `${process.env.FRONTEND_URL}/integrations/social/mastodon` + ); + form.append('scopes', this.scopes.join(' ')); + form.append('website', process.env.FRONTEND_URL!); + const { client_id, client_secret, ...all } = await ( + await fetch(url + '/api/v1/apps', { + method: 'POST', + body: form, + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + }) + ).json(); + + return { + client_id, + client_secret, + }; + } + override async generateAuthUrl( + refresh?: string, + external?: ClientInformation + ) { + const state = makeId(6); + const url = this.generateUrlDynamic( + external?.instanceUrl!, + state, + external?.client_id!, + process.env.FRONTEND_URL!, + refresh + ); + + return { + url, + codeVerifier: makeId(10), + state, + }; + } + + override async authenticate( + params: { + code: string; + codeVerifier: string; + refresh?: string; + }, + clientInformation?: ClientInformation + ) { + return this.dynamicAuthenticate( + clientInformation?.client_id!, + clientInformation?.client_secret!, + clientInformation?.instanceUrl!, + params.code + ); + } + + override async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + return this.dynamicPost( + id, + accessToken, + process.env.MASTODON_URL || 'https://mastodon.social', + postDetails + ); + } + + override async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + return this.dynamicComment( + id, + postId, + lastCommentId, + accessToken, + process.env.MASTODON_URL || 'https://mastodon.social', + postDetails + ); + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e2f17feffe29d78f3e6bfa39c051ba88f89c32c --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/mastodon.provider.ts @@ -0,0 +1,283 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { getSsrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { number, string } from 'yup'; + +export class MastodonProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 5; // Mastodon instances typically have generous limits + identifier = 'mastodon'; + name = 'Mastodon'; + isBetweenSteps = false; + scopes = ['write:statuses', 'profile', 'write:media']; + editor = 'normal' as const; + maxLength() { + return 500; + } + + override handleErrors( + body: string, + status: number + ): + | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | undefined { + if (body.includes('Your login is currently disabled')) { + return { + type: 'refresh-token', + value: 'Your login is currently disabled', + }; + } + + return undefined; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + protected generateUrlDynamic( + customUrl: string, + state: string, + clientId: string, + url: string + ) { + return `${customUrl}/oauth/authorize?client_id=${clientId}&response_type=code&redirect_uri=${encodeURIComponent( + `${url}/integrations/social/mastodon` + )}&scope=${this.scopes.join('+')}&state=${state}`; + } + + async generateAuthUrl() { + const state = makeId(6); + const url = this.generateUrlDynamic( + process.env.MASTODON_URL || 'https://mastodon.social', + state, + process.env.MASTODON_CLIENT_ID!, + process.env.FRONTEND_URL! + ); + return { + url, + codeVerifier: makeId(10), + state, + }; + } + + protected async dynamicAuthenticate( + clientId: string, + clientSecret: string, + url: string, + code: string + ) { + const form = new FormData(); + form.append('client_id', clientId); + form.append('client_secret', clientSecret); + form.append('code', code); + form.append('grant_type', 'authorization_code'); + form.append( + 'redirect_uri', + `${process.env.FRONTEND_URL}/integrations/social/mastodon` + ); + form.append('scope', this.scopes.join(' ')); + + const tokenInformation = await ( + await this.fetch(`${url}/oauth/token`, { + method: 'POST', + body: form, + }) + ).json(); + + const personalInformation = await ( + await this.fetch(`${url}/api/v1/accounts/verify_credentials`, { + headers: { + Authorization: `Bearer ${tokenInformation.access_token}`, + }, + }) + ).json(); + + return { + id: personalInformation.id, + name: personalInformation.display_name || personalInformation.acct, + accessToken: tokenInformation.access_token, + refreshToken: 'null', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + picture: personalInformation?.avatar || '', + username: personalInformation.username, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + return this.dynamicAuthenticate( + process.env.MASTODON_CLIENT_ID!, + process.env.MASTODON_CLIENT_SECRET!, + process.env.MASTODON_URL || 'https://mastodon.social', + params.code + ); + } + + async uploadFile( + instanceUrl: string, + fileUrl: string, + accessToken: string, + alt?: string + ) { + const form = new FormData(); + form.append( + 'file', + await fetch(fileUrl, { + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + }).then((r) => r.blob()) + ); + if (alt) { + form.append('description', alt); + } + const media = await ( + await this.fetch(`${instanceUrl}/api/v1/media`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: form, + }) + ).json(); + return media.id; + } + + async dynamicPost( + id: string, + accessToken: string, + url: string, + postDetails: PostDetails[] + ): Promise { + const [firstPost] = postDetails; + + const uploadFiles = await Promise.all( + firstPost?.media?.map((media) => + this.uploadFile(url, media.path, accessToken, media.alt) + ) || [] + ); + + 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); + } + } + + 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 { + const [commentPost] = postDetails; + const replyToId = lastCommentId || postId; + + const uploadFiles = await Promise.all( + commentPost?.media?.map((media) => + this.uploadFile(url, media.path, accessToken, media.alt) + ) || [] + ); + + 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( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + return this.dynamicPost( + id, + accessToken, + process.env.MASTODON_URL || 'https://mastodon.social', + postDetails + ); + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + return this.dynamicComment( + id, + postId, + lastCommentId, + accessToken, + process.env.MASTODON_URL || 'https://mastodon.social', + postDetails + ); + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts b/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..5aab52d644f9f758d94c371bf66461bea96010b3 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/medium.provider.ts @@ -0,0 +1,143 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { MediumSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/medium.settings.dto'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +export class MediumProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; // Medium has lenient publishing limits + identifier = 'medium'; + name = 'Medium'; + isBetweenSteps = false; + scopes = [] as string[]; + editor = 'markdown' as const; + dto = MediumSettingsDto; + maxLength() { + return 100000; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async customFields() { + return [ + { + key: 'apiKey', + label: 'API key', + validation: `/^.{3,}$/`, + type: 'password' as const, + }, + ]; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body = JSON.parse(Buffer.from(params.code, 'base64').toString()); + try { + const { + data: { name, id, imageUrl, username }, + } = await ( + await fetch('https://api.medium.com/v1/me', { + headers: { + Authorization: `Bearer ${body.apiKey}`, + }, + }) + ).json(); + + return { + refreshToken: '', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + accessToken: body.apiKey, + id, + name, + picture: imageUrl || '', + username, + }; + } catch (err) { + return 'Invalid credentials'; + } + } + + @Tool({ description: 'List of publications', dataSchema: [] }) + async publications(accessToken: string, _: any, id: string) { + const { data } = await ( + await fetch(`https://api.medium.com/v1/users/${id}/publications`, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return data; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const { settings } = postDetails?.[0] || { settings: {} }; + const { data } = await ( + await fetch( + settings?.publication + ? `https://api.medium.com/v1/publications/${settings?.publication}/posts` + : `https://api.medium.com/v1/users/${id}/posts`, + { + method: 'POST', + body: JSON.stringify({ + title: settings.title, + contentFormat: 'markdown', + content: postDetails?.[0].message, + ...(settings.canonical ? { canonicalUrl: settings.canonical } : {}), + ...(settings?.tags?.length + ? { tags: settings?.tags?.map((p: any) => p.value) } + : {}), + publishStatus: settings?.publication ? 'draft' : 'public', + }), + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + ).json(); + + return [ + { + id: postDetails?.[0].id, + status: 'completed', + postId: data.id, + releaseURL: data.url, + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts b/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..51c51c5350a9b5643515c52eee8efbf390102821 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/mewe.provider.ts @@ -0,0 +1,296 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +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'; +import { MeweDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/mewe.dto'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; + +export class MeweProvider extends SocialAbstract implements SocialProvider { + identifier = 'mewe'; + name = 'MeWe'; + isBetweenSteps = false; + scopes = [] as string[]; + editor = 'normal' as const; + dto = MeweDto; + + private get meweHost() { + return process.env.MEWE_HOST || 'https://mewe.com'; + } + + private authHeaders(apiToken: string) { + return { + 'X-App-Id': process.env.MEWE_APP_ID!, + 'X-Api-Key': process.env.MEWE_API_KEY!, + Authorization: `Bearer ${apiToken}`, + 'Content-Type': 'application/json', + }; + } + + maxLength() { + return 63206; + } + + override handleErrors( + body: string + ): + | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | undefined { + if (body.indexOf('Unauthorized') > -1) { + return { + type: 'refresh-token' as const, + value: 'Access token expired, please re-authenticate', + }; + } + + if (body.indexOf('Enhance Your Calm') > -1 || body.indexOf('420') > -1) { + return { + type: 'retry' as const, + value: 'Rate limited, retrying...', + }; + } + + if (body.indexOf('Forbidden') > -1) { + return { + type: 'bad-body' as const, + value: 'Insufficient permissions for this action', + }; + } + + return undefined; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: + `${this.meweHost}/login` + + `?client_id=${process.env.MEWE_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/mewe` + )}` + + `&state=${state}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const loginRequestToken = params.code; + + if (!loginRequestToken) { + return 'No login request token received. Please try again.'; + } + + try { + // Exchange loginRequestToken for apiToken + const tokenResponse = await fetch( + `${this.meweHost}/api/dev/token?loginRequestToken=${loginRequestToken}`, + { + method: 'GET', + headers: { + 'X-App-Id': process.env.MEWE_APP_ID!, + 'X-Api-Key': process.env.MEWE_API_KEY!, + }, + } + ); + + if (!tokenResponse.ok) { + return 'Failed to exchange token. Please try again.'; + } + + const tokenData = await tokenResponse.json(); + + if (tokenData.pending) { + return 'Login request is still pending. Please approve on MeWe and try again.'; + } + + if (!tokenData.apiToken) { + return 'No API token received. Please try again.'; + } + + const apiToken = tokenData.apiToken; + const expiresAt = tokenData.expiresAt; + + // Fetch user profile + const profileResponse = await fetch(`${this.meweHost}/api/dev/me`, { + method: 'GET', + headers: this.authHeaders(apiToken), + }); + + if (!profileResponse.ok) { + return 'Failed to fetch MeWe profile.'; + } + + const profile = await profileResponse.json(); + + const expiresIn = expiresAt + ? dayjs(expiresAt).unix() - dayjs().unix() + : dayjs().add(30, 'days').unix() - dayjs().unix(); + + return { + id: profile.userId, + name: + profile.name || + `${profile.firstName || ''} ${profile.lastName || ''}`.trim(), + accessToken: apiToken, + refreshToken: '', + expiresIn, + picture: '', + username: profile.handle || '', + }; + } catch (e) { + console.log(e); + return 'MeWe authentication failed. Please try again.'; + } + } + + @Tool({ description: 'Groups', dataSchema: [] }) + async groups( + accessToken: string, + params: any, + id: string, + integration: Integration + ) { + try { + const allGroups: any[] = []; + let nextUrl: string | null = `${this.meweHost}/api/dev/groups`; + + while (nextUrl) { + const response = await fetch(nextUrl, { + method: 'GET', + headers: this.authHeaders(accessToken), + }); + + if (!response.ok) break; + + const data = await response.json(); + allGroups.push(...(data.groups || [])); + nextUrl = data.nextPage ? `${this.meweHost}${data.nextPage}` : null; + } + + return allGroups.map((group: any) => ({ + id: String(group.groupId), + name: group.name, + })); + } catch (err) { + return []; + } + } + + private async uploadPhoto( + accessToken: string, + mediaPath: string + ): Promise { + const mediaResponse = await fetch(mediaPath); + const blob = await mediaResponse.blob(); + const fileName = mediaPath.split('/').pop() || 'photo.jpg'; + + const form = new FormData(); + form.append('file', blob, fileName); + + const uploadResponse = await fetch( + `${this.meweHost}/api/dev/photo/upload`, + { + method: 'POST', + headers: { + 'X-App-Id': process.env.MEWE_APP_ID!, + 'X-Api-Key': process.env.MEWE_API_KEY!, + Authorization: `Bearer ${accessToken}`, + }, + body: form, + } + ); + + if (!uploadResponse.ok) { + const errorText = await uploadResponse.text(); + throw new Error(`Photo upload failed: ${errorText}`); + } + + const uploadData = await uploadResponse.json(); + return uploadData.id; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [firstPost] = postDetails; + const postType = firstPost.settings.postType || 'group'; + const groupId = firstPost.settings.group; + + // Upload photos if present (exclude videos) + const imageMedia = + firstPost.media?.filter((m) => !m.path || !hasExtension(m.path, 'mp4')) || + []; + + const uploadedPhotoIds: string[] = []; + for (const media of imageMedia) { + const photoId = await this.uploadPhoto(accessToken, media.path); + uploadedPhotoIds.push(photoId); + } + + const postBody: Record = { text: firstPost.message }; + if (uploadedPhotoIds.length > 0) { + postBody.uploadedPhotoIds = uploadedPhotoIds; + } + + const postUrl = + postType === 'timeline' + ? `${this.meweHost}/api/dev/me/post` + : `${this.meweHost}/api/dev/group/${groupId}/post`; + + // MeWe post endpoint may return 204 (no content), so use raw fetch + const postResponse = await fetch(postUrl, { + method: 'POST', + headers: this.authHeaders(accessToken), + body: JSON.stringify(postBody), + }); + + if (!postResponse.ok) { + const errorText = await postResponse.text(); + const handleError = this.handleErrors(errorText); + if (handleError) { + throw new Error(handleError.value); + } + throw new Error('Failed to create MeWe post'); + } + + const postId = makeId(12); + + const releaseURL = postType === 'timeline' ? `https://mewe.com/${integration.profile}/posts` : `https://mewe.com/group/${firstPost.settings.group}`; + + return [ + { + id: firstPost.id, + postId, + releaseURL, + status: 'success', + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts b/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..50d44445d8e03372d080d993c4c5d86c32d9499c --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/moltbook.provider.ts @@ -0,0 +1,195 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +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'; +import axios from 'axios'; + +const MOLTBOOK_API_BASE = 'https://www.moltbook.com/api/v1'; + +export class MoltbookProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 100; // Moltbook: 100 requests/minute + identifier = 'moltbook'; + name = 'Moltbook'; + isBetweenSteps = false; + scopes = [] as string[]; + isWeb3 = true; + editor = 'normal' as const; + + maxLength() { + return 300; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + async registerAgent(name: string, description: string) { + const response = await axios.post( + `${MOLTBOOK_API_BASE}/agents/register`, + { name, description }, + { headers: { 'Content-Type': 'application/json' } } + ); + + if (!response.data.success) { + throw new Error(response.data.error || 'Registration failed'); + } + + return response.data.agent; + } + + async checkAgentStatus(apiKey: string) { + const response = await axios.get(`${MOLTBOOK_API_BASE}/agents/status`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + return response.data; + } + + async getAgentProfile(apiKey: string) { + const response = await axios.get(`${MOLTBOOK_API_BASE}/agents/me`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + + if (!response.data.success) { + throw new Error(response.data.error || 'Failed to get profile'); + } + + return response.data.agent; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const apiKey = params.code; + + const profile = await this.getAgentProfile(apiKey); + + return { + id: profile.name || profile.id, + name: profile.display_name || profile.name, + accessToken: apiKey, + refreshToken: '', + expiresIn: dayjs().add(200, 'year').unix() - dayjs().unix(), + picture: '', + username: profile.name, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const results: PostResponse[] = []; + + for (const post of postDetails) { + const postData: { + submolt: string; + title: string; + content?: string; + url?: string; + } = { + submolt: post.settings?.submolt || 'general', + title: post.message.slice(0, 100), + content: post.message, + }; + + const response = await axios.post( + `${MOLTBOOK_API_BASE}/posts`, + postData, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.data.success) { + throw new Error(response.data.error || 'Failed to create post'); + } + + const postId = response.data.post.id; + results.push({ + id: post.id, + postId: String(postId), + releaseURL: `https://www.moltbook.com/post/${postId}`, + status: 'completed', + }); + } + + return results; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const results: PostResponse[] = []; + + for (const post of postDetails) { + const commentData: { content: string; parent_id?: string } = { + content: post.message, + }; + + if (lastCommentId) { + commentData.parent_id = lastCommentId; + } + + const response = await axios.post( + `${MOLTBOOK_API_BASE}/posts/${postId}/comments`, + commentData, + { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.data.success) { + throw new Error(response.data.error || 'Failed to create comment'); + } + + const commentId = response.data.comment.id; + results.push({ + id: post.id, + postId: String(commentId), + releaseURL: `https://www.moltbook.com/post/${postId}`, + status: 'completed', + }); + } + + return results; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..c89f21dd773a5f5185481cbdd518907cff7f4385 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/nostr.provider.ts @@ -0,0 +1,236 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import dayjs from 'dayjs'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { getPublicKey, Relay, finalizeEvent, SimplePool } from 'nostr-tools'; + +import WebSocket from 'ws'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; +import { Integration } from '@prisma/client'; + +// @ts-ignore +global.WebSocket = WebSocket; + +const list = [ + 'wss://nos.lol', + 'wss://relay.damus.io', + 'wss://relay.snort.social', + 'wss://temp.iris.to', + 'wss://vault.iris.to', +]; + +const pool = new SimplePool(); + +export class NostrProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 5; + identifier = 'nostr'; + name = 'Nostr'; + isBetweenSteps = false; + scopes = [] as string[]; + editor = 'normal' as const; + toolTip = 'Make sure you private a HEX key of your Nostr private key, you can get it from websites like iris.to' + + maxLength() { + return 100000; + } + + async customFields() { + return [ + { + key: 'password', + label: 'Nostr private key', + validation: `/^.{3,}$/`, + type: 'password' as const, + }, + ]; + } + + async refreshToken(refresh_token: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(17); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + private async findRelayInformation(pubkey: string) { + // This queries ALL relays in parallel and resolves with + // the first matching event from ANY relay. + const evt = await pool.get(list, { + kinds: [0], + authors: [pubkey], + limit: 1, + }); + + if (!evt) return {}; + + let content: any = {}; + try { + content = JSON.parse(evt.content || '{}'); + } catch { + return {}; + } + + if (content.name || content.displayName || content.display_name) { + return content; + } + + return {}; + } + + private async publish(pubkey: string, event: any) { + let id = ''; + for (const relay of list) { + try { + const relayInstance = await Relay.connect(relay); + const value = new Promise((resolve) => { + relayInstance.subscribe([{ kinds: [1], authors: [pubkey] }], { + eoseTimeout: 6000, + onevent: (event) => { + resolve(event); + }, + oneose: () => { + resolve({}); + }, + onclose: () => { + resolve({}); + }, + }); + }); + + await relayInstance.publish(event); + const all = await value; + relayInstance.close(); + // relayInstance.close(); + id = id || all?.id; + } catch (err) { + /**empty**/ + } + } + + return id; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + try { + const body = JSON.parse(Buffer.from(params.code, 'base64').toString()); + + const pubkey = getPublicKey( + Uint8Array.from( + body.password.match(/.{1,2}/g).map((byte: any) => parseInt(byte, 16)) + ) + ); + + const user = await this.findRelayInformation(pubkey); + + return { + id: pubkey, + name: user.display_name || user.displayName || user.name || 'No Name', + accessToken: AuthService.signJWT({ password: body.password }), + refreshToken: '', + expiresIn: dayjs().add(200, 'year').unix() - dayjs().unix(), + picture: user?.picture || '', + username: user.name || 'nousername', + }; + } catch (e) { + console.log(e); + return 'Invalid credentials'; + } + } + + private buildContent(post: PostDetails): string { + const mediaContent = post.media?.map((m) => m.path).join('\n\n') || ''; + return mediaContent + ? `${post.message}\n\n${mediaContent}` + : post.message; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const { password } = AuthService.verifyJWT(accessToken) as any; + const [firstPost] = postDetails; + + const textEvent = finalizeEvent( + { + kind: 1, // Text note + content: this.buildContent(firstPost), + tags: [], + created_at: Math.floor(Date.now() / 1000), + }, + password + ); + + const eventId = await this.publish(id, textEvent); + + return [ + { + id: firstPost.id, + postId: String(eventId), + releaseURL: `https://primal.net/e/${eventId}`, + status: 'completed', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const { password } = AuthService.verifyJWT(accessToken) as any; + const [commentPost] = postDetails; + const replyToId = lastCommentId || postId; + + const textEvent = finalizeEvent( + { + kind: 1, // Text note + content: this.buildContent(commentPost), + tags: [ + ['e', replyToId, '', 'reply'], + ['p', id], + ], + created_at: Math.floor(Date.now() / 1000), + }, + password + ); + + const eventId = await this.publish(id, textEvent); + + return [ + { + id: commentPost.id, + postId: String(eventId), + releaseURL: `https://primal.net/e/${eventId}`, + status: 'completed', + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f416cc41b58e5d35e5eb79452f5cbf884355121 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/pinterest.provider.ts @@ -0,0 +1,531 @@ +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { PinterestSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/pinterest.dto'; +import axios from 'axios'; +import FormData from 'form-data'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { + BadBody, + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import dayjs from 'dayjs'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; + +@Rules( + 'Pinterest requires at least one media, if posting a video, you must have two attachment, one for video, one for the cover picture, When posting a video, there can be only one, if posting images, there can be maximum 5' +) +export class PinterestProvider + extends SocialAbstract + implements SocialProvider +{ + identifier = 'pinterest'; + name = 'Pinterest'; + isBetweenSteps = false; + scopes = [ + 'boards:read', + 'boards:write', + 'pins:read', + 'pins:write', + 'user_accounts:read', + ]; + override maxConcurrentJob = 3; // Pinterest has more lenient rate limits + maxLength() { + return 500; + } + + dto = PinterestSettingsDto; + + override async checkValidity( + [firstItem]: Array + ): Promise { + const isMp4 = firstItem?.find( + (item) => (item?.path?.indexOf?.('mp4') ?? -1) > -1 + ); + const isPicture = firstItem?.find( + (item) => (item?.path?.indexOf?.('mp4') ?? -1) === -1 + ); + if ((firstItem?.length ?? 0) === 0) { + return 'Requires at least one media'; + } + if ((firstItem?.length ?? 0) > 5) { + return 'You can only have up to 5 media items'; + } + if (isMp4 && firstItem?.length !== 2 && !isPicture) { + return 'If posting a video you have to also include a cover image as second media'; + } + if (isMp4 && (firstItem?.length ?? 0) > 2) { + return 'If posting a video you can only have two media items'; + } + + if ( + (firstItem?.length ?? 0) > 1 && + firstItem?.every((p) => (p?.path?.indexOf?.('mp4') ?? -1) === -1) + ) { + const loadAll = await Promise.all( + firstItem?.map((p) => this.getImageDimensions(p?.path)) ?? [] + ); + const checkAllTheSameWidthHeight = loadAll?.every((p, i, arr) => { + return p?.width === arr?.[0]?.width && p?.height === arr?.[0]?.height; + }); + if (!checkAllTheSameWidthHeight) { + return 'Requires all images to have the same width and height'; + } + } + return true; + } + + editor = 'normal' as const; + + public override handleErrors(body: string): + | { + type: 'refresh-token' | 'bad-body' | 'retry'; + value: string; + } + | undefined { + if (body.indexOf('constraint: maxItems=5') > -1) { + return { + type: 'bad-body' as const, + value: 'You can upload a maximum of 5 images per post on Pinterest.', + }; + } + if (body.indexOf('Unable to reach the URL') > -1) { + return { + type: 'retry' as const, + value: 'Pinterest was unable to reach the URL provided. Please check the link and try again.', + } + } + if (body.indexOf(`does not match '^\\\\\\\\\\\\\\\\d+$'`) > -1) { + return { + type: 'bad-body' as const, + value: 'The board ID must be a numeric string. Please check the board ID format.', + } + } + if (body.indexOf('Board not found') > -1) { + return { + type: 'bad-body' as const, + value: 'The specified board was not found. Please check the board ID.', + } + } + if (body.indexOf('cover_image_url or cover_image_content_type') > -1) { + return { + type: 'bad-body' as const, + value: + 'When uploading a video, you must add also an image to be used as a cover image.', + }; + } + + return undefined; + } + + async refreshToken(refreshToken: string): Promise { + const { access_token, expires_in } = await ( + await fetch('https://api.pinterest.com/v5/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + `${process.env.PINTEREST_CLIENT_ID}:${process.env.PINTEREST_CLIENT_SECRET}` + ).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + scope: this.scopes.join(','), + redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/pinterest`, + }), + }) + ).json(); + + const { id, profile_image, username } = await ( + await fetch('https://api.pinterest.com/v5/user_account', { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + id: id, + name: username, + accessToken: access_token, + refreshToken: refreshToken, + expiresIn: expires_in, + picture: profile_image || '', + username, + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: `https://www.pinterest.com/oauth/?client_id=${ + process.env.PINTEREST_CLIENT_ID + }&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/pinterest` + )}&response_type=code&scope=${encodeURIComponent( + 'boards:read,boards:write,pins:read,pins:write,user_accounts:read' + )}&state=${state}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh: string; + }) { + const { access_token, refresh_token, expires_in, scope } = await ( + await fetch('https://api.pinterest.com/v5/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + `${process.env.PINTEREST_CLIENT_ID}:${process.env.PINTEREST_CLIENT_SECRET}` + ).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: params.code, + redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/pinterest`, + }), + }) + ).json(); + + this.checkScopes(this.scopes, scope); + + const { id, profile_image, username } = await ( + await fetch('https://api.pinterest.com/v5/user_account', { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + id: id, + name: username, + accessToken: access_token, + refreshToken: refresh_token, + expiresIn: expires_in, + picture: profile_image, + username, + }; + } + + @Tool({ description: 'List of boards', dataSchema: [] }) + async boards(accessToken: string) { + const { items } = await ( + await fetch('https://api.pinterest.com/v5/boards?page_size=250', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return ( + items?.map((item: any) => ({ + name: item.name, + id: item.id, + })) || [] + ); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + let mediaId = ''; + const findMp4 = postDetails?.[0]?.media?.find((p) => + hasExtension(p.path, 'mp4') + ); + const picture = postDetails?.[0]?.media?.find( + (p) => !hasExtension(p.path, 'mp4') + ); + + if (findMp4) { + const { upload_url, media_id, upload_parameters } = await ( + await this.fetch('https://api.pinterest.com/v5/media', { + method: 'POST', + body: JSON.stringify({ + media_type: 'video', + }), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + const { data, status } = await axios.get( + postDetails?.[0]?.media?.[0]?.path!, + { + responseType: 'stream', + } + ); + + const formData = Object.keys(upload_parameters) + .filter((f) => f) + .reduce((acc, key) => { + acc.append(key, upload_parameters[key]); + return acc; + }, new FormData()); + + formData.append('file', data); + await axios.post(upload_url, formData); + + let statusCode = ''; + let attempts = 0; + const maxAttempts = 18; // ~9 minutes at 30s interval + while (statusCode !== 'succeeded') { + if (attempts++ >= maxAttempts) { + throw new BadBody( + 'pinterest', + JSON.stringify({}), + {} as any, + 'The file took too long to process, please try again' + ); + } + + const mediafile = await ( + await this.fetch( + 'https://api.pinterest.com/v5/media/' + media_id, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + '', + 0, + true + ) + ).json(); + + if (mediafile.status === 'failed') { + throw new BadBody( + 'pinterest', + JSON.stringify({}), + {} as any, + 'The file is corrupted and cannot be uploaded' + ); + } + + await timer(30000); + statusCode = mediafile.status; + } + + mediaId = media_id; + } + + const mapImages = postDetails?.[0]?.media?.map((m) => ({ + path: m.path, + })); + + const { id: pId } = await ( + await this.fetch('https://api.pinterest.com/v5/pins', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ...(postDetails?.[0]?.settings.link + ? { link: postDetails?.[0]?.settings.link } + : {}), + ...(postDetails?.[0]?.settings.title + ? { title: postDetails?.[0]?.settings.title } + : {}), + description: postDetails?.[0]?.message, + ...(postDetails?.[0]?.settings.dominant_color + ? { dominant_color: postDetails?.[0]?.settings.dominant_color } + : {}), + board_id: postDetails?.[0]?.settings.board, + media_source: mediaId + ? { + source_type: 'video_id', + media_id: mediaId, + cover_image_url: picture?.path, + } + : mapImages?.length === 1 + ? { + source_type: 'image_url', + url: mapImages?.[0]?.path, + } + : { + source_type: 'multiple_image_urls', + items: mapImages.map((m) => ({ + url: m.path, + })), + }, + }), + }) + ).json(); + + return [ + { + id: postDetails?.[0]?.id, + postId: pId, + releaseURL: `https://www.pinterest.com/pin/${pId}`, + status: 'success', + }, + ]; + } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const until = dayjs().format('YYYY-MM-DD'); + // Pinterest analytics only cover the last 90 days (89 for a UTC safety margin) + const since = dayjs() + .subtract(Math.min(date, 89), 'day') + .format('YYYY-MM-DD'); + + const { + all: { daily_metrics }, + } = await ( + await fetch( + `https://api.pinterest.com/v5/user_account/analytics?start_date=${since}&end_date=${until}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ) + ).json(); + + return daily_metrics.reduce( + (acc: any, item: any) => { + if (typeof item.metrics.PIN_CLICK_RATE !== 'undefined') { + acc[0].data.push({ + date: item.date, + total: item.metrics.PIN_CLICK_RATE, + }); + + acc[1].data.push({ + date: item.date, + total: item.metrics.IMPRESSION, + }); + + acc[2].data.push({ + date: item.date, + total: item.metrics.PIN_CLICK, + }); + + acc[3].data.push({ + date: item.date, + total: item.metrics.ENGAGEMENT, + }); + + acc[4].data.push({ + date: item.date, + total: item.metrics.SAVE, + }); + } + + return acc; + }, + [ + { label: 'Pin click rate', data: [] as any[] }, + { label: 'Impressions', data: [] as any[] }, + { label: 'Pin Clicks', data: [] as any[] }, + { label: 'Engagement', data: [] as any[] }, + { label: 'Saves', data: [] as any[] }, + ] + ); + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + // Pinterest only serves pin analytics for the last 90 days (89 for a UTC safety margin) + const since = dayjs().subtract(89, 'day').format('YYYY-MM-DD'); + + try { + // Fetch pin analytics from Pinterest API + const response = await fetch( + `https://api.pinterest.com/v5/pins/${postId}/analytics?start_date=${since}&end_date=${today}&metric_types=IMPRESSION,PIN_CLICK,OUTBOUND_CLICK,SAVE`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + } + ); + + const data = await response.json(); + + if (!data || !data.all) { + return []; + } + + const result: AnalyticsData[] = []; + const metrics = data.all; + + if (metrics.lifetime_metrics) { + const lifetimeMetrics = metrics.lifetime_metrics; + + if (lifetimeMetrics.IMPRESSION !== undefined) { + result.push({ + label: 'Impressions', + percentageChange: 0, + data: [{ total: String(lifetimeMetrics.IMPRESSION), date: today }], + }); + } + + if (lifetimeMetrics.PIN_CLICK !== undefined) { + result.push({ + label: 'Pin Clicks', + percentageChange: 0, + data: [{ total: String(lifetimeMetrics.PIN_CLICK), date: today }], + }); + } + + if (lifetimeMetrics.OUTBOUND_CLICK !== undefined) { + result.push({ + label: 'Outbound Clicks', + percentageChange: 0, + data: [ + { total: String(lifetimeMetrics.OUTBOUND_CLICK), date: today }, + ], + }); + } + + if (lifetimeMetrics.SAVE !== undefined) { + result.push({ + label: 'Saves', + percentageChange: 0, + data: [{ total: String(lifetimeMetrics.SAVE), date: today }], + }); + } + } + + return result; + } catch (err) { + console.error('Error fetching Pinterest post analytics:', err); + return []; + } + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f98245d44b5fca3d653f2816d623e3d646d6a84 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/reddit.provider.ts @@ -0,0 +1,514 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { RedditSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/reddit.dto'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { groupBy } from 'lodash'; +import { + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +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'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; + +// @ts-ignore +global.WebSocket = WebSocket; + +export class RedditProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 1; // Reddit has strict rate limits (1 request per second) + identifier = 'reddit'; + name = 'Reddit'; + isBetweenSteps = false; + scopes = ['read', 'identity', 'submit', 'flair']; + editor = 'normal' as const; + dto = RedditSettingsDto; + + maxLength() { + return 10000; + } + + override async checkValidity( + posts: Array, + settings: any + ): Promise { + if ( + settings?.subreddit?.some( + (p: any) => p?.value?.type === 'media' && posts?.[0]?.length !== 1 + ) + ) { + return 'When posting a media post, you must attached exactly one media file.'; + } + + if ( + posts?.some((p) => + p?.some((a) => !a?.thumbnail && (a?.path?.indexOf?.('mp4') ?? -1) > -1) + ) + ) { + return 'You must attach a thumbnail to your video post.'; + } + + return true; + } + + async refreshToken(refreshToken: string): Promise { + const { access_token: accessToken, expires_in: expiresIn } = await ( + await this.fetch('https://www.reddit.com/api/v1/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + `${process.env.REDDIT_CLIENT_ID}:${process.env.REDDIT_CLIENT_SECRET}` + ).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }), + }) + ).json(); + + const { name, id, icon_img } = await ( + await this.fetch('https://oauth.reddit.com/api/v1/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return { + id, + name, + accessToken, + refreshToken: refreshToken, + expiresIn, + picture: icon_img?.split?.('?')?.[0] || '', + username: name, + }; + } + + async generateAuthUrl() { + const state = makeId(6); + const codeVerifier = makeId(30); + const url = `https://www.reddit.com/api/v1/authorize?client_id=${ + process.env.REDDIT_CLIENT_ID + }&response_type=code&state=${state}&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/reddit` + )}&duration=permanent&scope=${encodeURIComponent(this.scopes.join(' '))}`; + return { + url, + codeVerifier, + state, + }; + } + + async authenticate(params: { code: string; codeVerifier: string }) { + const { + access_token: accessToken, + refresh_token: refreshToken, + expires_in: expiresIn, + scope, + } = await ( + await this.fetch('https://www.reddit.com/api/v1/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from( + `${process.env.REDDIT_CLIENT_ID}:${process.env.REDDIT_CLIENT_SECRET}` + ).toString('base64')}`, + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + code: params.code, + redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/reddit`, + }), + }) + ).json(); + + this.checkScopes(this.scopes, scope); + + const { name, id, icon_img } = await ( + await this.fetch('https://oauth.reddit.com/api/v1/me', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + ).json(); + + return { + id, + name, + accessToken, + refreshToken, + expiresIn, + picture: icon_img?.split?.('?')?.[0] || '', + username: name, + }; + } + + private async uploadFileToReddit(accessToken: string, path: string) { + const mimeType = lookup(path); + const formData = new FormData(); + formData.append('filepath', path.split('/').pop()); + formData.append('mimetype', mimeType || 'application/octet-stream'); + + const { + args: { action, fields }, + } = await ( + await this.fetch( + 'https://oauth.reddit.com/api/media/asset', + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + body: formData, + }, + 'reddit', + 0, + true + ) + ).json(); + + const { data } = await axios.get(path, { + responseType: 'arraybuffer', + }); + + const upload = (fields as { name: string; value: string }[]).reduce( + (acc, value) => { + acc.append(value.name, value.value); + return acc; + }, + new FormData() + ); + + upload.append( + 'file', + new Blob([Buffer.from(data)], { type: mimeType as string }) + ); + + const d = await fetch('https:' + action, { + method: 'POST', + body: upload, + }); + + return [...(await d.text()).matchAll(/(.*?)<\/Location>/g)][0][1]; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const [post] = postDetails; + + const valueArray: PostResponse[] = []; + for (const firstPostSettings of post.settings.subreddit) { + const kind = + firstPostSettings.value.type === 'media' + ? hasExtension(post.media[0].path, 'mp4') + ? 'video' + : 'image' + : firstPostSettings.value.type; + const postData = { + api_type: 'json', + title: firstPostSettings.value.title || '', + kind: + ['link', 'self', 'image', 'video', 'videogif'].indexOf(kind) > -1 + ? kind + : 'self', + ...(firstPostSettings.value.flair + ? { flair_id: firstPostSettings.value.flair.id } + : {}), + ...(firstPostSettings.value.type === 'link' + ? { + url: firstPostSettings.value.url, + } + : {}), + ...(firstPostSettings.value.type === 'media' + ? { + url: await this.uploadFileToReddit( + accessToken, + post.media[0].path + ), + ...(hasExtension(post.media[0].path, 'mp4') + ? { + video_poster_url: await this.uploadFileToReddit( + accessToken, + post.media[0].thumbnail + ), + } + : {}), + } + : {}), + text: post.message, + sr: firstPostSettings.value.subreddit.replace('/r/', '').toLowerCase(), + }; + + const all = await ( + await this.fetch('https://oauth.reddit.com/api/submit', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(postData), + }) + ).json(); + + const { + id: redditId, + name, + url, + } = await new Promise<{ + id: string; + name: string; + url: string; + }>((res) => { + if (all?.json?.data?.id) { + res(all.json.data); + } + + const ws = new WebSocket(all.json.data.websocket_url); + ws.on('message', (data: any) => { + setTimeout(() => { + res({ id: '', name: '', url: '' }); + ws.close(); + }, 30_000); + try { + const parsedData = JSON.parse(data.toString()); + if (parsedData?.payload?.redirect) { + const onlyId = parsedData?.payload?.redirect.replace( + /https:\/\/www\.reddit\.com\/r\/.*?\/comments\/(.*?)\/.*/g, + '$1' + ); + res({ + id: onlyId, + name: `t3_${onlyId}`, + url: parsedData?.payload?.redirect, + }); + } + } catch (err) {} + }); + }); + + valueArray.push({ + postId: redditId, + releaseURL: url, + id: post.id, + status: 'published', + }); + + if (post.settings.subreddit.length > 1) { + await timer(5000); + } + } + + return Object.values(groupBy(valueArray, (p) => p.id)).map((p) => ({ + id: p[0].id, + postId: p.map((p) => p.postId).join(','), + releaseURL: p.map((p) => p.releaseURL).join(','), + status: 'published', + })); + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + 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: [ + { + key: 'word', + type: 'string', + description: 'Search subreddit by string', + }, + ], + }) + async subreddits(accessToken: string, data: any) { + const { + data: { children }, + } = await ( + await this.fetch( + `https://oauth.reddit.com/subreddits/search?show=public&q=${data.word}&sort=activity&show_users=false&limit=10`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + 'reddit', + 0, + false + ) + ).json(); + + return children + .filter( + ({ data }: { data: any }) => + data.subreddit_type === 'public' && data.submission_type !== 'image' + ) + .map(({ data: { title, url, id } }: any) => ({ + title, + name: url, + id, + })); + } + + private getPermissions(submissionType: string, allow_images: string) { + const permissions = []; + if (['any', 'self'].indexOf(submissionType) > -1) { + permissions.push('self'); + } + + if (['any', 'link'].indexOf(submissionType) > -1) { + permissions.push('link'); + } + + if (allow_images) { + permissions.push('media'); + } + + return permissions; + } + + @Tool({ + description: 'Get list of flairs and restrictions for a subreddit', + dataSchema: [ + { + key: 'subreddit', + type: 'string', + description: + 'Search flairs and restrictions by subreddit key should be "/r/[name]"', + }, + ], + }) + async restrictions(accessToken: string, data: { subreddit: string }) { + const { + data: { submission_type, allow_images, ...all2 }, + } = await ( + await this.fetch( + `https://oauth.reddit.com/${data.subreddit}/about`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + 'reddit', + 0, + false + ) + ).json(); + + const { is_flair_required, ...all } = await ( + await this.fetch( + `https://oauth.reddit.com/api/v1/${ + data.subreddit.split('/r/')[1] + }/post_requirements`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + 'reddit', + 0, + false + ) + ).json(); + + // eslint-disable-next-line no-async-promise-executor + const newData = await new Promise<{ id: string; name: string }[]>( + async (res) => { + try { + const flair = await ( + await this.fetch( + `https://oauth.reddit.com/${data.subreddit}/api/link_flair_v2`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + 'reddit', + 0, + false + ) + ).json(); + + res(flair); + } catch (err) { + return res([]); + } + } + ); + + return { + subreddit: data.subreddit, + allow: this.getPermissions(submission_type, allow_images), + is_flair_required: is_flair_required && newData.length > 0, + flairs: + newData?.map?.((p: any) => ({ + id: p.id, + name: p.text, + })) || [], + }; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts b/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..00e7acad1223cac506f473c1f6b300855b5f08a2 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/skool.provider.ts @@ -0,0 +1,345 @@ +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { SocialAbstract } from '../social.abstract'; +import { + AuthTokenDetails, + MediaContent, + PostDetails, + PostResponse, + SocialProvider, +} from './social.integrations.interface'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; +import { SkoolDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/skool.dto'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; + +export class SkoolProvider extends SocialAbstract implements SocialProvider { + identifier = 'skool'; + name = 'Skool'; + isBetweenSteps = false; + isChromeExtension = true; + scopes = [] as string[]; + editor = 'normal' as const; + dto = SkoolDto; + + extensionCookies = [ + { name: 'client_id', domain: '.skool.com' }, + { name: 'auth_token', domain: '.skool.com' }, + ]; + + private getCookies(integration: Integration): { + client_id: string; + auth_token: string; + } { + const stored = integration.customInstanceDetails!; + try { + // Current format: credentials stored as at-rest AES-encrypted JSON. + return JSON.parse(AuthService.fixedDecryption(stored)) as { + client_id: string; + auth_token: string; + }; + } catch { + // Legacy format: Skool accounts connected before the storage format was + // changed kept their credentials as a signed JWT. Read those as-is so + // already-connected accounts keep working without a reconnect. + return AuthService.verifyJWT(stored) as { + client_id: string; + auth_token: string; + }; + } + } + + override handleErrors( + body: string + ): + | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | undefined { + if (body.includes('must be admin or level')) { + return { type: 'bad-body', value: 'You can\'t post to this channel' }; + } + if (body.includes('cannot post to this label')) { + return { type: 'bad-body', value: 'Cannot post to this label' }; + } + return undefined; + } + + maxLength() { + return 5000; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + try { + const cookies: Record = JSON.parse( + Buffer.from(params.code, 'base64').toString() + ); + + const missing = this.extensionCookies + .map((c) => c.name) + .filter((name) => !cookies[name]); + + if (missing.length > 0) { + return `Missing required cookies: ${missing.join(', ')}`; + } + + const data = await ( + await fetch('https://api2.skool.com/self', { + method: 'GET', + headers: { + Cookie: `auth_token=${cookies.auth_token}; client_id=${cookies.client_id}`, + }, + }) + ).json(); + + return { + refreshToken: '', + expiresIn: dayjs().add(100, 'year').unix() - dayjs().unix(), + accessToken: AuthService.fixedEncryption(JSON.stringify(cookies)), + id: data.id, + name: data.first_name + ' ' + data.last_name, + picture: data.metadata.picture_profile || '', + username: data.name, + }; + } catch (e) { + return 'Invalid cookie data'; + } + } + + @Tool({ description: 'Groups', dataSchema: [] }) + async groups(accessToken: string, params: any, id: string, integration: Integration) { + try { + const { client_id, auth_token } = this.getCookies(integration); + const { groups } = await ( + await fetch( + `https://api2.skool.com/users/${id}/groups?offset=0&limit=30`, + { + headers: { + Cookie: `auth_token=${auth_token}; client_id=${client_id}`, + }, + } + ) + ).json(); + + return groups.map((p: any) => ({ + id: String(p.id), + name: p.metadata.display_name, + })); + } catch (err) { + return []; + } + } + + @Tool({ description: 'Label', dataSchema: [] }) + async label(accessToken: string, params: any, id: string, integration: Integration) { + try { + const { client_id, auth_token } = this.getCookies(integration); + const { metadata } = await ( + await this.fetch(`https://api2.skool.com/groups/${params.id}`, { + headers: { + Cookie: `auth_token=${auth_token}; client_id=${client_id}`, + }, + }) + ).json(); + + if (!metadata.labels || metadata.labels.length === 0) { + return [{ id: 'none', name: 'Default Label' }]; + } + + const labels = metadata.labels.split(','); + + if (labels.length === 0) { + return [{ id: 'none', name: 'Default Label' }]; + } + + const labelInformation = await Promise.all( + labels.map(async (labelId: string) => { + return ( + await this.fetch(`https://api2.skool.com/labels/${labelId}`, { + headers: { + Cookie: `auth_token=${auth_token}; client_id=${client_id}`, + }, + }) + ).json(); + }) + ); + + return labelInformation.map((p: any) => ({ + id: String(p.id), + name: p.metadata.display_name, + })); + } catch (err) { + return []; + } + } + + private async uploadMediaToSkool( + media: MediaContent[], + userId: string, + cookies: { client_id: string; auth_token: string } + ): Promise { + if (!media || media.length === 0) return ''; + + const fileIds: string[] = []; + + for (const item of media) { + const fileResponse = await fetch(item.path); + const fileBuffer = await fileResponse.arrayBuffer(); + const contentType = + fileResponse.headers.get('content-type') || 'application/octet-stream'; + const fileName = item.path.split('/').pop() || 'file'; + + const createFileResponse = await ( + await this.fetch('https://api2.skool.com/files', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Cookie: `auth_token=${cookies.auth_token}; client_id=${cookies.client_id}`, + }, + body: JSON.stringify({ + file_name: fileName, + content_type: contentType, + content_length: fileBuffer.byteLength, + content_disposition: '', + ref: '', + owner_id: userId, + large_thumbnail: false, + }), + }, 'create file record') + ).json(); + + await fetch(createFileResponse.write_url, { + method: 'PUT', + headers: { + 'Content-Type': createFileResponse.content_type, + 'x-amz-acl': createFileResponse.acl, + }, + body: fileBuffer, + }); + + fileIds.push(createFileResponse.file.id); + } + + return fileIds.join(','); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const { client_id, auth_token } = this.getCookies(integration); + const [post] = postDetails; + + const attachments = await this.uploadMediaToSkool( + post.media || [], + id, + { client_id, auth_token } + ); + + const { id: postId, name } = await ( + await this.fetch('https://api2.skool.com/posts?follow=true', { + method: 'POST', + headers: { + Cookie: `auth_token=${auth_token}; client_id=${client_id}`, + }, + body: JSON.stringify({ + post_type: 'generic', + group_id: post.settings.group, + metadata: { + title: post.settings.title, + content: post.message, + attachments, + ...(post.settings.label && post.settings.label !== 'none' + ? { labels: post.settings.label } + : {}), + action: 0, + video_ids: '', + }, + }), + }) + ).json(); + + return [ + { + id: String(postId), + postId, + releaseURL: `https://www.skool.com/${post.settings.group}/${name}`, + status: 'success', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const { client_id, auth_token } = this.getCookies(integration); + const [post] = postDetails; + + const attachments = await this.uploadMediaToSkool( + post.media || [], + id, + { client_id, auth_token } + ); + + const { id: postIdFinal, name } = await ( + await this.fetch('https://api2.skool.com/posts?follow=true', { + method: 'POST', + headers: { + Cookie: `auth_token=${auth_token}; client_id=${client_id}`, + }, + body: JSON.stringify({ + post_type: 'comment', + group_id: post.settings.group, + root_id: postId, + parent_id: lastCommentId || postId, + metadata: { + title: '', + content: post.message, + attachments, + action: 0, + video_ids: '', + }, + }), + }) + ).json(); + + return [ + { + id: String(id), + postId: postIdFinal, + releaseURL: `https://www.skool.com/${post.settings.group}/${name}`, + status: 'success', + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcbe01dbe8dfdb24ace4ee25f7115caeda51134a --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/slack.provider.ts @@ -0,0 +1,289 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +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'; +import { SlackDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/slack.dto'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +export class SlackProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; // Slack has moderate API limits + identifier = 'slack'; + name = 'Slack'; + isBetweenSteps = false; + editor = 'normal' as const; + scopes = [ + 'channels:read', + 'chat:write', + 'users:read', + 'groups:read', + 'channels:join', + 'chat:write.customize', + ]; + dto = SlackDto; + + maxLength() { + return 400000; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 1000000, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + async generateAuthUrl() { + const state = makeId(6); + + return { + url: `https://slack.com/oauth/v2/authorize?client_id=${ + process.env.SLACK_ID + }&redirect_uri=${encodeURIComponent( + `${ + process?.env?.FRONTEND_URL?.indexOf('https') === -1 + ? 'https://redirectmeto.com/' + : '' + }${process?.env?.FRONTEND_URL}/integrations/social/slack` + )}&scope=channels:read,chat:write,users:read,groups:read,channels:join,chat:write.customize&state=${state}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const { access_token, team, bot_user_id, scope } = await ( + await this.fetch(`https://slack.com/api/oauth.v2.access`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + client_id: process.env.SLACK_ID!, + client_secret: process.env.SLACK_SECRET!, + code: params.code, + redirect_uri: `${ + process?.env?.FRONTEND_URL?.indexOf('https') === -1 + ? 'https://redirectmeto.com/' + : '' + }${process?.env?.FRONTEND_URL}/integrations/social/slack${ + params.refresh ? `?refresh=${params.refresh}` : '' + }`, + }), + }) + ).json(); + + this.checkScopes(this.scopes, scope.split(',')); + + const { user } = await ( + await fetch(`https://slack.com/api/users.info?user=${bot_user_id}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + }, + }) + ).json(); + + return { + id: team.id, + name: user.real_name, + accessToken: access_token, + refreshToken: 'null', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + picture: user?.profile?.image_original || '', + username: user.name, + }; + } + + @Tool({ + description: 'Get list of channels', + dataSchema: [], + }) + async channels(accessToken: string, params: any, id: string) { + const list = await ( + await fetch( + `https://slack.com/api/conversations.list?types=public_channel,private_channel`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).json(); + + return list.channels.map((p: any) => ({ + id: p.id, + name: p.name, + })); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [firstPost] = postDetails; + const channel = firstPost.settings.channel; + + // Join the channel first + await fetch(`https://slack.com/api/conversations.join`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel, + }), + }); + + // Post the main message + const { ts, channel: responseChannel } = await ( + await fetch(`https://slack.com/api/chat.postMessage`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel, + username: integration.name, + icon_url: integration.picture, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: firstPost.message, + }, + }, + ...(firstPost.media?.length + ? firstPost.media.map((m) => ({ + type: 'image', + image_url: m.path, + alt_text: '', + })) + : []), + ], + }), + }) + ).json(); + + // Get permalink for the message + const { permalink } = await ( + await fetch( + `https://slack.com/api/chat.getPermalink?channel=${responseChannel}&message_ts=${ts}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).json(); + + return [ + { + id: firstPost.id, + postId: ts, + releaseURL: permalink || '', + status: 'posted', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [commentPost] = postDetails; + const channel = commentPost.settings.channel; + const threadTs = lastCommentId || postId; + + // Post the threaded reply + const { ts, channel: responseChannel } = await ( + await fetch(`https://slack.com/api/chat.postMessage`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + channel, + username: integration.name, + icon_url: integration.picture, + thread_ts: threadTs, + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: commentPost.message, + }, + }, + ...(commentPost.media?.length + ? commentPost.media.map((m) => ({ + type: 'image', + image_url: m.path, + alt_text: '', + })) + : []), + ], + }), + }) + ).json(); + + // Get permalink for the comment + const { permalink } = await ( + await fetch( + `https://slack.com/api/chat.getPermalink?channel=${responseChannel}&message_ts=${ts}`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).json(); + + return [ + { + id: commentPost.id, + postId: ts, + releaseURL: permalink || '', + status: 'posted', + }, + ]; + } + + async changeProfilePicture(id: string, accessToken: string, url: string) { + return { + url, + }; + } + + async changeNickname(id: string, accessToken: string, name: string) { + return { + name, + }; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ad3cd7fa3184594af7066875f3021e56b196793 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/social.integrations.interface.ts @@ -0,0 +1,189 @@ +import { Integration } from '@prisma/client'; + +export interface ClientInformation { + client_id: string; + client_secret: string; + instanceUrl: string; +} +export interface IAuthenticator { + authenticate( + params: { + code: string; + codeVerifier: string; + refresh?: string; + }, + clientInformation?: ClientInformation + ): Promise; + refreshToken(refreshToken: string): Promise; + reConnect?( + id: string, + requiredId: string, + accessToken: string + ): Promise>; + generateAuthUrl( + clientInformation?: ClientInformation + ): Promise; + analytics?( + id: string, + accessToken: string, + date: number + ): Promise; + postAnalytics?( + integrationId: string, + accessToken: string, + postId: string, + fromDate: number, + ): Promise; + changeNickname?( + id: string, + accessToken: string, + name: string + ): Promise<{ name: string }>; + changeProfilePicture?( + id: string, + accessToken: string, + url: string + ): Promise<{ url: string }>; + missing?( + id: string, + accessToken: string + ): Promise<{ id: string; url: string }[]>; +} + +export interface AnalyticsData { + label: string; + data: Array<{ total: string; date: string }>; + percentageChange: number; +} + + +export type GenerateAuthUrlResponse = { + url: string; + codeVerifier: string; + state: string; +}; + +export type AuthTokenDetails = { + id: string; + name: string; + error?: string; + accessToken: string; // The obtained access token + refreshToken?: string; // The refresh token, if applicable + expiresIn?: number; // The duration in seconds for which the access token is valid + picture?: string; + username: string; + additionalSettings?: { + title: string; + description: string; + type: 'checkbox' | 'text' | 'textarea'; + value: any; + regex?: string; + }[]; +}; + +export interface ISocialMediaIntegration { + post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise; // Schedules a new post + + comment?( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise; // Schedules a new post +} + +export type PostResponse = { + id: string; // The db internal id of the post + postId: string; // The ID of the scheduled post returned by the platform + releaseURL: string; // The URL of the post on the platform + status: string; // Status of the operation or initial post status +}; + +export type PostDetails = { + id: string; + message: string; + settings: T; + media?: MediaContent[]; + poll?: PollDetails; +}; + +export type PollDetails = { + options: string[]; // Array of poll options + duration: number; // Duration in hours for which the poll will be active +}; + +export type MediaContent = { + type: 'image' | 'video'; // Type of the media content + path: string; + alt?: string; + thumbnail?: string; + thumbnailTimestamp?: number; +}; + +export type FetchPageInformationResult = { + id: string; + name: string; + access_token: string; + picture: string; + username: string; +}; + +export interface SocialProvider + extends IAuthenticator, + ISocialMediaIntegration { + identifier: string; + refreshWait?: boolean; + convertToJPEG?: boolean; + stripLinks?: () => boolean; + refreshCron?: boolean; + dto?: any; + maxLength: (additionalSettings?: any) => number; + checkValidity( + posts: Array<{ path: string; thumbnail?: string }[]>, + settings: any, + additionalSettings: any[] + ): Promise; + isWeb3?: boolean; + isChromeExtension?: boolean; + extensionCookies?: { name: string; domain: string }[]; + editor: 'none' | 'normal' | 'markdown' | 'html'; + customFields?: () => Promise< + { + key: string; + label: string; + defaultValue?: string; + validation: string; + type: 'text' | 'password'; + hint?: string; + }[] + >; + name: string; + toolTip?: string; + oneTimeToken?: boolean; + isBetweenSteps: boolean; + scopes: string[]; + externalUrl?: ( + url: string + ) => Promise<{ client_id: string; client_secret: string }>; + mention?: ( + token: string, + data: { query: string }, + id: string, + integration: Integration + ) => Promise< + | { id: string; label: string; image: string; doNotCache?: boolean }[] + | { none: true } + >; + mentionFormat?(idOrHandle: string, name: string): string; + fetchPageInformation?( + accessToken: string, + data: any + ): Promise; +} diff --git a/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..bcdc7a096c7e17591b22eb4bb16bdb33bc8ad394 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/telegram.provider.ts @@ -0,0 +1,336 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import dayjs from 'dayjs'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +//@ts-ignore +import mime from 'mime'; +import TelegramBot from 'node-telegram-bot-api'; +import { Integration } from '@prisma/client'; +import striptags from 'striptags'; + +const telegramBot = new TelegramBot(process.env.TELEGRAM_TOKEN!); +// Added to support local storage posting +const frontendURL = process.env.FRONTEND_URL || 'http://localhost:5000'; +const mediaStorage = process.env.STORAGE_PROVIDER || 'local'; + +export class TelegramProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; // Telegram has moderate bot API limits + identifier = 'telegram'; + name = 'Telegram'; + isBetweenSteps = false; + isWeb3 = true; + scopes = [] as string[]; + editor = 'html' as const; + maxLength() { + return 4096; + } + + async refreshToken(refresh_token: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(17); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const chat = await telegramBot.getChat(params.code); + + console.log(JSON.stringify(chat)); + if (!chat?.id) { + return 'No chat found'; + } + + const photo = !chat?.photo?.big_file_id + ? '' + : await telegramBot.getFileLink(chat.photo.big_file_id); + + // Modified id to work with chat.username (public groups/channels) or chat.id (private groups/channels) when chat.username is not available + return { + id: String(chat.username ? chat.username : chat.id), + name: chat.title!, + accessToken: String(chat.id), + refreshToken: '', + expiresIn: dayjs().add(200, 'year').unix() - dayjs().unix(), + picture: photo || '', + username: chat.username!, + }; + } + + async getBotId(query: { id?: number; word: string }) { + // Added allowed_updates Ensure only necessary updates are fetched + const res = await telegramBot.getUpdates({ + ...(query.id ? { offset: query.id } : {}), + allowed_updates: ['message', 'channel_post'], + }); + //message.text is for groups, channel_post.text is for channels + const match = res.find( + (p) => + (p?.message?.text === `/connect ${query.word}` && + p?.message?.chat?.id) || + (p?.channel_post?.text === `/connect ${query.word}` && + p?.channel_post?.chat?.id) + ); + // get correct chatId based on the channel type + const chatId = match?.message?.chat?.id || match?.channel_post?.chat?.id; + + // prevents the code from running while chatId is still undefined to avoid the error 'ETELEGRAM: 400 Bad Request: chat_id is empty'. the code would still work eventually but console spam is not pretty + if (chatId) { + //get the numberic ID of the bot + const botId = (await telegramBot.getMe()).id; + // check if the bot is an admin in the chat + const isAdmin = await this.botIsAdmin(chatId, botId); + // get the messageId of the message that triggered the connection + const connectMessageId = + match?.message?.message_id || match?.channel_post?.message_id; + + if (!isAdmin) { + // alternatively you can replace this with a console.log if you do not want to inform the user of the bot's admin status + telegramBot.sendMessage( + chatId, + "Connection Successful. I don't have admin privileges to delete these messages, please go ahead and remove them yourself." + ); + } else { + // Delete the message that triggered the connection + await telegramBot.deleteMessage(chatId, connectMessageId); + // Send success message to the chat + const successMessage = await telegramBot.sendMessage( + chatId, + 'Connection Successful. Message will be deleted in 10 seconds.' + ); + // Delete the success message after 10 seconds + setTimeout(async () => { + await telegramBot.deleteMessage(chatId, successMessage.message_id); + console.log('Success message deleted.'); + }, 10000); + } + } + + // modified lastChatId to work with any type of channel (private/public groups/channels) + return chatId + ? { chatId } + : res.length > 0 + ? { + lastChatId: res[res.length - 1].update_id + 1, + } + : {}; + } + + private processMedia(mediaFiles: PostDetails['media']) { + return (mediaFiles || []).map((media) => { + let mediaUrl = media.path; + if (mediaStorage === 'local' && mediaUrl.startsWith(frontendURL)) { + mediaUrl = mediaUrl.replace(frontendURL, ''); + } + //get mime type to pass contentType to telegram api. + //some photos and videos might not pass telegram api restrictions, so they are sent as documents instead of returning errors + const mimeType = mime.getType(mediaUrl); // Detect MIME type + let mediaType: 'photo' | 'video' | 'document'; + + if (mimeType?.startsWith('image/')) { + mediaType = 'photo'; + } else if (mimeType?.startsWith('video/')) { + mediaType = 'video'; + } else { + mediaType = 'document'; + } + + return { + type: mediaType, + media: mediaUrl, + fileOptions: { + filename: media.path.split('/').pop(), + contentType: mimeType || 'application/octet-stream', + }, + }; + }); + } + + private async sendMessage( + accessToken: string, + message: PostDetails, + replyToMessageId?: number + ): Promise { + let messageId: number | null = null; + const mediaFiles = message.media || []; + const text = striptags(message.message || '', ['u', 'strong', 'p']) + .replace(//g, '') + .replace(/<\/strong>/g, '') + .replace(/

(.*?)<\/p>/g, '$1\n'); + + console.log(text); + const processedMedia = this.processMedia(mediaFiles); + + // if there's no media, bot sends a text message only + if (processedMedia.length === 0) { + const response = await telegramBot.sendMessage(accessToken, text, { + parse_mode: 'HTML', + ...(replyToMessageId ? { reply_to_message_id: replyToMessageId } : {}), + }); + messageId = response.message_id; + } + // if there's only one media, bot sends the media with the text message as caption + else if (processedMedia.length === 1) { + const media = processedMedia[0]; + const options = { + caption: text, + parse_mode: 'HTML' as const, + ...(replyToMessageId ? { reply_to_message_id: replyToMessageId } : {}), + }; + const response = + media.type === 'video' + ? await telegramBot.sendVideo( + accessToken, + media.media, + options, + media.fileOptions + ) + : media.type === 'photo' + ? await telegramBot.sendPhoto( + accessToken, + media.media, + options, + media.fileOptions + ) + : await telegramBot.sendDocument( + accessToken, + media.media, + options, + media.fileOptions + ); + messageId = response.message_id; + } + // if there are multiple media, bot sends them as a media group - max 10 media per group - with the text as a caption (if there are more than 1 group, the caption will only be sent with the first group) + else { + const mediaGroups = this.chunkMedia(processedMedia, 10); + for (let i = 0; i < mediaGroups.length; i++) { + const mediaGroup = mediaGroups[i].map((m, index) => ({ + type: m.type === 'document' ? 'document' : m.type, // Documents are not allowed in media groups + media: m.media, + caption: i === 0 && index === 0 ? text : undefined, + parse_mode: 'HTML', + })); + + const response = await telegramBot.sendMediaGroup( + accessToken, + mediaGroup as any[], + { + ...(replyToMessageId && i === 0 + ? { reply_to_message_id: replyToMessageId } + : {}), + } + ); + if (i === 0) { + messageId = response[0].message_id; + } + } + } + + return messageId; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const [firstPost] = postDetails; + + const messageId = await this.sendMessage(accessToken, firstPost); + + // for private groups/channels message.id is undefined so the link generated by Postiz will be unusable "https://t.me/c/undefined/16" + // to avoid that, we use accessToken instead of message.id and we generate the link manually removing the -100 from the start. + if (messageId) { + return [ + { + id: firstPost.id, + postId: String(messageId), + releaseURL: `https://t.me/${ + id !== 'undefined' ? id : `c/${accessToken.replace('-100', '')}` + }/${messageId}`, + status: 'completed', + }, + ]; + } + + return []; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [commentPost] = postDetails; + const replyToId = Number(lastCommentId || postId); + + const messageId = await this.sendMessage(accessToken, commentPost, replyToId); + + if (messageId) { + return [ + { + id: commentPost.id, + postId: String(messageId), + releaseURL: `https://t.me/${ + id !== 'undefined' ? id : `c/${accessToken.replace('-100', '')}` + }/${messageId}`, + status: 'completed', + }, + ]; + } + + return []; + } + // chunkMedia is used to split media into groups of "size". 10 is used here because telegram api allows a maximum of 10 media per group + private chunkMedia(media: { type: string; media: string }[], size: number) { + const result = []; + for (let i = 0; i < media.length; i += size) { + result.push(media.slice(i, i + size)); + } + return result; + } + + async botIsAdmin(chatId: number, botId: number): Promise { + try { + const chatMember = await telegramBot.getChatMember(chatId, botId); + + if ( + chatMember.status === 'administrator' || + chatMember.status === 'creator' + ) { + const permissions = chatMember.can_delete_messages; + return !!permissions; // Return true if bot can delete messages + } + + return false; + } catch (error) { + console.error('Error checking bot privileges:', error); + return false; + } + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..30092f83f603771d80de7858f31cce3713cb1b89 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/threads.provider.ts @@ -0,0 +1,644 @@ +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { timer } from '@gitroom/helpers/utils/timer'; +import dayjs from 'dayjs'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { capitalize, chunk } from 'lodash'; +import { Plug } from '@gitroom/helpers/decorators/plug.decorator'; +import { Integration } from '@prisma/client'; +import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; + +export class ThreadsProvider extends SocialAbstract implements SocialProvider { + identifier = 'threads'; + name = 'Threads'; + isBetweenSteps = false; + scopes = [ + 'threads_basic', + 'threads_content_publish', + 'threads_manage_replies', + 'threads_manage_insights', + // 'threads_profile_discovery', + ]; + override maxConcurrentJob = 2; // Threads has moderate rate limits + refreshCron = true; + + editor = 'normal' as const; + maxLength() { + return 500; + } + + override handleErrors(body: string): + | { + type: 'refresh-token' | 'bad-body'; + value: string; + } + | undefined { + console.log(body); + if (body.includes('Error validating access token')) { + return { type: 'refresh-token', value: 'Threads access token expired' }; + } + + if (body.includes('2207051')) { + return { + type: 'bad-body', + value: + 'Error from Meta: We restrict certain activity to protect our community', + }; + } + + if (body.includes('4279013')) { + return { + type: 'bad-body', + value: + 'User restricted', + }; + } + if (body.includes('The media could not be fetched from this URI')) { + return { + type: 'bad-body', + value: + "One of the media URLs is invalid or inaccessible, make sure it's being uploaded to Postiz first", + }; + } + if (body.includes('text must be at most 500 characters')) { + return { + type: 'bad-body', + value: 'Post text exceeds 500 characters limit', + }; + } + + return undefined; + } + + async refreshToken(refresh_token: string): Promise { + const { access_token } = await ( + await this.fetch( + `https://graph.threads.net/refresh_access_token?grant_type=th_refresh_token&access_token=${refresh_token}` + ) + ).json(); + + const { id, name, username, picture } = await this.fetchUserInfo( + access_token + ); + + return { + id, + name, + accessToken: access_token, + refreshToken: access_token, + expiresIn: dayjs().add(58, 'days').unix() - dayjs().unix(), + picture: picture || '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: + 'https://www.threads.net/oauth/authorize' + + `?client_id=${process.env.THREADS_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + `${ + process?.env.FRONTEND_URL?.indexOf('https') == -1 + ? `https://redirectmeto.com/${process?.env.FRONTEND_URL}` + : `${process?.env.FRONTEND_URL}` + }/integrations/social/threads` + )}` + + `&state=${state}` + + `&scope=${encodeURIComponent(this.scopes.join(','))}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const getAccessToken = await ( + await this.fetch( + 'https://graph.threads.net/oauth/access_token' + + `?client_id=${process.env.THREADS_APP_ID}` + + `&redirect_uri=${encodeURIComponent( + `${ + process?.env.FRONTEND_URL?.indexOf('https') == -1 + ? `https://redirectmeto.com/${process?.env.FRONTEND_URL}` + : `${process?.env.FRONTEND_URL}` + }/integrations/social/threads` + )}` + + `&grant_type=authorization_code` + + `&client_secret=${process.env.THREADS_APP_SECRET}` + + `&code=${params.code}` + ) + ).json(); + + const { access_token } = await ( + await this.fetch( + 'https://graph.threads.net/access_token' + + '?grant_type=th_exchange_token' + + `&client_secret=${process.env.THREADS_APP_SECRET}` + + `&access_token=${getAccessToken.access_token}` + ) + ).json(); + + const { id, name, username, picture } = await this.fetchUserInfo( + access_token + ); + + return { + id, + name, + accessToken: access_token, + refreshToken: access_token, + expiresIn: dayjs().add(58, 'days').unix() - dayjs().unix(), + picture: picture || '', + username: username, + }; + } + + private async checkLoaded( + mediaContainerId: string, + accessToken: string + ): Promise { + const { status, id, error_message } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${mediaContainerId}?fields=status,error_message&access_token=${accessToken}` + ) + ).json(); + + if (status === 'ERROR') { + throw new Error(id); + } + + if (status === 'FINISHED') { + await timer(2000); + return true; + } + + await timer(2200); + return this.checkLoaded(mediaContainerId, accessToken); + } + + private async fetchUserInfo(accessToken: string) { + const { id, username, threads_profile_picture_url } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/me?fields=id,username,threads_profile_picture_url&access_token=${accessToken}` + ) + ).json(); + + return { + id, + name: username, + picture: threads_profile_picture_url || '', + username, + }; + } + + private async createSingleMediaContent( + userId: string, + accessToken: string, + media: { path: string }, + message: string, + isCarouselItem = false, + replyToId?: string + ): Promise { + const mediaType = hasExtension(media.path, 'mp4') + ? 'video_url' + : 'image_url'; + const mediaParams = new URLSearchParams({ + ...(mediaType === 'video_url' ? { video_url: media.path } : {}), + ...(mediaType === 'image_url' ? { image_url: media.path } : {}), + ...(isCarouselItem ? { is_carousel_item: 'true' } : {}), + ...(replyToId ? { reply_to_id: replyToId } : {}), + media_type: mediaType === 'video_url' ? 'VIDEO' : 'IMAGE', + text: message, + access_token: accessToken, + }); + + const { id: mediaId } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${userId}/threads?${mediaParams.toString()}`, + { + method: 'POST', + } + ) + ).json(); + + return mediaId; + } + + private async createCarouselContent( + userId: string, + accessToken: string, + media: { path: string }[], + message: string, + replyToId?: string + ): Promise { + // Create each media item + const mediaIds = []; + for (const mediaItem of media) { + const mediaId = await this.createSingleMediaContent( + userId, + accessToken, + mediaItem, + message, + true + ); + mediaIds.push(mediaId); + } + + // Wait for all media to be loaded + await Promise.all( + mediaIds.map((id: string) => this.checkLoaded(id, accessToken)) + ); + + // Create carousel container + const params = new URLSearchParams({ + text: message, + media_type: 'CAROUSEL', + children: mediaIds.join(','), + ...(replyToId ? { reply_to_id: replyToId } : {}), + access_token: accessToken, + }); + + const { id: containerId } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${userId}/threads?${params.toString()}`, + { + method: 'POST', + } + ) + ).json(); + + return containerId; + } + + private async createTextContent( + userId: string, + accessToken: string, + message: string, + replyToId?: string, + quoteId?: string + ): Promise { + const form = new FormData(); + form.append('media_type', 'TEXT'); + form.append('text', message); + form.append('access_token', accessToken); + + if (replyToId) { + form.append('reply_to_id', replyToId); + } + + if (quoteId) { + form.append('quote_post_id', quoteId); + } + + const { id: contentId, ...all } = await ( + await this.fetch(`https://graph.threads.net/v1.0/${userId}/threads`, { + method: 'POST', + body: form, + }) + ).json(); + + return contentId; + } + + private async publishThread( + userId: string, + accessToken: string, + creationId: string + ): Promise<{ threadId: string; permalink: string }> { + await this.checkLoaded(creationId, accessToken); + + const { id: threadId } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${userId}/threads_publish?creation_id=${creationId}&access_token=${accessToken}`, + { + method: 'POST', + } + ) + ).json(); + + const { permalink } = await ( + await this.fetch( + `https://graph.threads.net/v1.0/${threadId}?fields=id,permalink&access_token=${accessToken}` + ) + ).json(); + + return { threadId, permalink }; + } + + private async createThreadContent( + userId: string, + accessToken: string, + postDetails: PostDetails, + replyToId?: string, + quoteId?: string + ): Promise { + // Handle content creation based on media type + if (!postDetails.media || postDetails.media.length === 0) { + // Text-only content + return await this.createTextContent( + userId, + accessToken, + postDetails.message, + replyToId, + quoteId + ); + } else if (postDetails.media.length === 1) { + // Single media content + return await this.createSingleMediaContent( + userId, + accessToken, + postDetails.media[0], + postDetails.message, + false, + replyToId + ); + } else { + // Carousel content + return await this.createCarouselContent( + userId, + accessToken, + postDetails.media, + postDetails.message, + replyToId + ); + } + } + + async post( + userId: string, + accessToken: string, + postDetails: PostDetails<{ + active_thread_finisher: boolean; + thread_finisher: string; + }>[] + ): Promise { + if (!postDetails.length) { + return []; + } + + const [firstPost] = postDetails; + + // Create the initial thread + const initialContentId = await this.createThreadContent( + userId, + accessToken, + firstPost + ); + + // Publish the thread + const { threadId, permalink } = await this.publishThread( + userId, + accessToken, + initialContentId + ); + + // Return the main post response + return [ + { + id: firstPost.id, + postId: threadId, + status: 'success', + releaseURL: permalink, + }, + ]; + } + + async comment( + userId: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails<{ + active_thread_finisher: boolean; + thread_finisher: string; + }>[], + integration: Integration + ): Promise { + if (!postDetails.length) { + return []; + } + + const [commentPost] = postDetails; + const replyToId = lastCommentId || postId; + + // Create reply content + const replyContentId = await this.createThreadContent( + userId, + accessToken, + commentPost, + replyToId + ); + + // Publish the reply + const { threadId: replyThreadId, permalink } = await this.publishThread( + userId, + accessToken, + replyContentId + ); + + return [ + { + id: commentPost.id, + postId: replyThreadId, + status: 'success', + releaseURL: permalink, + }, + ]; + } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const until = dayjs().endOf('day').unix(); + const since = dayjs().subtract(date, 'day').unix(); + + const { data, ...all } = await ( + await fetch( + `https://graph.threads.net/v1.0/${id}/threads_insights?metric=views,likes,replies,reposts,quotes&access_token=${accessToken}&period=day&since=${since}&until=${until}` + ) + ).json(); + + return ( + data?.map((d: any) => ({ + label: capitalize(d.name), + percentageChange: 5, + data: d.total_value + ? [{ total: d.total_value.value, date: dayjs().format('YYYY-MM-DD') }] + : d.values.map((v: any) => ({ + total: v.value, + date: dayjs(v.end_time).format('YYYY-MM-DD'), + })), + })) || [] + ); + } + + @Plug({ + identifier: 'threads-autoPlugPost', + title: 'Auto plug post', + description: + 'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion', + runEveryMilliseconds: 21600000, + totalRuns: 3, + fields: [ + { + name: 'likesAmount', + type: 'number', + placeholder: 'Amount of likes', + description: 'The amount of likes to trigger the repost', + validation: /^\d+$/, + }, + { + name: 'post', + type: 'richtext', + placeholder: 'Post to plug', + description: 'Message content to plug', + validation: /^[\s\S]{3,}$/g, + }, + ], + }) + async autoPlugPost( + integration: Integration, + id: string, + fields: { likesAmount: string; post: string } + ) { + const { data } = await ( + await fetch( + `https://graph.threads.net/v1.0/${id}/insights?metric=likes&access_token=${integration.token}` + ) + ).json(); + + const { + values: [value], + } = data.find((p: any) => p.name === 'likes'); + + if (value.value >= fields.likesAmount) { + await timer(2000); + + const form = new FormData(); + form.append('media_type', 'TEXT'); + form.append('text', stripHtmlValidation('normal', fields.post, true)); + form.append('reply_to_id', id); + form.append('access_token', integration.token); + + const { id: replyId } = await ( + await this.fetch('https://graph.threads.net/v1.0/me/threads', { + method: 'POST', + body: form, + }) + ).json(); + + await ( + await this.fetch( + `https://graph.threads.net/v1.0/${integration.internalId}/threads_publish?creation_id=${replyId}&access_token=${integration.token}`, + { + method: 'POST', + } + ) + ).json(); + return true; + } + + return false; + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + try { + // Fetch thread insights from Threads API + const { data } = await ( + await fetch( + `https://graph.threads.net/v1.0/${postId}/insights?metric=views,likes,replies,reposts,quotes&access_token=${accessToken}` + ) + ).json(); + + if (!data || data.length === 0) { + return []; + } + + const result: AnalyticsData[] = []; + + for (const metric of data) { + const value = metric.values?.[0]?.value ?? metric.total_value?.value; + if (value === undefined) continue; + + let label = ''; + + switch (metric.name) { + case 'views': + label = 'Views'; + break; + case 'likes': + label = 'Likes'; + break; + case 'replies': + label = 'Replies'; + break; + case 'reposts': + label = 'Reposts'; + break; + case 'quotes': + label = 'Quotes'; + break; + } + + if (label) { + result.push({ + label, + percentageChange: 0, + data: [{ total: String(value), date: today }], + }); + } + } + + return result; + } catch (err) { + console.error('Error fetching Threads post analytics:', err); + return []; + } + } + + // override async mention( + // token: string, + // data: { query: string }, + // id: string, + // integration: Integration + // ) { + // const p = await ( + // await fetch( + // `https://graph.threads.net/v1.0/profile_lookup?username=${data.query}&access_token=${integration.token}` + // ) + // ).json(); + // + // return [ + // { + // id: String(p.id), + // label: p.name, + // image: p.profile_picture_url, + // }, + // ]; + // } + // + // mentionFormat(idOrHandle: string, name: string) { + // return `@${idOrHandle}`; + // } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..fff84f7cad3402f6221bb73080dfa1afb13ad6a4 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/tiktok.provider.ts @@ -0,0 +1,1129 @@ +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import dayjs from 'dayjs'; +import { + BadBody, + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { TikTokDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tiktok.dto'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; +import { createReadStream, statSync } from 'fs'; +import { Integration } from '@prisma/client'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; + +@Rules( + [ + 'TikTok can have one video or one picture or multiple pictures, it cannot be without an attachment.', + 'content_posting_method=DIRECT_POST publishes the post to the account. content_posting_method=UPLOAD does NOT publish: it only sends the media to the user inbox of the TikTok app, where the user must manually complete and publish it within 24 hours or it is discarded. Use DIRECT_POST unless the user explicitly asks to review or edit the post inside the TikTok app first.', + 'With content_posting_method=UPLOAD, TikTok ignores every setting except the title / post content. Never tell the user that video_made_with_ai, privacy_level, duet, stitch, comment, autoAddMusic, brand_content_toggle or brand_organic_toggle will be applied in UPLOAD mode - they are silently discarded. If the user asks for any of those settings, tell them it requires DIRECT_POST.', + 'video_made_with_ai, duet and stitch apply to video posts only. TikTok has no equivalent field for photo posts, so those settings are discarded when the attachment is a picture.', + ].join(' ') +) +export class TiktokProvider extends SocialAbstract implements SocialProvider { + identifier = 'tiktok'; + name = 'Tiktok'; + isBetweenSteps = false; + convertToJPEG = true; + scopes = [ + 'video.list', + 'user.info.basic', + 'video.publish', + 'video.upload', + 'user.info.profile', + 'user.info.stats', + ]; + override maxConcurrentJob = 10000; + dto = TikTokDto; + editor = 'normal' as const; + maxLength() { + return 2000; + } + + override async checkValidity( + items: Array + ): Promise { + const [firstItems] = items ?? []; + if ((firstItems?.length ?? 0) === 0) { + return 'No video / images selected'; + } + if ( + (firstItems?.length ?? 0) > 1 && + firstItems?.some((p) => (p?.path?.indexOf?.('mp4') ?? -1) > -1) + ) { + return 'Only pictures are supported when selecting multiple items'; + } else if ( + firstItems?.length !== 1 && + (firstItems?.[0]?.path?.indexOf?.('mp4') ?? -1) > -1 + ) { + return 'You need one media'; + } + return true; + } + + override handleErrors(body: string): + | { + type: 'refresh-token' | 'bad-body'; + value: string; + } + | undefined { + // Authentication/Authorization errors - require re-authentication + if (body.indexOf('access_token_invalid') > -1) { + return { + type: 'refresh-token' as const, + value: + 'Access token invalid, please re-authenticate your TikTok account', + }; + } + + if (body.indexOf('scope_not_authorized') > -1) { + return { + type: 'bad-body' as const, + value: + 'Missing required permissions, please re-authenticate with all scopes', + }; + } + + if (body.indexOf('scope_permission_missed') > -1) { + return { + type: 'bad-body' as const, + value: 'Additional permissions required, please re-authenticate', + }; + } + + // Rate limiting errors + if (body.indexOf('rate_limit_exceeded') > -1) { + return { + type: 'bad-body' as const, + value: 'TikTok API rate limit exceeded, please try again later', + }; + } + + if (body.indexOf('file_format_check_failed') > -1) { + return { + type: 'bad-body' as const, + value: 'File format is invalid, please check video specifications', + }; + } + + if (body.indexOf('app_version_check_failed') > -1) { + return { + type: 'bad-body' as const, + value: + 'In order to use the TikTok upload feature, you have to update your app to the latest version', + }; + } + + if (body.indexOf('duration_check_failed') > -1) { + return { + type: 'bad-body' as const, + value: 'Video duration is invalid, please check video specifications', + }; + } + + if (body.indexOf('frame_rate_check_failed') > -1) { + return { + type: 'bad-body' as const, + value: 'Video frame rate is invalid, please check video specifications', + }; + } + + if (body.indexOf('video_pull_failed') > -1) { + return { + type: 'bad-body' as const, + value: 'Failed to pull video from URL, please check the URL', + }; + } + + if (body.indexOf('photo_pull_failed') > -1) { + return { + type: 'bad-body' as const, + value: 'Failed to pull photo from URL, please check the URL', + }; + } + + if (body.indexOf('spam_risk_user_banned_from_posting') > -1) { + return { + type: 'bad-body' as const, + value: + 'Account banned from posting, please check TikTok account status', + }; + } + + if (body.indexOf('spam_risk_text') > -1) { + return { + type: 'bad-body' as const, + value: 'TikTok detected potential spam in the post text', + }; + } + + if (body.indexOf('spam_risk_too_many_posts') > -1) { + return { + type: 'bad-body' as const, + value: + 'TikTok says your daily post limit reached, please try again tomorrow', + }; + } + + if (body.indexOf('spam_risk_too_many_pending_share') > -1) { + return { + type: 'bad-body' as const, + value: + 'TikTok limits pending posts to 5 within any 24-hour period. Please check your TikTok inbox in the TikTok mobile app and try again after 24 hours.', + }; + } + + if (body.indexOf('spam_risk_user_banned_from_posting') > -1) { + return { + type: 'bad-body' as const, + value: + 'Account banned from posting, please check TikTok account status', + }; + } + + if (body.indexOf('spam_risk') > -1) { + return { + type: 'bad-body' as const, + value: 'TikTok detected potential spam', + }; + } + + if (body.indexOf('reached_active_user_cap') > -1) { + return { + type: 'bad-body' as const, + value: 'Daily active user quota reached, please try again later', + }; + } + + if ( + body.indexOf('unaudited_client_can_only_post_to_private_accounts') > -1 + ) { + return { + type: 'bad-body' as const, + value: 'App not approved for public posting, contact support', + }; + } + + if (body.indexOf('url_ownership_unverified') > -1) { + return { + type: 'bad-body' as const, + value: + 'You have to upload the picture/video to Postiz when sending a URL', + }; + } + + if (body.indexOf('privacy_level_option_mismatch') > -1) { + return { + type: 'bad-body' as const, + value: 'Privacy level mismatch, please check privacy settings', + }; + } + + // Content/Format validation errors + if (body.indexOf('invalid_file_upload') > -1) { + return { + type: 'bad-body' as const, + value: 'Invalid file format or specifications not met', + }; + } + + if (body.indexOf('invalid_params') > -1) { + return { + type: 'bad-body' as const, + value: 'Invalid request parameters, please check content format', + }; + } + + // Server errors + if (body.indexOf('internal') > -1) { + return { + type: 'bad-body' as const, + value: 'There is a problem with TikTok servers, please try again later', + }; + } + + // Generic TikTok API errors + if (body.indexOf('picture_size_check_failed') > -1) { + return { + type: 'bad-body' as const, + value: 'Video must be at least 720p, Picture must no exceed 1080p', + }; + } + + if (body.indexOf('TikTok API error') > -1) { + return { + type: 'bad-body' as const, + value: 'TikTok API error, please try again', + }; + } + + // Fall back to parent class error handling + return undefined; + } + + async refreshToken(refreshToken: string): Promise { + const value = { + client_key: process.env.TIKTOK_CLIENT_ID!, + client_secret: process.env.TIKTOK_CLIENT_SECRET!, + grant_type: 'refresh_token', + refresh_token: refreshToken, + }; + + const { access_token, refresh_token, ...all } = await ( + await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + body: new URLSearchParams(value).toString(), + }) + ).json(); + + const { + data: { + user: { avatar_url, display_name, open_id, username }, + }, + } = await ( + await fetch( + 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,avatar_url,display_name,union_id,username', + { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + }, + } + ) + ).json(); + + return { + refreshToken: refresh_token, + expiresIn: dayjs().add(23, 'hours').unix() - dayjs().unix(), + accessToken: access_token, + id: open_id.replace(/-/g, ''), + name: display_name, + picture: avatar_url || '', + username: username, + }; + } + + async generateAuthUrl() { + const state = Math.random().toString(36).substring(2); + + return { + url: + 'https://www.tiktok.com/v2/auth/authorize/' + + `?client_key=${process.env.TIKTOK_CLIENT_ID}` + + `&redirect_uri=${encodeURIComponent( + `${ + process?.env?.FRONTEND_URL?.indexOf('https') === -1 + ? 'https://redirectmeto.com/' + : '' + }${process?.env?.FRONTEND_URL}/integrations/social/tiktok` + )}` + + `&state=${state}` + + `&response_type=code` + + `&scope=${encodeURIComponent(this.scopes.join(','))}`, + codeVerifier: state, + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const value = { + client_key: process.env.TIKTOK_CLIENT_ID!, + client_secret: process.env.TIKTOK_CLIENT_SECRET!, + code: params.code, + grant_type: 'authorization_code', + code_verifier: params.codeVerifier, + redirect_uri: `${ + process?.env?.FRONTEND_URL?.indexOf('https') === -1 + ? 'https://redirectmeto.com/' + : '' + }${process?.env?.FRONTEND_URL}/integrations/social/tiktok`, + }; + + const { access_token, refresh_token, scope } = await ( + await fetch('https://open.tiktokapis.com/v2/oauth/token/', { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + method: 'POST', + body: new URLSearchParams(value).toString(), + }) + ).json(); + + this.checkScopes(this.scopes, scope); + + const { + data: { + user: { avatar_url, display_name, open_id, username }, + }, + } = await ( + await fetch( + 'https://open.tiktokapis.com/v2/user/info/?fields=open_id,avatar_url,display_name,union_id,username', + { + method: 'GET', + headers: { + Authorization: `Bearer ${access_token}`, + }, + } + ) + ).json(); + + return { + id: open_id.replace(/-/g, ''), + name: display_name, + accessToken: access_token, + refreshToken: refresh_token, + expiresIn: dayjs().add(23, 'hours').unix() - dayjs().unix(), + picture: avatar_url, + username: username, + }; + } + + async maxVideoLength(accessToken: string) { + const { + data: { max_video_post_duration_sec }, + } = await ( + await fetch( + 'https://open.tiktokapis.com/v2/post/publish/creator_info/query/', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Authorization: `Bearer ${accessToken}`, + }, + } + ) + ).json(); + + return { + maxDurationSeconds: max_video_post_duration_sec, + }; + } + + private async uploadedVideoSuccess( + id: string, + publishId: string, + accessToken: string + ): Promise<{ url: string; id: string }> { + // eslint-disable-next-line no-constant-condition + for (const i of Array(27).keys()) { + // ~9 minutes at 20s interval + const post = await ( + await this.fetch( + 'https://open.tiktokapis.com/v2/post/publish/status/fetch/', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + publish_id: publishId, + }), + }, + '', + 0, + true + ) + ).json(); + + const { status, publicaly_available_post_id } = post.data; + + if (status === 'SEND_TO_USER_INBOX') { + return { + url: 'https://www.tiktok.com/messages?lang=en', + id: 'missing', + }; + } + + if (status === 'PUBLISH_COMPLETE') { + return { + url: !publicaly_available_post_id + ? `https://www.tiktok.com/@${id}` + : `https://www.tiktok.com/@${id}/video/` + + publicaly_available_post_id, + id: !publicaly_available_post_id + ? publishId + : publicaly_available_post_id?.[0], + }; + } + + if (status === 'FAILED') { + const handleError = this.handleErrors(JSON.stringify(post)); + throw new BadBody( + 'titok-error-upload', + JSON.stringify(post), + Buffer.from(JSON.stringify(post)), + handleError?.value || '' + ); + } + + await timer(20000); + } + + throw new BadBody( + 'titok-error-upload', + JSON.stringify({}), + Buffer.from(JSON.stringify({})), + 'TikTok refused to publish your post' + ); + } + + // UPLOAD does not publish - it only drops the media into the user's TikTok + // inbox - so only an explicit UPLOAD selects it. A missing value (drafts, or + // any caller that skipped the setting) publishes instead of silently landing + // in the inbox. + private contentPostingMethod( + firstPost: PostDetails + ): TikTokDto['content_posting_method'] { + return firstPost?.settings?.content_posting_method === 'UPLOAD' + ? 'UPLOAD' + : 'DIRECT_POST'; + } + + private postingMethod( + method: TikTokDto['content_posting_method'], + isPhoto: boolean + ): string { + switch (method) { + case 'UPLOAD': + return isPhoto ? '/content/init/' : '/inbox/video/init/'; + case 'DIRECT_POST': + default: + return isPhoto ? '/content/init/' : '/video/init/'; + } + } + + private buildTikokPostInfoBody(firstPost: PostDetails) { + const isPhoto = !hasExtension(firstPost?.media?.[0]?.path, 'mp4'); + const method = this.contentPostingMethod(firstPost); + + if (method === 'DIRECT_POST') { + return { + post_info: { + ...(isPhoto && firstPost.settings.title + ? { title: firstPost.settings.title.slice(0, 90) } + : {}), + ...(!isPhoto && firstPost.message + ? { title: firstPost.message } + : {}), + ...(isPhoto ? { description: firstPost.message } : {}), + privacy_level: + firstPost.settings.privacy_level || 'PUBLIC_TO_EVERYONE', + ...(isPhoto + ? {} + : { disable_duet: !this.assetBoolean(firstPost.settings.duet) }), + disable_comment: !this.assetBoolean(firstPost.settings.comment), + ...(isPhoto + ? {} + : { + disable_stitch: !this.assetBoolean(firstPost.settings.stitch), + }), + ...(isPhoto + ? {} + : { + is_aigc: this.assetBoolean( + firstPost.settings.video_made_with_ai + ), + }), + brand_content_toggle: this.assetBoolean( + firstPost.settings.brand_content_toggle + ), + brand_organic_toggle: this.assetBoolean( + firstPost.settings.brand_organic_toggle + ), + ...(isPhoto + ? { + auto_add_music: firstPost.settings.autoAddMusic === 'yes', + } + : {}), + }, + }; + } + + return { + post_info: { + ...(isPhoto && firstPost.settings.title + ? { title: firstPost.settings.title } + : {}), + ...(!isPhoto && firstPost.message ? { title: firstPost.message } : {}), + ...(isPhoto ? { description: firstPost.message } : {}), + }, + }; + } + + // --------------------------------------------------------------------------- + // OLD PULL_FROM_URL IMPLEMENTATION (kept for when the TikTok PULL bug is fixed) + // --------------------------------------------------------------------------- + // private buildTikokSourceInfoBody(firstPost: PostDetails) { + // const isPhoto = !hasExtension(firstPost?.media?.[0]?.path, 'mp4'); + // + // if (isPhoto) { + // return { + // post_mode: + // firstPost?.settings?.content_posting_method === 'DIRECT_POST' + // ? 'DIRECT_POST' + // : 'MEDIA_UPLOAD', + // media_type: 'PHOTO', + // source_info: { + // source: 'PULL_FROM_URL', + // photo_cover_index: 0, + // photo_images: firstPost.media?.map((p) => p.path), + // }, + // }; + // } + // + // return { + // source_info: { + // source: 'PULL_FROM_URL', + // video_url: firstPost?.media?.[0]?.path!, + // ...(firstPost?.media?.[0]?.thumbnailTimestamp! + // ? { + // video_cover_timestamp_ms: + // firstPost?.media?.[0]?.thumbnailTimestamp!, + // } + // : {}), + // }, + // }; + // } + // + // async post( + // id: string, + // accessToken: string, + // postDetails: PostDetails[], + // integration: Integration + // ): Promise { + // const [firstPost] = postDetails; + // const isPhoto = !hasExtension(firstPost?.media?.[0]?.path, 'mp4'); + // + // console.log({ + // ...this.buildTikokPostInfoBody(firstPost), + // ...this.buildTikokSourceInfoBody(firstPost), + // }); + // const { + // data: { publish_id }, + // } = await ( + // await this.fetch( + // `https://open.tiktokapis.com/v2/post/publish${this.postingMethod( + // firstPost.settings.content_posting_method, + // !hasExtension(firstPost?.media?.[0]?.path, 'mp4') + // )}`, + // { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json; charset=UTF-8', + // Authorization: `Bearer ${accessToken}`, + // }, + // body: JSON.stringify({ + // ...this.buildTikokPostInfoBody(firstPost), + // ...this.buildTikokSourceInfoBody(firstPost), + // }), + // } + // ) + // ).json(); + // + // const { url, id: videoId } = await this.uploadedVideoSuccess( + // integration.profile!, + // publish_id, + // accessToken + // ); + // + // return [ + // { + // id: firstPost.id, + // releaseURL: url, + // postId: String(videoId), + // status: 'success', + // }, + // ]; + // } + + // --------------------------------------------------------------------------- + // NEW FILE_UPLOAD IMPLEMENTATION (no PULL_FROM_URL for videos) + // --------------------------------------------------------------------------- + // TikTok video chunk constraints: a chunk must be between 5MB and 64MB. + // When the whole file fits in a single chunk (<= 64MB) we upload it in one + // request; otherwise we split it into 10MB chunks (the final chunk carries + // the remainder, which TikTok allows to exceed chunk_size). + private static readonly TIKTOK_MAX_SINGLE_CHUNK = 64 * 1024 * 1024; + private static readonly TIKTOK_CHUNK_SIZE = 10 * 1024 * 1024; + + private tiktokChunkPlan(videoSize: number) { + if (videoSize <= TiktokProvider.TIKTOK_MAX_SINGLE_CHUNK) { + return { chunkSize: videoSize, totalChunkCount: 1 }; + } + + const chunkSize = TiktokProvider.TIKTOK_CHUNK_SIZE; + return { + chunkSize, + totalChunkCount: Math.floor(videoSize / chunkSize), + }; + } + + // Resolves the total byte size of the media without loading it into memory: + // a HEAD request for remote URLs, statSync for local files. + private async tiktokMediaSize(path: string): Promise { + if (path.indexOf('http') === 0) { + const head = await fetch(path, { method: 'HEAD' }); + const length = head.headers.get('content-length'); + if (!length) { + throw new BadBody( + 'tiktok-error-upload', + '{}', + Buffer.from('{}'), + 'Could not determine the video size for TikTok upload' + ); + } + return Number(length); + } + + return statSync(path).size; + } + + // Returns a streaming body for the [start, end] byte range of the media so we + // never hold the whole file in memory: a ranged GET for remote URLs, a ranged + // read stream for local files. + private async tiktokChunkStream(path: string, start: number, end: number) { + if (path.indexOf('http') === 0) { + const response = await fetch(path, { + headers: { Range: `bytes=${start}-${end}` }, + }); + return response.body; + } + + return createReadStream(path, { start, end }); + } + + // Streams the video bytes to the upload_url returned by the init call. + // We use the global fetch (not this.fetch) because chunked uploads answer + // with 206 (Partial Content), which this.fetch would treat as an error. + private async uploadTikTokVideoBytes( + uploadUrl: string, + path: string, + videoSize: number, + contentType: string + ) { + const { chunkSize, totalChunkCount } = this.tiktokChunkPlan(videoSize); + + for (let i = 0; i < totalChunkCount; i++) { + const start = i * chunkSize; + const end = + i === totalChunkCount - 1 ? videoSize - 1 : start + chunkSize - 1; + const contentLength = end - start + 1; + + const body = await this.tiktokChunkStream(path, start, end); + + const upload = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'Content-Type': contentType, + 'Content-Length': String(contentLength), + 'Content-Range': `bytes ${start}-${end}/${videoSize}`, + }, + body, + // Required by undici when streaming a request body. + duplex: 'half', + } as any); + + if ( + upload.status !== 200 && + upload.status !== 201 && + upload.status !== 206 + ) { + const text = await upload.text().catch(() => '{}'); + const handleError = this.handleErrors(text); + throw new BadBody( + 'tiktok-error-upload', + text, + Buffer.from(text), + handleError?.value || 'Failed to upload the video to TikTok' + ); + } + } + } + + private buildTikokSourceInfoBody( + firstPost: PostDetails, + videoSize?: number + ) { + const isPhoto = !hasExtension(firstPost?.media?.[0]?.path, 'mp4'); + + // TikTok photo posts only support PULL_FROM_URL, there is no FILE_UPLOAD + // path for photos, so this branch keeps pulling from the URL. + if (isPhoto) { + return { + post_mode: + this.contentPostingMethod(firstPost) === 'DIRECT_POST' + ? 'DIRECT_POST' + : 'MEDIA_UPLOAD', + media_type: 'PHOTO', + source_info: { + source: 'PULL_FROM_URL', + photo_cover_index: 0, + photo_images: firstPost.media?.map((p) => p.path), + }, + }; + } + + const { chunkSize, totalChunkCount } = this.tiktokChunkPlan(videoSize || 0); + + return { + source_info: { + source: 'FILE_UPLOAD', + video_size: videoSize, + chunk_size: chunkSize, + total_chunk_count: totalChunkCount, + }, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [firstPost] = postDetails; + const isPhoto = !hasExtension(firstPost?.media?.[0]?.path, 'mp4'); + const videoPath = firstPost?.media?.[0]?.path!; + + // For videos we only need the total size up front (HEAD / statSync) so we + // can init the upload; the bytes themselves are streamed later, never fully + // loaded into memory. + const videoSize = isPhoto + ? undefined + : await this.tiktokMediaSize(videoPath); + + const { + data: { publish_id, upload_url }, + } = await ( + await this.fetch( + `https://open.tiktokapis.com/v2/post/publish${this.postingMethod( + this.contentPostingMethod(firstPost), + isPhoto + )}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + ...this.buildTikokPostInfoBody(firstPost), + ...this.buildTikokSourceInfoBody(firstPost, videoSize), + }), + } + ) + ).json(); + + // Videos: stream the bytes to the upload_url returned by the init call. + if (!isPhoto && upload_url && videoSize) { + await this.uploadTikTokVideoBytes( + upload_url, + videoPath, + videoSize, + 'video/mp4' + ); + } + + const { url, id: videoId } = await this.uploadedVideoSuccess( + integration.profile!, + publish_id, + accessToken + ); + + return [ + { + id: firstPost.id, + releaseURL: url, + postId: String(videoId), + status: 'success', + }, + ]; + } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + try { + // Get user stats (follower_count, following_count, likes_count, video_count) + const userStatsResponse = await fetch( + 'https://open.tiktokapis.com/v2/user/info/?fields=follower_count,following_count,likes_count,video_count', + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + const userStatsData = await userStatsResponse.json(); + const userStats = userStatsData?.data?.user; + + const result: AnalyticsData[] = []; + + if (userStats) { + if (userStats.follower_count !== undefined) { + result.push({ + label: 'Followers', + percentageChange: 0, + data: [{ total: String(userStats.follower_count), date: today }], + }); + } + + if (userStats.following_count !== undefined) { + result.push({ + label: 'Following', + percentageChange: 0, + data: [{ total: String(userStats.following_count), date: today }], + }); + } + + if (userStats.likes_count !== undefined) { + result.push({ + label: 'Total Likes', + percentageChange: 0, + data: [{ total: String(userStats.likes_count), date: today }], + }); + } + + if (userStats.video_count !== undefined) { + result.push({ + label: 'Videos', + percentageChange: 0, + data: [{ total: String(userStats.video_count), date: today }], + }); + } + } + + // Get recent videos and aggregate their stats + const videoListResponse = await fetch( + 'https://open.tiktokapis.com/v2/video/list/?fields=id', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ max_count: 20 }), + } + ); + + const videoListData = await videoListResponse.json(); + const videos = videoListData?.data?.videos; + + if (videos && videos.length > 0) { + const videoIds = videos.map((v: { id: string }) => v.id); + + // Query video details to get engagement metrics + const videoQueryResponse = await fetch( + 'https://open.tiktokapis.com/v2/video/query/?fields=id,like_count,comment_count,share_count,view_count', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + filters: { video_ids: videoIds }, + }), + } + ); + + const videoQueryData = await videoQueryResponse.json(); + const videoDetails = videoQueryData?.data?.videos; + + if (videoDetails && videoDetails.length > 0) { + let totalViews = 0; + let totalLikes = 0; + let totalComments = 0; + let totalShares = 0; + + for (const video of videoDetails) { + totalViews += video.view_count || 0; + totalLikes += video.like_count || 0; + totalComments += video.comment_count || 0; + totalShares += video.share_count || 0; + } + + result.push({ + label: 'Views', + percentageChange: 0, + data: [{ total: String(totalViews), date: today }], + }); + + result.push({ + label: 'Recent Likes', + percentageChange: 0, + data: [{ total: String(totalLikes), date: today }], + }); + + result.push({ + label: 'Recent Comments', + percentageChange: 0, + data: [{ total: String(totalComments), date: today }], + }); + + result.push({ + label: 'Recent Shares', + percentageChange: 0, + data: [{ total: String(totalShares), date: today }], + }); + } + } + + return result; + } catch (err) { + console.error('Error fetching TikTok analytics:', err); + return []; + } + } + + async missing( + id: string, + accessToken: string + ): Promise<{ id: string; url: string }[]> { + try { + const videoListResponse = await this.fetch( + 'https://open.tiktokapis.com/v2/video/list/?fields=id,cover_image_url,title', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ max_count: 20 }), + } + ); + + const videoListData = await videoListResponse.json(); + const videos = videoListData?.data?.videos; + + if (!videos || videos.length === 0) { + return []; + } + + return videos.map((v: { id: string; cover_image_url: string }) => ({ + id: String(v.id), + url: v.cover_image_url, + })); + } catch (err) { + console.error('Error fetching TikTok missing content:', err); + return []; + } + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + fromDate: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + if (postId.indexOf('v_pub_url') > -1) { + const post = await ( + await fetch( + 'https://open.tiktokapis.com/v2/post/publish/status/fetch/', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=UTF-8', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + publish_id: postId, + }), + } + ) + ).json(); + + if (!post?.data?.publicaly_available_post_id?.[0]) { + return []; + } + + postId = post.data.publicaly_available_post_id[0]; + } + + try { + // Query video details using the video ID + const response = await fetch( + 'https://open.tiktokapis.com/v2/video/query/?fields=id,like_count,comment_count,share_count,view_count', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + filters: { + video_ids: [postId], + }, + }), + } + ); + + const data = await response.json(); + const video = data?.data?.videos?.[0]; + + if (!video) { + return []; + } + + const result: AnalyticsData[] = []; + + if (video.view_count !== undefined) { + result.push({ + label: 'Views', + percentageChange: 0, + data: [{ total: String(video.view_count), date: today }], + }); + } + + if (video.like_count !== undefined) { + result.push({ + label: 'Likes', + percentageChange: 0, + data: [{ total: String(video.like_count), date: today }], + }); + } + + if (video.comment_count !== undefined) { + result.push({ + label: 'Comments', + percentageChange: 0, + data: [{ total: String(video.comment_count), date: today }], + }); + } + + if (video.share_count !== undefined) { + result.push({ + label: 'Shares', + percentageChange: 0, + data: [{ total: String(video.share_count), date: today }], + }); + } + + return result; + } catch (err) { + console.error('Error fetching TikTok post analytics:', err); + return []; + } + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts b/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..0553dc4a5a0a3ecb54cf15c251d9506f38404047 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/tumblr.provider.ts @@ -0,0 +1,633 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { TumblrDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/tumblr.dto'; +import { Integration } from '@prisma/client'; +import axios from 'axios'; +import { lookup } from 'mime-types'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; + +const TUMBLR_API_URL = 'https://api.tumblr.com/v2'; +const TUMBLR_USER_AGENT = 'Postiz/1.0 (+https://postiz.com)'; +const TUMBLR_TEXT_BLOCK_LIMIT = 4096; +const TUMBLR_DEFAULT_VIDEO_WIDTH = 540; +const TUMBLR_DEFAULT_VIDEO_HEIGHT = 405; + +interface TumblrBlog { + name: string; + title?: string; + url?: string; + primary?: boolean; + followers?: number; +} + +interface TumblrUserInfo { + response?: { + user?: { + name?: string; + blogs?: TumblrBlog[]; + }; + }; +} + +interface TumblrTokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + scope?: string; +} + +interface TumblrCreatePostResponse { + response?: { + id?: string | number; + id_string?: string; + post_id?: string; + }; +} + +type TumblrUploadMedia = { + type: string; + identifier: string; + width: number; + height: number; +}; + +type TumblrContentBlock = + | { + type: 'text'; + text: string; + subtype?: 'heading1'; + } + | { + type: 'link'; + url: string; + } + | { + type: 'image'; + media: TumblrUploadMedia[]; + alt_text?: string; + } + | { + type: 'video'; + provider: 'tumblr'; + media: TumblrUploadMedia; + }; + +export class TumblrProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 3; + identifier = 'tumblr'; + name = 'Tumblr'; + isBetweenSteps = true; + scopes = ['write', 'offline_access']; + editor = 'normal' as const; + dto = TumblrDto; + + maxLength() { + return 32768; + } + + override async checkValidity( + posts: Array + ): Promise { + const [firstPost] = posts ?? []; + const images = + firstPost?.filter((item) => !this.isVideoPath(item?.path)) || []; + const videos = + firstPost?.filter((item) => this.isVideoPath(item?.path)) || []; + + if (images.length > 30) { + return 'Tumblr supports up to 30 images in one post.'; + } + + if (videos.length > 1) { + return 'Tumblr supports one uploaded video in one post.'; + } + + return true; + } + + override handleErrors( + body: string, + status?: number + ): + | { + type: 'refresh-token' | 'bad-body' | 'retry'; + value: string; + } + | undefined { + if ( + status === 401 || + body.includes('Unauthorized') || + body.includes('invalid_grant') || + body.includes('invalid_token') + ) { + return { + type: 'refresh-token', + value: 'Please re-authenticate your Tumblr account.', + }; + } + + if (body.includes('daily posting limit') || this.hasErrorCode(body, 8023)) { + return { + type: 'bad-body', + value: 'Tumblr daily posting limit reached.', + }; + } + + if (this.hasErrorCode(body, 8001)) { + return { + type: 'bad-body', + value: 'Tumblr rejected the post content format.', + }; + } + + if (this.hasErrorCode(body, 8002)) { + return { + type: 'bad-body', + value: 'Tumblr rejected the reblog parent post information.', + }; + } + + if (this.hasErrorCode(body, 8004)) { + return { + type: 'bad-body', + value: 'Tumblr daily media upload limit reached.', + }; + } + + if (this.hasErrorCode(body, 8005)) { + return { + type: 'bad-body', + value: 'Tumblr rejected one of the uploaded media files.', + }; + } + + if (this.hasErrorCode(body, 8006)) { + return { + type: 'retry', + value: 'Tumblr had a media upload error.', + }; + } + + if (this.hasErrorCode(body, 8008)) { + return { + type: 'bad-body', + value: 'Tumblr does not allow uploaded videos in reblog content.', + }; + } + + if (this.hasErrorCode(body, 8010)) { + return { + type: 'bad-body', + value: + 'Tumblr is still transcoding a video upload for this blog. Please try again later.', + }; + } + + if (this.hasErrorCode(body, 8011)) { + return { + type: 'bad-body', + value: 'Tumblr daily video upload limit reached.', + }; + } + + if (this.hasErrorCode(body, 8016)) { + return { + type: 'bad-body', + value: 'Tumblr rejected the ask content or layout.', + }; + } + + if (this.hasErrorCode(body, 8022)) { + return { + type: 'bad-body', + value: 'Tumblr blog queue limit reached.', + }; + } + + if (this.hasErrorCode(body, 8009)) { + return { + type: 'retry', + value: 'Tumblr had a video upload error.', + }; + } + + if (status === 429) { + return { + type: 'retry', + value: 'Tumblr API rate limit reached.', + }; + } + + if (status === 503) { + return { + type: 'retry', + value: 'Tumblr posting via the API is temporarily unavailable.', + }; + } + + return undefined; + } + + async refreshToken(refreshToken: string): Promise { + const token = await this.requestToken( + new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: process.env.TUMBLR_CLIENT_ID!, + client_secret: process.env.TUMBLR_CLIENT_SECRET!, + }) + ); + + const userInfo = await this.getUserInfo(token.access_token); + const user = userInfo.response?.user; + const primaryBlog = this.getPrimaryBlog(user?.blogs || []); + + return { + id: user?.name || primaryBlog?.name || '', + name: user?.name || primaryBlog?.title || primaryBlog?.name || 'Tumblr', + accessToken: token.access_token, + refreshToken: token.refresh_token || refreshToken, + expiresIn: token.expires_in, + picture: primaryBlog ? this.getAvatarUrl(primaryBlog.name) : '', + username: user?.name || '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + const redirectUri = this.redirectUri(); + const params = new URLSearchParams({ + client_id: process.env.TUMBLR_CLIENT_ID!, + response_type: 'code', + scope: this.scopes.join(' '), + state, + redirect_uri: redirectUri, + }); + + return { + url: `https://www.tumblr.com/oauth2/authorize?${params.toString()}`, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { code: string; codeVerifier: string }) { + const token = await this.requestToken( + new URLSearchParams({ + grant_type: 'authorization_code', + code: params.code, + client_id: process.env.TUMBLR_CLIENT_ID!, + client_secret: process.env.TUMBLR_CLIENT_SECRET!, + redirect_uri: this.redirectUri(), + }) + ); + + this.checkScopes(this.scopes, token.scope || ''); + + const userInfo = await this.getUserInfo(token.access_token); + const user = userInfo.response?.user; + const primaryBlog = this.getPrimaryBlog(user?.blogs || []); + + return { + id: user?.name || primaryBlog?.name || '', + name: user?.name || primaryBlog?.title || primaryBlog?.name || 'Tumblr', + accessToken: token.access_token, + refreshToken: token.refresh_token || '', + expiresIn: token.expires_in, + picture: primaryBlog ? this.getAvatarUrl(primaryBlog.name) : '', + username: user?.name || '', + }; + } + + async pages(accessToken: string) { + const userInfo = await this.getUserInfo(accessToken); + const blogs = userInfo.response?.user?.blogs || []; + + return blogs.map((blog) => ({ + id: blog.name, + name: blog.title || blog.name, + username: blog.url || `https://${blog.name}.tumblr.com/`, + followers: blog.followers || 0, + primary: !!blog.primary, + picture: { + data: { + url: this.getAvatarUrl(blog.name), + }, + }, + })); + } + + async fetchPageInformation(accessToken: string, data: { id: string }) { + const blogs = await this.pages(accessToken); + const blog = blogs.find((item) => item.id === data.id); + + if (!blog) { + throw new Error('Tumblr blog not found'); + } + + return { + id: blog.id, + name: blog.name, + access_token: accessToken, + picture: blog.picture.data.url, + username: blog.username, + }; + } + + async reConnect( + id: string, + requiredId: string, + accessToken: string + ): Promise> { + const information = await this.fetchPageInformation(accessToken, { + id: requiredId, + }); + + return { + id: information.id, + name: information.name, + accessToken: information.access_token, + picture: information.picture, + username: information.username, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [post] = postDetails; + const content = await this.createContentBlocks(post); + const payload = { + content, + state: 'published', + ...(post.settings?.tags ? { tags: post.settings.tags } : {}), + ...(post.settings?.sourceUrl + ? { source_url: this.normalizeUrl(post.settings.sourceUrl) } + : {}), + }; + + const response = post.media?.length + ? await this.createMultipartPost(id, accessToken, payload, post.media) + : await this.createJsonPost(id, accessToken, payload); + + const postId = String( + response.response?.id_string || + response.response?.post_id || + response.response?.id || + '' + ); + const blogUrl = this.normalizeBlogUrl( + integration.profile || `https://www.tumblr.com/${id}` + ); + + return [ + { + id: post.id, + status: 'completed', + postId, + releaseURL: `${blogUrl}/post/${postId}`, + }, + ]; + } + + private redirectUri() { + return `${process.env.FRONTEND_URL}/integrations/social/tumblr`; + } + + private getAvatarUrl(blogName: string) { + return `${TUMBLR_API_URL}/blog/${encodeURIComponent( + `${blogName}.tumblr.com` + )}/avatar/128`; + } + + private normalizeBlogUrl(url: string) { + return this.normalizeUrl(url).replace(/\/$/, ''); + } + + private normalizeUrl(url: string) { + return url.startsWith('http') ? url : `https://${url}`; + } + + private getMediaUrl(path: string) { + return path?.indexOf('http') === -1 + ? `${process.env.FRONTEND_URL}/${path}` + : path; + } + + private getMimeType(path: string) { + return lookup(path.split('?')[0]) || 'application/octet-stream'; + } + + private isVideoPath(path?: string | null) { + return hasExtension(path, 'mp4'); + } + + private async getVideoDimensions( + media: NonNullable[number] + ) { + if (!media.thumbnail) { + return { + width: TUMBLR_DEFAULT_VIDEO_WIDTH, + height: TUMBLR_DEFAULT_VIDEO_HEIGHT, + }; + } + + try { + return await this.getImageDimensions(media.thumbnail); + } catch { + return { + width: TUMBLR_DEFAULT_VIDEO_WIDTH, + height: TUMBLR_DEFAULT_VIDEO_HEIGHT, + }; + } + } + + private getPrimaryBlog(blogs: TumblrBlog[]) { + return blogs.find((blog) => blog.primary) || blogs[0]; + } + + private async requestToken(body: URLSearchParams) { + return (await ( + await this.fetch(`${TUMBLR_API_URL}/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': TUMBLR_USER_AGENT, + }, + body, + }) + ).json()) as TumblrTokenResponse; + } + + private async getUserInfo(accessToken: string) { + return (await ( + await this.fetch(`${TUMBLR_API_URL}/user/info`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': TUMBLR_USER_AGENT, + }, + }) + ).json()) as TumblrUserInfo; + } + + private async createJsonPost( + blogName: string, + accessToken: string, + payload: { content: TumblrContentBlock[]; [key: string]: any } + ) { + return (await ( + await this.fetch( + `${TUMBLR_API_URL}/blog/${encodeURIComponent(blogName)}/posts`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'User-Agent': TUMBLR_USER_AGENT, + }, + body: JSON.stringify(payload), + } + ) + ).json()) as TumblrCreatePostResponse; + } + + private async createMultipartPost( + blogName: string, + accessToken: string, + payload: { content: TumblrContentBlock[]; [key: string]: any }, + media: NonNullable + ) { + const formData = new FormData(); + formData.append( + 'json', + new Blob([JSON.stringify(payload)], { type: 'application/json' }) + ); + + for (const [index, item] of media.entries()) { + const mimeType = this.getMimeType(item.path); + const { data } = await axios.get(this.getMediaUrl(item.path), { + responseType: 'arraybuffer', + }); + formData.append( + `media-${index}`, + new Blob([Buffer.from(data)], { type: mimeType }), + item.path.split('/').pop() || `media-${index}` + ); + } + + return (await ( + await this.fetch( + `${TUMBLR_API_URL}/blog/${encodeURIComponent(blogName)}/posts`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'User-Agent': TUMBLR_USER_AGENT, + }, + body: formData, + } + ) + ).json()) as TumblrCreatePostResponse; + } + + private async createContentBlocks(post: PostDetails) { + const content: TumblrContentBlock[] = []; + + if (post.settings?.title) { + content.push({ + type: 'text', + subtype: 'heading1', + text: post.settings.title, + }); + } + + content.push(...this.textBlocks(post.message || '')); + + if (post.settings?.link) { + content.push({ + type: 'link', + url: this.normalizeUrl(post.settings.link), + }); + } + + for (const [index, item] of (post.media || []).entries()) { + const identifier = `media-${index}`; + const media = { + type: this.getMimeType(item.path), + identifier, + }; + + if (this.isVideoPath(item.path)) { + const details = await this.getVideoDimensions(item); + content.push({ + type: 'video', + provider: 'tumblr', + media: { + ...media, + width: details.width, + height: details.height, + }, + }); + continue; + } + + const details = await this.getImageDimensions(item.path); + content.push({ + type: 'image', + media: [ + { + ...media, + width: details.width, + height: details.height, + }, + ], + ...(item.alt ? { alt_text: item.alt } : {}), + }); + } + + if (!content.length) { + content.push({ + type: 'text', + text: '', + }); + } + + return content; + } + + private textBlocks(message: string) { + return message + .split(/\n{2,}/g) + .flatMap((part) => this.chunkText(part.trim())) + .filter(Boolean) + .map((text) => ({ + type: 'text' as const, + text, + })); + } + + private chunkText(text: string) { + const codePoints = Array.from(text); + const chunks: string[] = []; + for (let i = 0; i < codePoints.length; i += TUMBLR_TEXT_BLOCK_LIMIT) { + chunks.push(codePoints.slice(i, i + TUMBLR_TEXT_BLOCK_LIMIT).join('')); + } + return chunks; + } + + private hasErrorCode(body: string, code: number) { + return new RegExp(`(?:\\b|\\.)${code}\\b`).test(body); + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts b/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..c88480360fb26ccc582126daa16f8f98434bdf10 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/twitch.provider.ts @@ -0,0 +1,291 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { Integration } from '@prisma/client'; +import { TwitchDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/twitch.dto'; +import { timer } from '@gitroom/helpers/utils/timer'; + +export class TwitchProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 1; + identifier = 'twitch'; + name = 'Twitch'; + isBetweenSteps = false; + editor = 'normal' as const; + scopes = ['user:write:chat', 'user:read:chat', 'moderator:manage:announcements']; + dto = TwitchDto; + + maxLength() { + return 500; // Twitch chat message max length + } + + async refreshToken(refreshToken: string): Promise { + const response = await this.fetch('https://id.twitch.tv/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + client_id: process.env.TWITCH_CLIENT_ID!, + client_secret: process.env.TWITCH_CLIENT_SECRET!, + refresh_token: refreshToken, + }), + }); + + const { access_token, refresh_token, expires_in } = await response.json(); + + // Get user info + const userInfo = await this.getUserInfo(access_token); + + return { + refreshToken: refresh_token, + expiresIn: expires_in, + accessToken: access_token, + id: userInfo.id, + name: userInfo.name, + picture: userInfo.picture || '', + username: userInfo.username, + }; + } + + async generateAuthUrl() { + const state = makeId(32); + + const redirectUri = `${process.env.FRONTEND_URL}/integrations/social/twitch`; + + const url = + `https://id.twitch.tv/oauth2/authorize` + + `?response_type=code` + + `&client_id=${process.env.TWITCH_CLIENT_ID}` + + `&redirect_uri=${encodeURIComponent(redirectUri)}` + + `&scope=${encodeURIComponent(this.scopes.join(' '))}` + + `&state=${state}`; + + return { + url, + codeVerifier: makeId(10), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const redirectUri = `${process.env.FRONTEND_URL}/integrations/social/twitch${ + params.refresh ? `?refresh=${params.refresh}` : '' + }`; + + const tokenResponse = await this.fetch('https://id.twitch.tv/oauth2/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'authorization_code', + client_id: process.env.TWITCH_CLIENT_ID!, + client_secret: process.env.TWITCH_CLIENT_SECRET!, + redirect_uri: redirectUri, + code: params.code, + }), + }); + + const { access_token, refresh_token, expires_in } = + await tokenResponse.json(); + + // Get user info + const userInfo = await this.getUserInfo(access_token); + + return { + id: userInfo.id, + name: userInfo.name, + accessToken: access_token, + refreshToken: refresh_token, + expiresIn: expires_in, + picture: userInfo.picture || '', + username: userInfo.username, + }; + } + + private async getUserInfo( + accessToken: string + ): Promise<{ id: string; name: string; username: string; picture?: string }> { + const userResponse = await fetch('https://api.twitch.tv/helix/users', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Client-Id': process.env.TWITCH_CLIENT_ID!, + }, + }); + + const userData = await userResponse.json(); + const user = userData.data?.[0]; + + return { + id: String(user.id), + name: user.display_name, + username: user.login, + picture: user.profile_image_url || '', + }; + } + + private async sendAnnouncement( + broadcasterId: string, + accessToken: string, + message: string, + color: string = 'primary' + ): Promise<{ success: boolean }> { + await fetch( + `https://api.twitch.tv/helix/chat/announcements?broadcaster_id=${broadcasterId}&moderator_id=${broadcasterId}`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Client-Id': process.env.TWITCH_CLIENT_ID!, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: message.substring(0, 500), + color, + }), + } + ); + + // Announcements return 204 No Content on success + return { success: true }; + } + + private async sendChatMessage( + broadcasterId: string, + accessToken: string, + message: string, + replyToMessageId?: string + ): Promise<{ messageId: string; isSent: boolean }> { + const body: Record = { + broadcaster_id: broadcasterId, + sender_id: broadcasterId, + message: message.substring(0, 500), + }; + + if (replyToMessageId) { + body.reply_parent_message_id = replyToMessageId; + } + + const response = await this.fetch( + 'https://api.twitch.tv/helix/chat/messages', + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Client-Id': process.env.TWITCH_CLIENT_ID!, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + } + ); + + const data = await response.json(); + + return { + messageId: data.data?.[0]?.message_id || makeId(10), + isSent: data.data?.[0]?.is_sent ?? false, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + await timer(2000); + const [firstPost] = postDetails; + const messageType = firstPost.settings?.messageType || 'message'; + const announcementColor = firstPost.settings?.announcementColor || 'primary'; + + if (messageType === 'announcement') { + const result = await this.sendAnnouncement( + id, + accessToken, + firstPost.message, + announcementColor + ); + + return [ + { + id: firstPost.id, + postId: makeId(10), // Announcements don't return a message ID + releaseURL: `https://twitch.tv/${integration.profile || integration.providerIdentifier}`, + status: result.success ? 'posted' : 'error', + }, + ]; + } + + // Regular chat message + const result = await this.sendChatMessage(id, accessToken, firstPost.message); + + return [ + { + id: firstPost.id, + postId: result.messageId, + releaseURL: `https://twitch.tv/${integration.profile || integration.providerIdentifier}`, + status: result.isSent ? 'posted' : 'error', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + await timer(2000); + const [commentPost] = postDetails; + const messageType = commentPost.settings?.messageType || 'message'; + const announcementColor = commentPost.settings?.announcementColor || 'primary'; + + if (messageType === 'announcement') { + const result = await this.sendAnnouncement( + id, + accessToken, + commentPost.message, + announcementColor + ); + + return [ + { + id: commentPost.id, + postId: makeId(10), + releaseURL: `https://twitch.tv/${integration.profile || integration.providerIdentifier}`, + status: result.success ? 'posted' : 'error', + }, + ]; + } + + // Regular chat message with reply + const result = await this.sendChatMessage( + id, + accessToken, + commentPost.message, + lastCommentId || postId + ); + + return [ + { + id: commentPost.id, + postId: result.messageId, + releaseURL: `https://twitch.tv/${integration.profile || integration.providerIdentifier}`, + status: result.isSent ? 'posted' : 'error', + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..f767d531eef421c21562ac9b60869d26b175eef4 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/vk.provider.ts @@ -0,0 +1,312 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import dayjs from 'dayjs'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { createHash, randomBytes } from 'crypto'; +import axios from 'axios'; +import FormDataNew from 'form-data'; +import mime from 'mime-types'; +import { Integration } from '@prisma/client'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; + +export class VkProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 2; // VK has moderate API limits + identifier = 'vk'; + name = 'VK'; + isBetweenSteps = false; + scopes = [ + 'vkid.personal_info', + 'email', + 'wall', + 'status', + 'docs', + 'photos', + 'video', + ]; + + editor = 'normal' as const; + maxLength() { + return 2048; + } + + async refreshToken(refresh: string): Promise { + const [oldRefreshToken, device_id] = refresh.split('&&&&'); + const formData = new FormData(); + formData.append('grant_type', 'refresh_token'); + formData.append('refresh_token', oldRefreshToken); + formData.append('client_id', process.env.VK_ID!); + formData.append('device_id', device_id); + formData.append('state', makeId(32)); + formData.append('scope', this.scopes.join(' ')); + + const { access_token, refresh_token, expires_in } = await ( + await this.fetch('https://id.vk.com/oauth2/auth', { + method: 'POST', + body: formData, + }) + ).json(); + + const newFormData = new FormData(); + newFormData.append('client_id', process.env.VK_ID!); + newFormData.append('access_token', access_token); + + const { + user: { user_id, first_name, last_name, avatar }, + } = await ( + await this.fetch('https://id.vk.com/oauth2/user_info', { + method: 'POST', + body: newFormData, + }) + ).json(); + + return { + id: user_id, + name: first_name + ' ' + last_name, + accessToken: access_token, + refreshToken: refresh_token + '&&&&' + device_id, + expiresIn: dayjs().add(expires_in, 'seconds').unix() - dayjs().unix(), + picture: avatar || '', + username: first_name.toLowerCase(), + }; + } + + async generateAuthUrl() { + const state = makeId(32); + const codeVerifier = randomBytes(64).toString('base64url'); + const challenge = Buffer.from( + createHash('sha256').update(codeVerifier).digest() + ) + .toString('base64') + .replace(/=*$/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + return { + url: + 'https://id.vk.com/authorize' + + `?response_type=code` + + `&client_id=${process.env.VK_ID}` + + `&code_challenge_method=S256` + + `&code_challenge=${challenge}` + + `&redirect_uri=${encodeURIComponent( + `${ + process?.env.FRONTEND_URL?.indexOf('https') == -1 + ? `https://redirectmeto.com/${process?.env.FRONTEND_URL}` + : `${process?.env.FRONTEND_URL}` + }/integrations/social/vk` + )}` + + `&state=${state}` + + `&scope=${encodeURIComponent(this.scopes.join(' '))}`, + codeVerifier, + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const [code, device_id] = params.code.split('&&&&'); + + const formData = new FormData(); + formData.append('client_id', process.env.VK_ID!); + formData.append('grant_type', 'authorization_code'); + formData.append('code_verifier', params.codeVerifier); + formData.append('device_id', device_id); + formData.append('code', code); + formData.append( + 'redirect_uri', + `${ + process?.env.FRONTEND_URL?.indexOf('https') == -1 + ? `https://redirectmeto.com/${process?.env.FRONTEND_URL}` + : `${process?.env.FRONTEND_URL}` + }/integrations/social/vk` + ); + + const { access_token, scope, refresh_token, expires_in } = await ( + await this.fetch('https://id.vk.com/oauth2/auth', { + method: 'POST', + body: formData, + }) + ).json(); + + const newFormData = new FormData(); + newFormData.append('client_id', process.env.VK_ID!); + newFormData.append('access_token', access_token); + + const { + user: { user_id, first_name, last_name, avatar }, + } = await ( + await this.fetch('https://id.vk.com/oauth2/user_info', { + method: 'POST', + body: newFormData, + }) + ).json(); + + return { + id: user_id, + name: first_name + ' ' + last_name, + accessToken: access_token, + refreshToken: refresh_token + '&&&&' + device_id, + expiresIn: dayjs().add(expires_in, 'seconds').unix() - dayjs().unix(), + picture: avatar || '', + username: first_name.toLowerCase(), + }; + } + + private async uploadMedia( + userId: string, + accessToken: string, + post: PostDetails + ): Promise<{ id: string; type: string }[]> { + return await Promise.all( + (post?.media || []).map(async (media) => { + const all = await ( + await this.fetch( + hasExtension(media.path, 'mp4') + ? `https://api.vk.com/method/video.save?access_token=${accessToken}&v=5.251` + : `https://api.vk.com/method/photos.getWallUploadServer?owner_id=${userId}&access_token=${accessToken}&v=5.251` + ) + ).json(); + + const { data } = await axios.get(media.path!, { + responseType: 'stream', + }); + + const slash = media.path.split('/').at(-1); + + const formData = new FormDataNew(); + formData.append('photo', data, { + filename: slash, + contentType: mime.lookup(slash!) || '', + }); + const value = ( + await axios.post(all.response.upload_url, formData, { + headers: { + ...formData.getHeaders(), + }, + }) + ).data; + + if (hasExtension(media.path, 'mp4')) { + return { + id: all.response.video_id, + type: 'video', + }; + } + + const formSend = new FormData(); + formSend.append('photo', value.photo); + formSend.append('server', value.server); + formSend.append('hash', value.hash); + + const { id } = ( + await ( + await fetch( + `https://api.vk.com/method/photos.saveWallPhoto?access_token=${accessToken}&v=5.251`, + { + method: 'POST', + body: formSend, + } + ) + ).json() + ).response[0]; + + return { + id, + type: 'photo', + }; + }) + ); + } + + async post( + userId: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const [firstPost] = postDetails; + + // Upload media for the first post + const mediaList = await this.uploadMedia(userId, accessToken, firstPost); + + const body = new FormData(); + body.append('message', firstPost.message); + + if (mediaList.length) { + body.append( + 'attachments', + mediaList.map((p) => `${p.type}${userId}_${p.id}`).join(',') + ); + } + + const { response } = await ( + await this.fetch( + `https://api.vk.com/method/wall.post?v=5.251&access_token=${accessToken}&client_id=${process.env.VK_ID}`, + { + method: 'POST', + body, + } + ) + ).json(); + + return [ + { + id: firstPost.id, + postId: String(response?.post_id), + releaseURL: `https://vk.com/feed?w=wall${userId}_${response?.post_id}`, + status: 'completed', + }, + ]; + } + + async comment( + userId: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [commentPost] = postDetails; + + // Upload media for the comment + const mediaList = await this.uploadMedia(userId, accessToken, commentPost); + + const body = new FormData(); + body.append('message', commentPost.message); + body.append('post_id', postId); + + if (mediaList.length) { + body.append( + 'attachments', + mediaList.map((p) => `${p.type}${userId}_${p.id}`).join(',') + ); + } + + const { response } = await ( + await this.fetch( + `https://api.vk.com/method/wall.createComment?v=5.251&access_token=${accessToken}&client_id=${process.env.VK_ID}`, + { + method: 'POST', + body, + } + ) + ).json(); + + return [ + { + id: commentPost.id, + postId: String(response?.comment_id), + releaseURL: `https://vk.com/feed?w=wall${userId}_${postId}`, + status: 'completed', + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts b/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..d174b9329bacfe75a3a2447693f855067512d99b --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/whop.provider.ts @@ -0,0 +1,381 @@ +import { createHash, randomBytes } from 'crypto'; +import { + AuthTokenDetails, + MediaContent, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { WhopDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/whop.dto'; +import { Integration } from '@prisma/client'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; + +export class WhopProvider extends SocialAbstract implements SocialProvider { + identifier = 'whop'; + name = 'Whop'; + isBetweenSteps = false; + scopes = ['openid', 'profile', 'email', 'forum:post:create', 'forum:read', 'company:basic:read']; + refreshCron = false; + editor = 'markdown' as const; + dto = WhopDto; + toolTip = 'Schedule posts to forums'; + + maxLength() { + return 50000; + } + + private generateCodeChallenge(codeVerifier: string): string { + return createHash('sha256').update(codeVerifier).digest('base64url'); + } + + override handleErrors( + body: string + ): + | { type: 'refresh-token' | 'bad-body'; value: string } + | undefined { + if (body.includes('invalid_grant')) { + return { + type: 'refresh-token' as const, + value: 'Invalid token, please re-authenticate', + }; + } + + if (body.includes('insufficient_scope')) { + return { + type: 'refresh-token' as const, + value: + 'Insufficient permissions, please re-authenticate with required scopes', + }; + } + + if (body.includes('invalid_request')) { + return { + type: 'bad-body' as const, + value: 'Invalid request parameters', + }; + } + + if (body.includes('not_found')) { + return { + type: 'bad-body' as const, + value: 'Forum or experience not found', + }; + } + + return undefined; + } + + async refreshToken(refreshToken: string): Promise { + const response = await ( + await fetch('https://api.whop.com/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: process.env.WHOP_CLIENT_ID, + }), + }) + ).json(); + + const userInfo = await ( + await fetch('https://api.whop.com/oauth/userinfo', { + headers: { Authorization: `Bearer ${response.access_token}` }, + }) + ).json(); + + return { + id: userInfo.sub, + name: userInfo.name || userInfo.preferred_username || '', + accessToken: response.access_token, + refreshToken: response.refresh_token, + expiresIn: response.expires_in || 3600, + picture: userInfo.picture || '', + username: userInfo.preferred_username || '', + }; + } + + async generateAuthUrl() { + const state = makeId(6); + const codeVerifier = randomBytes(32).toString('base64url'); + const codeChallenge = this.generateCodeChallenge(codeVerifier); + const nonce = makeId(16); + + return { + url: + 'https://api.whop.com/oauth/authorize' + + `?response_type=code` + + `&client_id=${process.env.WHOP_CLIENT_ID}` + + `&redirect_uri=${encodeURIComponent( + `${process.env.FRONTEND_URL}/integrations/social/whop` + )}` + + `&scope=${encodeURIComponent(this.scopes.join(' '))}` + + `&state=${state}` + + `&nonce=${nonce}` + + `&code_challenge=${codeChallenge}` + + `&code_challenge_method=S256`, + codeVerifier, + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const redirectUri = `${process.env.FRONTEND_URL}/integrations/social/whop${ + params.refresh ? `?refresh=${params.refresh}` : '' + }`; + + const tokenResponse = await ( + await fetch('https://api.whop.com/oauth/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + code: params.code, + redirect_uri: redirectUri, + client_id: process.env.WHOP_CLIENT_ID, + code_verifier: params.codeVerifier, + }), + }) + ).json(); + + if (tokenResponse.error) { + return `Authentication failed: ${ + tokenResponse.error_description || tokenResponse.error + }`; + } + + const userInfo = await ( + await fetch('https://api.whop.com/oauth/userinfo', { + headers: { Authorization: `Bearer ${tokenResponse.access_token}` }, + }) + ).json(); + + return { + id: userInfo.sub, + name: userInfo.name || userInfo.preferred_username || '', + accessToken: tokenResponse.access_token, + refreshToken: tokenResponse.refresh_token, + expiresIn: tokenResponse.expires_in || 3600, + picture: userInfo.picture || '', + username: userInfo.preferred_username || '', + }; + } + + @Tool({ description: 'Companies', dataSchema: [] }) + async companies(accessToken: string, params: any, id: string) { + try { + const response = await fetch( + 'https://api.whop.com/api/v1/companies?first=50', + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + const { data } = await response.json(); + + return (data || []).map((company: any) => ({ + id: company.id, + name: company.title, + })); + } catch { + return []; + } + } + + @Tool({ description: 'Experiences', dataSchema: [] }) + async experiences(accessToken: string, params: any, id: string) { + try { + if (!params?.id) return []; + + const response = await fetch( + `https://api.whop.com/api/v1/forums?company_id=${params.id}&first=50`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + } + ); + + const { data } = await response.json(); + + return (data || []).map((forum: any) => ({ + id: forum.experience?.id || forum.id, + name: forum.experience?.name || forum.id, + })); + } catch { + return []; + } + } + + private async uploadMediaToWhop( + media: MediaContent[], + accessToken: string + ): Promise<{ id: string }[]> { + if (!media || media.length === 0) return []; + + const attachments: { id: string }[] = []; + + for (const item of media) { + const fileResponse = await fetch(item.path); + const fileBuffer = await fileResponse.arrayBuffer(); + const fileName = item.path.split('/').pop() || 'file'; + + const createFileResponse = await ( + await this.fetch( + 'https://api.whop.com/api/v1/files', + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + filename: fileName, + }), + }, + 'create file record' + ) + ).json(); + + if (createFileResponse.upload_url) { + await fetch(createFileResponse.upload_url, { + method: 'PUT', + headers: createFileResponse.upload_headers || {}, + body: fileBuffer, + }); + + let uploadStatus = 'pending'; + let attempts = 0; + const maxAttempts = 108; // ~9 minutes at 5s interval + while (uploadStatus !== 'ready') { + if (attempts++ >= maxAttempts) { + throw new Error('File upload timed out'); + } + + const fileStatus = await ( + await this.fetch( + `https://api.whop.com/api/v1/files/${createFileResponse.id}`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + 'check file status', + 0, + true + ) + ).json(); + uploadStatus = fileStatus.upload_status; + if (uploadStatus === 'failed') { + throw new Error('File upload failed'); + } + if (uploadStatus !== 'ready') { + await timer(5000); + } + } + } + + attachments.push({ id: createFileResponse.id }); + } + + return attachments; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [post] = postDetails; + + const attachments = await this.uploadMediaToWhop( + post.media || [], + accessToken + ); + + const data = await ( + await this.fetch( + 'https://api.whop.com/api/v1/forum_posts', + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + experience_id: post.settings.experience, + content: post.message, + ...(post.settings.title ? { title: post.settings.title } : {}), + ...(attachments.length ? { attachments } : {}), + }), + }, + 'create forum post' + ) + ).json(); + + return [ + { + id: post.id, + postId: data.id, + releaseURL: `https://whop.com/experiences/${post.settings.experience}/${data.id}`, + status: 'success', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const [post] = postDetails; + const replyToId = lastCommentId || postId; + + const attachments = await this.uploadMediaToWhop( + post.media || [], + accessToken + ); + + const data = await ( + await this.fetch( + 'https://api.whop.com/api/v1/forum_posts', + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + experience_id: post.settings.experience, + content: post.message, + parent_id: replyToId, + ...(attachments.length ? { attachments } : {}), + }), + }, + 'create comment' + ) + ).json(); + + return [ + { + id: post.id, + postId: data.id, + releaseURL: `https://whop.com/experiences/${post.settings.experience}/${postId}`, + status: 'success', + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..161f598bb1907423b0f0383a653a3bcaebae24ee --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/wordpress.provider.ts @@ -0,0 +1,366 @@ +import { + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import dayjs from 'dayjs'; +import { Integration } from '@prisma/client'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { WordpressDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/wordpress.dto'; +import slugify from 'slugify'; +// import FormData from 'form-data'; +import axios from 'axios'; +import { Tool } from '@gitroom/nestjs-libraries/integrations/tool.decorator'; +import { getSsrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import { string } from 'yup'; + +export class WordpressProvider + extends SocialAbstract + implements SocialProvider +{ + identifier = 'wordpress'; + name = 'WordPress'; + isBetweenSteps = false; + editor = 'html' as const; + scopes = [] as string[]; + override maxConcurrentJob = 5; // WordPress self-hosted typically has generous limits + dto = WordpressDto; + maxLength() { + return 100000; + } + + async generateAuthUrl() { + const state = makeId(6); + return { + url: state, + codeVerifier: makeId(10), + state, + }; + } + + async refreshToken(refreshToken: string): Promise { + return { + refreshToken: '', + expiresIn: 0, + accessToken: '', + id: '', + name: '', + picture: '', + username: '', + }; + } + override handleErrors( + body: string + ): + | { type: 'refresh-token' | 'bad-body' | 'retry'; value: string } + | undefined { + if (body.indexOf('rest_cannot_create') > -1) { + return { + type: 'bad-body', + value: 'The connect user has insufficient permissions to create posts', + }; + } + return undefined; + } + + async customFields() { + return [ + { + key: 'domain', + label: 'Domain URL', + validation: `/^https?:\\/\\/(?:www\\.)?[\\w\\-]+(\\.[\\w\\-]+)+([\\/?#][^\\s]*)?$/`, + type: 'text' as const, + }, + { + key: 'username', + label: 'Username', + validation: `/.+/`, + type: 'text' as const, + }, + { + key: 'password', + label: 'Password', + validation: `/.+/`, + type: 'password' as const, + hint: 'Application password, create in User->Profile', + }, + ]; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const body = JSON.parse(Buffer.from(params.code, 'base64').toString()) as { + domain: string; + username: string; + password: string; + }; + + // Normalize the domain - users often paste it with surrounding whitespace + // or a trailing slash, which would otherwise build `https://site.com//wp-json/...`. + const domain = body.domain.trim().replace(/\/+$/, ''); + + const auth = Buffer.from(`${body.username}:${body.password}`).toString( + 'base64' + ); + + // Direct fetch (not `this.fetch`) so we can branch on the HTTP status and + // return a specific message instead of throwing a generic error. + let response: Response; + try { + response = await fetch(`${domain}/wp-json/wp/v2/users/me`, { + headers: { + Authorization: `Basic ${auth}`, + }, + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + }); + } catch (err) { + // DNS failure, connection refused, TLS error, site unreachable, etc. + console.log(err); + return 'Could not reach your WordPress site. Check the Domain URL and that the site is publicly accessible.'; + } + + // A security plugin (e.g. Wordfence), a WAF, or the server config commonly + // strips the Authorization header or locks down the REST API. We don't try + // to work around that - surface a distinct, actionable message instead. + if (!response.ok) { + // Log what WordPress actually returned (REST errors carry a `code` and + // `message`) so failures can be diagnosed without guessing. + const errorBody = await response.text().catch(() => ''); + let wpCode = ''; + let wpMessage = ''; + try { + const parsed = JSON.parse(errorBody); + wpCode = parsed?.code || ''; + wpMessage = parsed?.message || ''; + } catch (err) { + // Non-JSON error body (e.g. an HTML page from a security plugin). + } + console.log( + `WordPress auth failed for ${domain} (HTTP ${response.status})`, + JSON.stringify({ + code: wpCode, + message: wpMessage, + ...(wpCode ? {} : { body: errorBody.slice(0, 500) }), + }) + ); + + if (response.status === 401 || response.status === 403) { + return 'WordPress rejected the login. A security plugin or server setting may be blocking the REST API or stripping the Authorization header, or the username / Application Password is incorrect.'; + } + + return `WordPress returned an unexpected error (HTTP ${response.status}). Make sure the REST API is enabled and Application Passwords are available.`; + } + + // Even on a 200, a security plugin / maintenance page can return HTML + // instead of JSON, which would otherwise throw on `.json()`. + let data: any; + try { + data = await response.json(); + } catch (err) { + console.log(err); + return 'WordPress did not return a valid response. The REST API may be disabled or blocked by a security plugin.'; + } + + const { id, name, avatar_urls, code } = data || {}; + + if (code) { + return 'Invalid credentials'; + } + + const biggestImage = Object.entries(avatar_urls || {}).reduce( + (all, current) => { + if (all > Number(current[0])) { + return all; + } + return Number(current[0]); + }, + 0 + ); + + return { + refreshToken: '', + expiresIn: dayjs().add(100, 'years').unix() - dayjs().unix(), + accessToken: params.code, + id: body.domain + '_' + id, + name, + picture: avatar_urls?.[String(biggestImage)] || '', + username: body.username, + }; + } + + // Custom provider functions below are invoked from the backend HTTP endpoint + // (`/integrations/function`) - which is NOT a Temporal activity - so they must + // use a plain `fetch` (with the SSRF guard) rather than `this.fetch`, which + // calls `Context.current()` and throws outside an activity. This mirrors how + // `authenticate` issues its request. + private async wpGet(token: string, path: string) { + const body = JSON.parse(Buffer.from(token, 'base64').toString()) as { + domain: string; + username: string; + password: string; + }; + + const auth = Buffer.from(`${body.username}:${body.password}`).toString( + 'base64' + ); + + const response = await fetch(`${body.domain}${path}`, { + headers: { + Authorization: `Basic ${auth}`, + }, + // @ts-ignore - undici-only option; blocks SSRF to internal IPs + dispatcher: getSsrfSafeDispatcher(), + }); + + return response.json(); + } + + @Tool({ + description: 'Get list of post types', + dataSchema: [], + }) + async postTypes(token: string) { + const postTypes = await this.wpGet(token, '/wp-json/wp/v2/types'); + + return Object.entries(postTypes).reduce((all, [key, value]) => { + if ( + key.indexOf('wp_') > -1 || + key.indexOf('nav_') > -1 || + key === 'attachment' + ) { + return all; + } + + all.push({ + id: value.rest_base, + name: value.name, + }); + + return all; + }, []); + } + + @Tool({ + description: 'Get list of categories', + dataSchema: [], + }) + async categoriesList(token: string) { + const categories = await this.wpGet( + token, + '/wp-json/wp/v2/categories?per_page=100' + ); + + return (Array.isArray(categories) ? categories : []).map( + (category: any) => ({ + id: category.id, + name: category.name, + }) + ); + } + + @Tool({ + description: 'Get list of tags', + dataSchema: [], + }) + async tagsList(token: string) { + const tags = await this.wpGet(token, '/wp-json/wp/v2/tags?per_page=100'); + + return (Array.isArray(tags) ? tags : []).map((tag: any) => ({ + id: tag.id, + name: tag.name, + })); + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[], + integration: Integration + ): Promise { + const body = JSON.parse(Buffer.from(accessToken, 'base64').toString()) as { + domain: string; + username: string; + password: string; + }; + + const auth = Buffer.from(`${body.username}:${body.password}`).toString( + 'base64' + ); + + let mediaId = ''; + if (postDetails?.[0]?.settings?.main_image?.path) { + console.log( + 'Uploading image to WordPress', + postDetails[0].settings.main_image.path + ); + + const blob = await this.fetch( + postDetails[0].settings.main_image.path + ).then((r) => r.blob()); + + const mediaResponse = await ( + await this.fetch(`${body.domain}/wp-json/wp/v2/media`, { + method: 'POST', + headers: { + Authorization: `Basic ${auth}`, + 'Content-Disposition': `attachment; filename="${postDetails[0].settings.main_image.path + .split('/') + .pop()}"`, + 'Content-Type': blob.type, + }, + body: blob, + }) + ).json(); + + mediaId = mediaResponse.id; + } + + const categories = (postDetails?.[0]?.settings?.categories || []) + .map((category) => Number(category)) + .filter((category) => !isNaN(category)); + const tags = (postDetails?.[0]?.settings?.tags || []) + .map((tag) => Number(tag)) + .filter((tag) => !isNaN(tag)); + + const submit = await ( + await this.fetch( + `${body.domain}/wp-json/wp/v2/${postDetails?.[0]?.settings?.type}`, + { + headers: { + Authorization: `Basic ${auth}`, + 'Content-Type': 'application/json', + }, + method: 'POST', + body: JSON.stringify({ + title: postDetails?.[0]?.settings?.title, + content: postDetails?.[0]?.message, + slug: slugify(postDetails?.[0]?.settings?.title, { + lower: true, + strict: true, + trim: true, + }), + status: postDetails?.[0]?.settings?.status || 'publish', + ...(categories.length ? { categories } : {}), + ...(tags.length ? { tags } : {}), + ...(mediaId ? { featured_media: mediaId } : {}), + }), + } + ) + ).json(); + + return [ + { + id: postDetails?.[0].id, + status: 'completed', + postId: String(submit.id), + releaseURL: submit.link, + }, + ]; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/x.provider.ts b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..22a85f73d4e0f456d6b2e7877bc5c05a34097898 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/x.provider.ts @@ -0,0 +1,855 @@ +import { TweetV2, TwitterApi } from 'twitter-api-v2'; +import { createHmac, randomBytes } from 'crypto'; +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { lookup } from 'mime-types'; +import sharp from 'sharp'; +import { readOrFetch } from '@gitroom/helpers/utils/read.or.fetch'; +import { SocialAbstract } from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import { Plug } from '@gitroom/helpers/decorators/plug.decorator'; +import { Integration } from '@prisma/client'; +import { timer } from '@gitroom/helpers/utils/timer'; +import { PostPlug } from '@gitroom/helpers/decorators/post.plug'; +import dayjs from 'dayjs'; +import { uniqBy } from 'lodash'; +import { stripHtmlValidation } from '@gitroom/helpers/utils/strip.html.validation'; +import { stripLinks as removeLinks } from '@gitroom/helpers/utils/strip.links'; +import { XDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/x.dto'; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; +import { hasExtension } from '@gitroom/helpers/utils/has.extension'; + +@Rules( + `X can have maximum 4 pictures, or maximum one video, it can also be without attachments ${ + process.env.STRIP_LINKS_FROM_X_POSTS + ? 'do not add links, they will be stripped from the post' + : '' + }` +) +export class XProvider extends SocialAbstract implements SocialProvider { + identifier = 'x'; + name = 'X'; + isBetweenSteps = false; + scopes = [] as string[]; + stripLinks = () => !!process.env.STRIP_LINKS_FROM_X_POSTS; + override maxConcurrentJob = 1; // X has strict rate limits (300 posts per 3 hours) + toolTip = + 'You will be logged in into your current account, if you would like a different account, change it first on X'; + + editor = 'normal' as const; + dto = XDto; + + maxLength(additionalSettings?: any) { + // Accepts either the parsed additionalSettings array (from validation) or a + // plain boolean (legacy callers). "Verified" => premium => higher limit. + const isTwitterPremium = Array.isArray(additionalSettings) + ? !!additionalSettings.find((p: any) => p?.title === 'Verified')?.value + : !!additionalSettings; + return isTwitterPremium ? 4000 : 280; + } + + override handleErrors(body: string): + | { + type: 'refresh-token' | 'bad-body' | 'retry'; + value: string; + } + | undefined { + if (body.includes('You are not permitted to perform this action')) { + return { + type: 'bad-body', + value: + 'There is a problem posting, please edit your post and check character count and media attachments', + }; + } + if (body.includes('Service Unavailable')) { + return { + type: 'retry', + value: 'X is currently unavailable, please try again later', + }; + } + if (body.includes('maximum of one cashtag')) { + return { + type: 'bad-body', + value: 'There can be maximum of one cashtag ($SYMBOL) per post', + }; + } + if (body.includes('maximum of 4 items')) { + return { + type: 'bad-body', + value: 'There must be a maximum of 4 items per post', + }; + } + if (body.includes('Unsupported Authentication')) { + return { + type: 'refresh-token', + value: 'X authentication has expired, please reconnect your account', + }; + } + + if (body.includes('You are not allowed to create a Tweet')) { + return { + type: 'bad-body', + value: 'You are not allowed to create a post with duplicate content', + } + } + + if (body.includes('usage-capped')) { + return { + type: 'bad-body', + value: 'Posting failed - capped reached. Please try again later', + }; + } + + if (body.includes('user-suspended')) { + return { + type: 'bad-body', + value: + 'Your X account has been suspended, please reconnect with another account', + }; + } + if (body.includes('duplicate-rules')) { + return { + type: 'bad-body', + value: + 'You have already posted this post, please wait before posting again', + }; + } + if (body.includes('Your account is not permitted to access this feature')) { + return { + type: 'bad-body', + value: + 'X blocked your request', + }; + } + if (body.includes('The Tweet contains an invalid URL.')) { + return { + type: 'bad-body', + value: 'The Tweet contains a URL that is not allowed on X', + }; + } + if ( + body.includes( + 'This user is not allowed to post a video longer than 2 minutes' + ) + ) { + return { + type: 'bad-body', + value: + 'The video you are trying to post is longer than 2 minutes, which is not allowed for this account', + }; + } + return undefined; + } + + @Plug({ + identifier: 'x-autoRepostPost', + title: 'Auto Repost Posts', + disabled: !!process.env.DISABLE_X_ANALYTICS, + description: + 'When a post reached a certain number of likes, repost it to increase engagement (1 week old posts)', + runEveryMilliseconds: 21600000, + totalRuns: 3, + fields: [ + { + name: 'likesAmount', + type: 'number', + placeholder: 'Amount of likes', + description: 'The amount of likes to trigger the repost', + validation: /^\d+$/, + }, + ], + }) + async autoRepostPost( + integration: Integration, + id: string, + fields: { likesAmount: string } + ) { + // @ts-ignore + // eslint-disable-next-line prefer-rest-params + const [accessTokenSplit, accessSecretSplit] = integration.token.split(':'); + const client = new TwitterApi({ + appKey: process.env.X_API_KEY!, + appSecret: process.env.X_API_SECRET!, + accessToken: accessTokenSplit, + accessSecret: accessSecretSplit, + }); + + if ( + (await client.v2.tweetLikedBy(id)).meta.result_count >= + +fields.likesAmount + ) { + await timer(2000); + await client.v2.retweet(integration.internalId, id); + return true; + } + + return false; + } + + @PostPlug({ + identifier: 'x-repost-post-users', + title: 'Add Re-posters', + description: 'Add accounts to repost your post', + pickIntegration: ['x'], + fields: [], + }) + async repostPostUsers( + integration: Integration, + originalIntegration: Integration, + postId: string, + information: any + ) { + const [accessTokenSplit, accessSecretSplit] = integration.token.split(':'); + const client = new TwitterApi({ + appKey: process.env.X_API_KEY!, + appSecret: process.env.X_API_SECRET!, + accessToken: accessTokenSplit, + accessSecret: accessSecretSplit, + }); + + const { + data: { id }, + } = await client.v2.me(); + + try { + await client.v2.retweet(id, postId); + } catch (err) { + /** nothing **/ + } + } + + @Plug({ + identifier: 'x-autoPlugPost', + title: 'Auto plug post', + disabled: !!process.env.DISABLE_X_ANALYTICS, + description: + 'When a post reached a certain number of likes, add another post to it so you followers get a notification about your promotion', + runEveryMilliseconds: 21600000, + totalRuns: 3, + fields: [ + { + name: 'likesAmount', + type: 'number', + placeholder: 'Amount of likes', + description: 'The amount of likes to trigger the repost', + validation: /^\d+$/, + }, + { + name: 'post', + type: 'richtext', + placeholder: 'Post to plug', + description: 'Message content to plug', + validation: /^[\s\S]{3,}$/g, + }, + ], + }) + async autoPlugPost( + integration: Integration, + id: string, + fields: { likesAmount: string; post: string } + ) { + // @ts-ignore + // eslint-disable-next-line prefer-rest-params + const [accessTokenSplit, accessSecretSplit] = integration.token.split(':'); + const client = new TwitterApi({ + appKey: process.env.X_API_KEY!, + appSecret: process.env.X_API_SECRET!, + accessToken: accessTokenSplit, + accessSecret: accessSecretSplit, + }); + + if ( + (await client.v2.tweetLikedBy(id)).meta.result_count >= + +fields.likesAmount + ) { + await timer(2000); + + const plugText = stripHtmlValidation('normal', fields.post, true); + await client.v2.tweet({ + text: this.stripLinks() ? removeLinks(plugText) : plugText, + reply: { in_reply_to_tweet_id: id }, + }); + return true; + } + + return false; + } + + async refreshToken(): Promise { + return { + id: '', + name: '', + accessToken: '', + refreshToken: '', + expiresIn: 0, + picture: '', + username: '', + }; + } + + async generateAuthUrl() { + const client = new TwitterApi({ + appKey: process.env.X_API_KEY!, + appSecret: process.env.X_API_SECRET!, + }); + const { url, oauth_token, oauth_token_secret } = + await client.generateAuthLink( + (process.env.X_URL || process.env.FRONTEND_URL) + + `/integrations/social/x`, + { + authAccessType: 'write', + linkMode: 'authenticate', + forceLogin: false, + } + ); + return { + url, + codeVerifier: oauth_token + ':' + oauth_token_secret, + state: oauth_token, + }; + } + + async authenticate(params: { code: string; codeVerifier: string }) { + const { code, codeVerifier } = params; + const [oauth_token, oauth_token_secret] = codeVerifier.split(':'); + + const startingClient = new TwitterApi({ + appKey: process.env.X_API_KEY!, + appSecret: process.env.X_API_SECRET!, + accessToken: oauth_token, + accessSecret: oauth_token_secret, + }); + + const { accessToken, client, accessSecret } = await startingClient.login( + code + ); + + const { + data: { username, verified, profile_image_url, name, id }, + } = await client.v2.me({ + 'user.fields': [ + 'username', + 'verified', + 'verified_type', + 'profile_image_url', + 'name', + ], + }); + + return { + id: String(id), + accessToken: accessToken + ':' + accessSecret, + name, + refreshToken: '', + expiresIn: 999999999, + picture: profile_image_url || '', + username, + additionalSettings: [ + { + title: 'Verified', + description: 'Is this a verified user? (Premium)', + type: 'checkbox' as const, + value: verified, + }, + ], + }; + } + + private async getClient(accessToken: string) { + const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); + return new TwitterApi({ + appKey: process.env.X_API_KEY!, + appSecret: process.env.X_API_SECRET!, + accessToken: accessTokenSplit, + accessSecret: accessSecretSplit, + }); + } + + private signOAuth1( + method: string, + url: string, + accessToken: string, + accessSecret: string + ): string { + const pct = (s: string) => + encodeURIComponent(s) + .replace(/!/g, '%21') + .replace(/\*/g, '%2A') + .replace(/'/g, '%27') + .replace(/\(/g, '%28') + .replace(/\)/g, '%29'); + + const params: Record = { + oauth_consumer_key: process.env.X_API_KEY!, + oauth_nonce: randomBytes(16).toString('hex'), + oauth_signature_method: 'HMAC-SHA1', + oauth_timestamp: String(Math.floor(Date.now() / 1000)), + oauth_token: accessToken, + oauth_version: '1.0', + }; + + const paramString = Object.keys(params) + .sort() + .map((k) => `${pct(k)}=${pct(params[k])}`) + .join('&'); + + const baseString = [ + method.toUpperCase(), + pct(url.split('?')[0]), + pct(paramString), + ].join('&'); + + const signingKey = `${pct(process.env.X_API_SECRET!)}&${pct(accessSecret)}`; + params.oauth_signature = createHmac('sha1', signingKey) + .update(baseString) + .digest('base64'); + + return ( + 'OAuth ' + + Object.keys(params) + .sort() + .map((k) => `${pct(k)}="${pct(params[k])}"`) + .join(', ') + ); + } + + private async uploadMedia( + client: TwitterApi, + postDetails: PostDetails[] + ) { + return ( + await Promise.all( + postDetails.flatMap((p) => + p?.media?.flatMap(async (m) => { + return { + id: await this.runInConcurrent( + async () => + client.v2.uploadMedia( + hasExtension(m.path, 'mp4') + ? Buffer.from(await readOrFetch(m.path)) + : await sharp(await readOrFetch(m.path), { + animated: lookup(m.path) === 'image/gif', + }) + .resize({ + width: 1000, + }) + .gif() + .toBuffer(), + { + media_type: (lookup(m.path) || '') as any, + } + ), + true + ), + postId: p.id, + }; + }) + ) + ) + ).reduce((acc, val) => { + if (!val?.id) { + return acc; + } + + acc[val.postId] = acc[val.postId] || []; + acc[val.postId].push(val.id); + + return acc; + }, {} as Record); + } + + 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'; + made_with_ai?: boolean; + paid_partnership?: boolean; + }>[], + integration: Integration + ): Promise { + const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); + const client = await this.getClient(accessToken); + + const [firstPost] = postDetails; + + // upload media for the first post + const uploadAll = await this.uploadMedia(client, [firstPost]); + + const media_ids = (uploadAll[firstPost.id] || []).filter((f) => f); + + const tweetUrl = 'https://api.x.com/2/tweets'; + const tweetBody = { + ...(!firstPost?.settings?.who_can_reply_post || + firstPost?.settings?.who_can_reply_post === 'everyone' + ? {} + : { + reply_settings: firstPost?.settings?.who_can_reply_post, + }), + ...(firstPost?.settings?.community + ? { + share_with_followers: true, + community_id: + firstPost?.settings?.community?.split('/').pop() || '', + } + : {}), + text: this.stripLinks() + ? removeLinks(firstPost.message) + : firstPost.message, + ...(media_ids.length ? { media: { media_ids } } : {}), + made_with_ai: this.assetBoolean(firstPost?.settings?.made_with_ai), + paid_partnership: this.assetBoolean(firstPost?.settings?.paid_partnership), + }; + + const tweetResponse = await this.fetch(tweetUrl, { + method: 'POST', + headers: { + Authorization: this.signOAuth1( + 'POST', + tweetUrl, + accessTokenSplit, + accessSecretSplit + ), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(tweetBody), + }); + const { data } = (await tweetResponse.json()) as { + data: { id: string }; + }; + + return [ + { + postId: data.id, + id: firstPost.id, + releaseURL: `https://twitter.com/${integration.profile}/status/${data.id}`, + status: 'posted', + }, + ]; + } + + async comment( + id: string, + postId: string, + lastCommentId: string | undefined, + accessToken: string, + postDetails: PostDetails<{ + active_thread_finisher: boolean; + thread_finisher: string; + made_with_ai?: boolean; + paid_partnership?: boolean; + }>[], + integration: Integration + ): Promise { + const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); + const client = await this.getClient(accessToken); + 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; + + const tweetUrl = 'https://api.x.com/2/tweets'; + const tweetBody = { + text: this.stripLinks() + ? removeLinks(commentPost.message) + : commentPost.message, + ...(media_ids.length ? { media: { media_ids } } : {}), + reply: { in_reply_to_tweet_id: replyToId }, + made_with_ai: this.assetBoolean(commentPost?.settings?.made_with_ai), + paid_partnership: this.assetBoolean( + commentPost?.settings?.paid_partnership + ), + }; + + const tweetResponse = await this.fetch(tweetUrl, { + method: 'POST', + headers: { + Authorization: this.signOAuth1( + 'POST', + tweetUrl, + accessTokenSplit, + accessSecretSplit + ), + 'Content-Type': 'application/json', + }, + body: JSON.stringify(tweetBody), + }); + const { data } = (await tweetResponse.json()) as { + data: { id: string }; + }; + + return [ + { + postId: data.id, + id: commentPost.id, + releaseURL: `https://twitter.com/${integration.profile}/status/${data.id}`, + status: 'posted', + }, + ]; + } + + private loadAllTweets = async ( + client: TwitterApi, + id: string, + until: string, + since: string, + token = '' + ): Promise => { + const tweets = await client.v2.userTimeline(id, { + 'tweet.fields': ['id'], + 'user.fields': [], + 'poll.fields': [], + 'place.fields': [], + 'media.fields': [], + exclude: ['replies', 'retweets'], + start_time: since, + end_time: until, + max_results: 100, + ...(token ? { pagination_token: token } : {}), + }); + + return [ + ...tweets.data.data, + ...(tweets.data.data.length === 100 + ? await this.loadAllTweets( + client, + id, + until, + since, + tweets.meta.next_token + ) + : []), + ]; + }; + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + if (process.env.DISABLE_X_ANALYTICS) { + return []; + } + + const until = dayjs().endOf('day'); + const since = dayjs().subtract(date > 100 ? 100 : date, 'day'); + + const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); + const client = new TwitterApi({ + appKey: process.env.X_API_KEY!, + appSecret: process.env.X_API_SECRET!, + accessToken: accessTokenSplit, + accessSecret: accessSecretSplit, + }); + + try { + const tweets = uniqBy( + await this.loadAllTweets( + client, + id, + until.format('YYYY-MM-DDTHH:mm:ssZ'), + since.format('YYYY-MM-DDTHH:mm:ssZ') + ), + (p) => p.id + ); + + if (tweets.length === 0) { + return []; + } + + const data = await client.v2.tweets( + tweets.map((p) => p.id), + { + 'tweet.fields': ['public_metrics'], + } + ); + + const metrics = data.data.reduce( + (all, current) => { + all.impression_count = + (all.impression_count || 0) + + +current.public_metrics.impression_count; + all.bookmark_count = + (all.bookmark_count || 0) + +current.public_metrics.bookmark_count; + all.like_count = + (all.like_count || 0) + +current.public_metrics.like_count; + all.quote_count = + (all.quote_count || 0) + +current.public_metrics.quote_count; + all.reply_count = + (all.reply_count || 0) + +current.public_metrics.reply_count; + all.retweet_count = + (all.retweet_count || 0) + +current.public_metrics.retweet_count; + + return all; + }, + { + impression_count: 0, + bookmark_count: 0, + like_count: 0, + quote_count: 0, + reply_count: 0, + retweet_count: 0, + } + ); + + return Object.entries(metrics).map(([key, value]) => ({ + label: key.replace('_count', '').replace('_', ' ').toUpperCase(), + percentageChange: 5, + data: [ + { + total: String(0), + date: since.format('YYYY-MM-DD'), + }, + { + total: String(value), + date: until.format('YYYY-MM-DD'), + }, + ], + })); + } catch (err) { + console.log(err); + } + return []; + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + if (process.env.DISABLE_X_ANALYTICS) { + return []; + } + + const today = dayjs().format('YYYY-MM-DD'); + + const [accessTokenSplit, accessSecretSplit] = accessToken.split(':'); + const client = new TwitterApi({ + appKey: process.env.X_API_KEY!, + appSecret: process.env.X_API_SECRET!, + accessToken: accessTokenSplit, + accessSecret: accessSecretSplit, + }); + + try { + // Fetch the specific tweet with public metrics + const tweet = await client.v2.singleTweet(postId, { + 'tweet.fields': ['public_metrics', 'created_at'], + }); + + if (!tweet?.data?.public_metrics) { + return []; + } + + const metrics = tweet.data.public_metrics; + + const result: AnalyticsData[] = []; + + if (metrics.impression_count !== undefined) { + result.push({ + label: 'Impressions', + percentageChange: 0, + data: [{ total: String(metrics.impression_count), date: today }], + }); + } + + if (metrics.like_count !== undefined) { + result.push({ + label: 'Likes', + percentageChange: 0, + data: [{ total: String(metrics.like_count), date: today }], + }); + } + + if (metrics.retweet_count !== undefined) { + result.push({ + label: 'Retweets', + percentageChange: 0, + data: [{ total: String(metrics.retweet_count), date: today }], + }); + } + + if (metrics.reply_count !== undefined) { + result.push({ + label: 'Replies', + percentageChange: 0, + data: [{ total: String(metrics.reply_count), date: today }], + }); + } + + if (metrics.quote_count !== undefined) { + result.push({ + label: 'Quotes', + percentageChange: 0, + data: [{ total: String(metrics.quote_count), date: today }], + }); + } + + if (metrics.bookmark_count !== undefined) { + result.push({ + label: 'Bookmarks', + percentageChange: 0, + data: [{ total: String(metrics.bookmark_count), date: today }], + }); + } + + return result; + } catch (err) { + console.log('Error fetching X post analytics:', err); + } + + return []; + } + + override async mention(token: string, d: { query: string }) { + const [accessTokenSplit, accessSecretSplit] = token.split(':'); + const client = new TwitterApi({ + appKey: process.env.X_API_KEY!, + appSecret: process.env.X_API_SECRET!, + accessToken: accessTokenSplit, + accessSecret: accessSecretSplit, + }); + + try { + const data = await client.v2.userByUsername(d.query, { + 'user.fields': ['username', 'name', 'profile_image_url'], + }); + + if (!data?.data?.username) { + return []; + } + + return [ + { + id: data.data.username, + image: data.data.profile_image_url, + label: data.data.name, + }, + ]; + } catch (err) { + console.log(err); + } + return []; + } + + mentionFormat(idOrHandle: string, name: string) { + return `@${idOrHandle}`; + } +} diff --git a/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..d73fb22839fc1298f3fceaa6c906b722e485885b --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/social/youtube.provider.ts @@ -0,0 +1,640 @@ +import { + AnalyticsData, + AuthTokenDetails, + PostDetails, + PostResponse, + SocialProvider, +} from '@gitroom/nestjs-libraries/integrations/social/social.integrations.interface'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { google, youtube_v3 } from 'googleapis'; +import { OAuth2Client } from 'google-auth-library/build/src/auth/oauth2client'; +import axios from 'axios'; +import { YoutubeSettingsDto } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/youtube.settings.dto'; +import { + BadBody, + SocialAbstract, + ValidityMedia, +} from '@gitroom/nestjs-libraries/integrations/social.abstract'; +import * as process from 'node:process'; +import dayjs from 'dayjs'; +import { GaxiosResponse } from 'gaxios/build/src/common'; +import Schema$Video = youtube_v3.Schema$Video; +import { Rules } from '@gitroom/nestjs-libraries/chat/rules.description.decorator'; + +const clientAndYoutube = () => { + const client = new google.auth.OAuth2({ + clientId: process.env.YOUTUBE_CLIENT_ID, + clientSecret: process.env.YOUTUBE_CLIENT_SECRET, + redirectUri: `${process.env.FRONTEND_URL}/integrations/social/youtube`, + }); + + const youtube = (newClient: OAuth2Client) => + google.youtube({ + version: 'v3', + auth: newClient, + }); + + const youtubeAnalytics = (newClient: OAuth2Client) => + google.youtubeAnalytics({ + version: 'v2', + auth: newClient, + }); + + const oauth2 = (newClient: OAuth2Client) => + google.oauth2({ + version: 'v2', + auth: newClient, + }); + + return { client, youtube, oauth2, youtubeAnalytics }; +}; + +@Rules('YouTube must have on video attachment, it cannot be empty') +export class YoutubeProvider extends SocialAbstract implements SocialProvider { + override maxConcurrentJob = 200; // YouTube has strict upload quotas + identifier = 'youtube'; + name = 'YouTube'; + isBetweenSteps = true; + dto = YoutubeSettingsDto; + scopes = [ + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/youtube', + 'https://www.googleapis.com/auth/youtube.force-ssl', + 'https://www.googleapis.com/auth/youtube.readonly', + 'https://www.googleapis.com/auth/youtube.upload', + 'https://www.googleapis.com/auth/youtubepartner', + 'https://www.googleapis.com/auth/yt-analytics.readonly', + ]; + + editor = 'normal' as const; + maxLength() { + return 5000; + } + + override async checkValidity( + items: Array + ): Promise { + const [firstItems] = items ?? []; + if (items?.[0]?.length !== 1) { + return 'You need one media'; + } + if ((firstItems?.[0]?.path?.indexOf?.('mp4') ?? -1) === -1) { + return 'Item must be a video'; + } + return true; + } + + override handleErrors(body: string): + | { + type: 'refresh-token' | 'bad-body'; + value: string; + } + | undefined { + if (body.includes('invalidTags')) { + return { + type: 'bad-body', + value: + 'The maximum allowed is 500 characters in total.', + }; + } + + if (body.includes('invalidTitle')) { + return { + type: 'bad-body', + value: + 'We have uploaded your video but we could not set the title. Title is too long.', + }; + } + + if (body.includes('invalidDescription')) { + return { + type: 'bad-body', + value: + 'Your video description is invalid, it may contain disallowed characters such as < or >.', + }; + } + + if (body.includes('invalidCategoryId')) { + return { + type: 'bad-body', + value: 'The selected video category is invalid.', + }; + } + + if (body.includes('invalidPublishAt')) { + return { + type: 'bad-body', + value: 'The scheduled publishing time is invalid.', + }; + } + + if (body.includes('invalidRecordingDetails')) { + return { + type: 'bad-body', + value: 'The recording details for the video are invalid.', + }; + } + + if (body.includes('invalidVideoGameRating')) { + return { + type: 'bad-body', + value: 'The video game rating is invalid.', + }; + } + + if (body.includes('invalidFilename')) { + return { + type: 'bad-body', + value: 'The video file name is invalid.', + }; + } + + if (body.includes('defaultLanguageNotSet')) { + return { + type: 'bad-body', + value: + 'We could not set the localized video details because no default language is set.', + }; + } + + if (body.includes('invalidVideoMetadata')) { + return { + type: 'bad-body', + value: + 'Some of the video details are invalid, please review the title, description and tags.', + }; + } + + if (body.includes('mediaBodyRequired')) { + return { + type: 'bad-body', + value: + 'The video file is missing or could not be read, please re-upload the video.', + }; + } + + if (body.includes('imageFormatUnsupported')) { + return { + type: 'bad-body', + value: + 'We have uploaded your video but the thumbnail format is not supported, please use JPEG or PNG.', + }; + } + + if (body.includes('imageTooTall')) { + return { + type: 'bad-body', + value: + 'We have uploaded your video but the thumbnail image is too tall.', + }; + } + + if (body.includes('imageTooWide')) { + return { + type: 'bad-body', + value: + 'We have uploaded your video but the thumbnail image is too wide.', + }; + } + + if (body.includes('rateLimitExceeded')) { + return { + type: 'bad-body', + value: + 'You are sending requests too quickly, please wait a little while and try again.', + }; + } + + if (body.includes('failedPrecondition')) { + return { + type: 'bad-body', + value: + 'We have uploaded your video but we could not set the thumbnail. Thumbnail size is too large.', + }; + } + + if (body.includes('uploadLimitExceeded')) { + return { + type: 'bad-body', + value: + 'You have reached your daily upload limit, please try again tomorrow.', + }; + } + + if (body.includes('youtubeSignupRequired')) { + return { + type: 'bad-body', + value: + 'You have to link your youtube account to your google account first.', + }; + } + + if (body.includes('youtube.thumbnail')) { + return { + type: 'bad-body', + value: + 'Your account is not verified, we have uploaded your video but we could not set the thumbnail. Please verify your account and try again.', + }; + } + + if (body.includes('Unauthorized')) { + return { + type: 'refresh-token', + value: + 'Token expired or invalid, please reconnect your YouTube account.', + }; + } + + if (body.includes('UNAUTHENTICATED') || body.includes('invalid_grant')) { + return { + type: 'refresh-token', + value: 'Please re-authenticate your YouTube account', + }; + } + + return undefined; + } + + async refreshToken(refresh_token: string): Promise { + const { client, oauth2 } = clientAndYoutube(); + client.setCredentials({ refresh_token }); + const { credentials } = await client.refreshAccessToken(); + const user = oauth2(client); + const expiryDate = new Date(credentials.expiry_date!); + const unixTimestamp = + Math.floor(expiryDate.getTime() / 1000) - + Math.floor(new Date().getTime() / 1000); + + const { data } = await user.userinfo.get(); + + return { + accessToken: credentials.access_token!, + expiresIn: unixTimestamp!, + refreshToken: credentials.refresh_token ?? refresh_token, + id: data.id!, + name: data.name!, + picture: data?.picture || '', + username: '', + }; + } + + async generateAuthUrl() { + const state = makeId(7); + const { client } = clientAndYoutube(); + return { + url: client.generateAuthUrl({ + access_type: 'offline', + prompt: 'consent', + state, + redirect_uri: `${process.env.FRONTEND_URL}/integrations/social/youtube`, + scope: this.scopes.slice(0), + }), + codeVerifier: makeId(11), + state, + }; + } + + async authenticate(params: { + code: string; + codeVerifier: string; + refresh?: string; + }) { + const { client, oauth2 } = clientAndYoutube(); + const { tokens } = await client.getToken(params.code); + client.setCredentials(tokens); + const { scopes } = await client.getTokenInfo(tokens.access_token!); + this.checkScopes(this.scopes, scopes); + + const user = oauth2(client); + const { data } = await user.userinfo.get(); + + const expiryDate = new Date(tokens.expiry_date!); + const unixTimestamp = + Math.floor(expiryDate.getTime() / 1000) - + Math.floor(new Date().getTime() / 1000); + + return { + accessToken: tokens.access_token!, + expiresIn: unixTimestamp, + refreshToken: tokens.refresh_token!, + id: data.id!, + name: data.name!, + picture: data?.picture || '', + username: '', + }; + } + + async pages(accessToken: string) { + const { client, youtube } = clientAndYoutube(); + client.setCredentials({ access_token: accessToken }); + const youtubeClient = youtube(client); + + try { + // Get all channels the user has access to + const response = await youtubeClient.channels.list({ + part: ['snippet', 'contentDetails', 'statistics'], + mine: true, + }); + + const channels = response.data.items || []; + + return channels.map((channel) => ({ + id: channel.id!, + name: channel.snippet?.title || 'Unnamed Channel', + picture: { + data: { + url: channel.snippet?.thumbnails?.default?.url || '', + }, + }, + username: channel.snippet?.customUrl || '', + subscriberCount: channel.statistics?.subscriberCount || '0', + })); + } catch (error) { + console.error('Failed to fetch YouTube channels:', error); + return []; + } + } + + async fetchPageInformation(accessToken: string, data: { id: string }) { + const { client, youtube } = clientAndYoutube(); + client.setCredentials({ access_token: accessToken }); + const youtubeClient = youtube(client); + + try { + const response = await youtubeClient.channels.list({ + part: ['snippet', 'contentDetails', 'statistics'], + id: [data.id], + }); + + const channel = response.data.items?.[0]; + + if (!channel) { + throw new Error('Channel not found'); + } + + return { + id: channel.id!, + name: channel.snippet?.title || 'Unnamed Channel', + access_token: accessToken, + picture: channel.snippet?.thumbnails?.default?.url || '', + username: channel.snippet?.customUrl || '', + }; + } catch (error) { + console.error('Failed to fetch YouTube channel information:', error); + throw error; + } + } + + async reConnect( + id: string, + requiredId: string, + accessToken: string + ): Promise> { + const pages = await this.pages(accessToken); + const findPage = pages.find((p) => p.id === requiredId); + + if (!findPage) { + throw new Error('Channel not found'); + } + + const information = await this.fetchPageInformation(accessToken, { + id: requiredId, + }); + + return { + id: information.id, + name: information.name, + accessToken: information.access_token, + picture: information.picture, + username: information.username, + }; + } + + async post( + id: string, + accessToken: string, + postDetails: PostDetails[] + ): Promise { + const [firstPost, ...comments] = postDetails; + + const { client, youtube } = clientAndYoutube(); + client.setCredentials({ access_token: accessToken }); + const youtubeClient = youtube(client); + + const { settings }: { settings: YoutubeSettingsDto } = firstPost; + + const response = await axios({ + url: firstPost?.media?.[0]?.path, + method: 'GET', + responseType: 'stream', + }); + + const all: GaxiosResponse = await this.runInConcurrent( + async () => + youtubeClient.videos.insert({ + part: ['id', 'snippet', 'status'], + notifySubscribers: true, + requestBody: { + snippet: { + title: settings.title, + description: firstPost?.message, + ...(settings?.tags?.length + ? { tags: settings.tags.map((p) => p.label) } + : {}), + }, + status: { + privacyStatus: settings.type, + selfDeclaredMadeForKids: + settings.selfDeclaredMadeForKids === 'yes', + }, + }, + media: { + body: response.data, + }, + }), + true + ); + + if (settings?.thumbnail?.path) { + await this.runInConcurrent(async () => + youtubeClient.thumbnails.set({ + videoId: all?.data?.id!, + media: { + body: ( + await axios({ + url: settings?.thumbnail?.path, + method: 'GET', + responseType: 'stream', + }) + ).data, + }, + }) + ); + } + + return [ + { + id: firstPost.id, + releaseURL: `https://www.youtube.com/watch?v=${all?.data?.id}`, + postId: all?.data?.id!, + status: 'success', + }, + ]; + } + + async analytics( + id: string, + accessToken: string, + date: number + ): Promise { + try { + const endDate = dayjs().format('YYYY-MM-DD'); + const startDate = dayjs().subtract(date, 'day').format('YYYY-MM-DD'); + + const { client, youtubeAnalytics } = clientAndYoutube(); + client.setCredentials({ access_token: accessToken }); + + const youtubeClient = youtubeAnalytics(client); + const { data } = await youtubeClient.reports.query({ + ids: 'channel==MINE', + startDate, + endDate, + metrics: + 'views,estimatedMinutesWatched,averageViewDuration,averageViewPercentage,subscribersGained,likes,subscribersLost', + dimensions: 'day', + sort: 'day', + }); + + const columns = data?.columnHeaders?.map((p) => p.name)!; + const mappedData = data?.rows?.map((p) => { + return columns.reduce((acc, curr, index) => { + acc[curr!] = p[index]; + return acc; + }, {} as any); + }); + + const acc = [] as any[]; + acc.push({ + label: 'Estimated Minutes Watched', + data: mappedData?.map((p: any) => ({ + total: p.estimatedMinutesWatched, + date: p.day, + })), + }); + + acc.push({ + label: 'Average View Duration', + average: true, + data: mappedData?.map((p: any) => ({ + total: p.averageViewDuration, + date: p.day, + })), + }); + + acc.push({ + label: 'Average View Percentage', + average: true, + data: mappedData?.map((p: any) => ({ + total: p.averageViewPercentage, + date: p.day, + })), + }); + + acc.push({ + label: 'Subscribers Gained', + data: mappedData?.map((p: any) => ({ + total: p.subscribersGained, + date: p.day, + })), + }); + + acc.push({ + label: 'Subscribers Lost', + data: mappedData?.map((p: any) => ({ + total: p.subscribersLost, + date: p.day, + })), + }); + + acc.push({ + label: 'Likes', + data: mappedData?.map((p: any) => ({ + total: p.likes, + date: p.day, + })), + }); + + return acc; + } catch (err) { + return []; + } + } + + async postAnalytics( + integrationId: string, + accessToken: string, + postId: string, + date: number + ): Promise { + const today = dayjs().format('YYYY-MM-DD'); + + try { + const { client, youtube } = clientAndYoutube(); + client.setCredentials({ access_token: accessToken }); + const youtubeClient = youtube(client); + + // Fetch video statistics + const response = await youtubeClient.videos.list({ + part: ['statistics', 'snippet'], + id: [postId], + }); + + const video = response.data.items?.[0]; + + if (!video || !video.statistics) { + return []; + } + + const stats = video.statistics; + const result: AnalyticsData[] = []; + + if (stats.viewCount !== undefined) { + result.push({ + label: 'Views', + percentageChange: 0, + data: [{ total: String(stats.viewCount), date: today }], + }); + } + + if (stats.likeCount !== undefined) { + result.push({ + label: 'Likes', + percentageChange: 0, + data: [{ total: String(stats.likeCount), date: today }], + }); + } + + if (stats.commentCount !== undefined) { + result.push({ + label: 'Comments', + percentageChange: 0, + data: [{ total: String(stats.commentCount), date: today }], + }); + } + + if (stats.favoriteCount !== undefined) { + result.push({ + label: 'Favorites', + percentageChange: 0, + data: [{ total: String(stats.favoriteCount), date: today }], + }); + } + + return result; + } catch (err) { + console.error('Error fetching YouTube post analytics:', err); + return []; + } + } +} diff --git a/libraries/nestjs-libraries/src/integrations/tool.decorator.ts b/libraries/nestjs-libraries/src/integrations/tool.decorator.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4759d4607af8edddfb8bdaa8190814fb5d4ec15 --- /dev/null +++ b/libraries/nestjs-libraries/src/integrations/tool.decorator.ts @@ -0,0 +1,17 @@ +import 'reflect-metadata'; + +export function Tool(params: { + description: string; + dataSchema: Array<{ key: string; type: string; description: string }>; +}) { + return function (target: any, propertyKey: string | symbol) { + // Retrieve existing metadata or initialize an empty array + const existingMetadata = Reflect.getMetadata('custom:tool', target) || []; + + // Add the metadata information for this method + existingMetadata.push({ methodName: propertyKey, ...params }); + + // Define metadata on the class prototype (so it can be retrieved from the class) + Reflect.defineMetadata('custom:tool', existingMetadata, target); + }; +} diff --git a/libraries/nestjs-libraries/src/newsletter/newsletter.interface.ts b/libraries/nestjs-libraries/src/newsletter/newsletter.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..200ad05d724ce8d8fd4a42a3dd5c9c589c12f7cb --- /dev/null +++ b/libraries/nestjs-libraries/src/newsletter/newsletter.interface.ts @@ -0,0 +1,4 @@ +export interface NewsletterInterface { + name: string; + register(email: string): Promise; +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/newsletter/newsletter.service.ts b/libraries/nestjs-libraries/src/newsletter/newsletter.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..7980a6d0668411d1bbda02c38465351108c8d588 --- /dev/null +++ b/libraries/nestjs-libraries/src/newsletter/newsletter.service.ts @@ -0,0 +1,20 @@ +import { newsletterProviders } from '@gitroom/nestjs-libraries/newsletter/providers'; + +export class NewsletterService { + static getProvider() { + if (process.env.BEEHIIVE_API_KEY) { + return newsletterProviders.find((p) => p.name === 'beehiiv')!; + } + if (process.env.LISTMONK_API_KEY) { + return newsletterProviders.find((p) => p.name === 'listmonk')!; + } + + return newsletterProviders.find((p) => p.name === 'empty')!; + } + static async register(email: string) { + if (email.indexOf('@') === -1) { + return; + } + return NewsletterService.getProvider().register(email); + } +} diff --git a/libraries/nestjs-libraries/src/newsletter/providers.ts b/libraries/nestjs-libraries/src/newsletter/providers.ts new file mode 100644 index 0000000000000000000000000000000000000000..c43fdca304e9c4b0f8d8b879a373ca499b0c0711 --- /dev/null +++ b/libraries/nestjs-libraries/src/newsletter/providers.ts @@ -0,0 +1,9 @@ +import { BeehiivProvider } from '@gitroom/nestjs-libraries/newsletter/providers/beehiiv.provider'; +import { EmailEmptyProvider } from '@gitroom/nestjs-libraries/newsletter/providers/email-empty.provider'; +import { ListmonkProvider } from '@gitroom/nestjs-libraries/newsletter/providers/listmonk.provider'; + +export const newsletterProviders = [ + new BeehiivProvider(), + new ListmonkProvider(), + new EmailEmptyProvider(), +]; diff --git a/libraries/nestjs-libraries/src/newsletter/providers/beehiiv.provider.ts b/libraries/nestjs-libraries/src/newsletter/providers/beehiiv.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7d0e8856611cd9df8d99c916b4e2d7d829d1a7b --- /dev/null +++ b/libraries/nestjs-libraries/src/newsletter/providers/beehiiv.provider.ts @@ -0,0 +1,26 @@ +import { NewsletterInterface } from '@gitroom/nestjs-libraries/newsletter/newsletter.interface'; + +export class BeehiivProvider implements NewsletterInterface { + name = 'beehiiv'; + async register(email: string) { + const body = { + email, + reactivate_existing: false, + send_welcome_email: true, + utm_source: 'gitroom_platform', + }; + + await fetch( + `https://api.beehiiv.com/v2/publications/${process.env.BEEHIIVE_PUBLICATION_ID}/subscriptions`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${process.env.BEEHIIVE_API_KEY}`, + }, + body: JSON.stringify(body), + } + ); + } +} diff --git a/libraries/nestjs-libraries/src/newsletter/providers/email-empty.provider.ts b/libraries/nestjs-libraries/src/newsletter/providers/email-empty.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..2abf9307d3dcbfa87262263c9f0e91dfc78d4d4c --- /dev/null +++ b/libraries/nestjs-libraries/src/newsletter/providers/email-empty.provider.ts @@ -0,0 +1,8 @@ +import { NewsletterInterface } from '@gitroom/nestjs-libraries/newsletter/newsletter.interface'; + +export class EmailEmptyProvider implements NewsletterInterface { + name = 'empty'; + async register(email: string) { + console.log('Could have registered to newsletter:', email); + } +} diff --git a/libraries/nestjs-libraries/src/newsletter/providers/listmonk.provider.ts b/libraries/nestjs-libraries/src/newsletter/providers/listmonk.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae022df17dddd3023139cb412eec724144a20c02 --- /dev/null +++ b/libraries/nestjs-libraries/src/newsletter/providers/listmonk.provider.ts @@ -0,0 +1,45 @@ +import { NewsletterInterface } from '@gitroom/nestjs-libraries/newsletter/newsletter.interface'; + +export class ListmonkProvider implements NewsletterInterface { + name = 'listmonk'; + async register(email: string) { + const body = { + email, + status: 'enabled', + lists: [+process.env.LISTMONK_LIST_ID].filter((f) => f), + }; + + const authString = `${process.env.LISTMONK_USER}:${process.env.LISTMONK_API_KEY}`; + const headers = new Headers(); + headers.set('Content-Type', 'application/json'); + headers.set('Accept', 'application/json'); + headers.set( + 'Authorization', + 'Basic ' + Buffer.from(authString).toString('base64') + ); + + try { + const { + data: { id }, + } = await ( + await fetch(`${process.env.LISTMONK_DOMAIN}/api/subscribers`, { + method: 'POST', + headers, + body: JSON.stringify(body), + }) + ).json(); + + const welcomeEmail = { + subscriber_id: id, + template_id: +process.env.LISTMONK_WELCOME_TEMPLATE_ID, + subject: 'Welcome to Postiz 🚀', + }; + + await fetch(`${process.env.LISTMONK_DOMAIN}/api/tx`, { + method: 'POST', + headers, + body: JSON.stringify(welcomeEmail), + }); + } catch (err) {} + } +} diff --git a/libraries/nestjs-libraries/src/openai/extract.content.service.ts b/libraries/nestjs-libraries/src/openai/extract.content.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..fac140e87ad80f8e4f60a28b3c701b7a8a12f62d --- /dev/null +++ b/libraries/nestjs-libraries/src/openai/extract.content.service.ts @@ -0,0 +1,92 @@ +import { Injectable } from '@nestjs/common'; +import { JSDOM } from 'jsdom'; + +function findDepth(element: Element) { + let depth = 0; + let elementer = element; + while (elementer.parentNode) { + depth++; + // @ts-ignore + elementer = elementer.parentNode; + } + return depth; +} + +@Injectable() +export class ExtractContentService { + async extractContent(url: string) { + const load = await (await fetch(url)).text(); + const dom = new JSDOM(load); + + // only element that has a title + const allTitles = Array.from(dom.window.document.querySelectorAll('*')) + .filter((f) => { + return ( + f.querySelector('h1') || + f.querySelector('h2') || + f.querySelector('h3') || + f.querySelector('h4') || + f.querySelector('h5') || + f.querySelector('h6') + ); + }) + .reverse(); + + const findTheOneWithMostTitles = allTitles.reduce( + (all, current) => { + const depth = findDepth(current); + const calculate = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].reduce( + (total, tag) => { + if (current.querySelector(tag)) { + return total + 1; + } + return total; + }, + 0 + ); + + if (calculate > all.total) { + return { total: calculate, depth, element: current }; + } + + if (depth > all.depth) { + return { total: calculate, depth, element: current }; + } + + return all; + }, + { total: 0, depth: 0, element: null as Element | null } + ); + + return findTheOneWithMostTitles?.element?.textContent + ?.replace(/\n/g, ' ') + .replace(/ {2,}/g, ' '); + // + // const allElements = Array.from( + // dom.window.document.querySelectorAll('*') + // ).filter((f) => f.tagName !== 'SCRIPT'); + // const findIndex = allElements.findIndex((element) => { + // return ( + // ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].indexOf( + // element.tagName.toLowerCase() + // ) > -1 + // ); + // }); + // + // if (!findIndex) { + // return false; + // } + // + // return allElements + // .slice(findIndex) + // .map((element) => element.textContent) + // .filter((f) => { + // const trim = f?.trim(); + // return (trim?.length || 0) > 0 && trim !== '\n'; + // }) + // .map((f) => f?.trim()) + // .join('') + // .replace(/\n/g, ' ') + // .replace(/ {2,}/g, ' '); + } +} diff --git a/libraries/nestjs-libraries/src/openai/fal.service.ts b/libraries/nestjs-libraries/src/openai/fal.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..0b6b17466a57accaffeec51145618f3fb45b2ea0 --- /dev/null +++ b/libraries/nestjs-libraries/src/openai/fal.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; + +import pLimit from 'p-limit'; +const limit = pLimit(10); + +@Injectable() +export class FalService { + async generateImageFromText( + model: string, + text: string, + isVertical: boolean = false + ): Promise { + const { images, video, ...all } = await ( + await limit(() => + fetch(`https://fal.run/fal-ai/${model}`, { + method: 'POST', + headers: { + Authorization: `Key ${process.env.FAL_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: text, + aspect_ratio: isVertical ? '9:16' : '16:9', + resolution: '720p', + num_images: 1, + output_format: 'jpeg', + expand_prompt: true, + }), + }) + ) + ).json(); + + console.log(all, video, images); + + if (video) { + return video.url; + } + + return images[0].url as string; + } +} diff --git a/libraries/nestjs-libraries/src/openai/generation.error.ts b/libraries/nestjs-libraries/src/openai/generation.error.ts new file mode 100644 index 0000000000000000000000000000000000000000..90f710865bb7182374178b329d8c88d6a665183e --- /dev/null +++ b/libraries/nestjs-libraries/src/openai/generation.error.ts @@ -0,0 +1,42 @@ +import { HttpException } from '@nestjs/common'; + +// e.g. "400 Your request was rejected by the safety system, safety_violations=[sexual]" +const SAFETY_VIOLATIONS_REGEX = /safety_violations=\[([^\]]*)\]/i; + +// Match genuine content-safety rejections by message, NOT by a bare 400 status: +// a 400 can just as easily be an invalid-parameter error, which must not be +// reported to the user as a safety violation. +const SAFETY_MESSAGE_REGEX = + /safety system|safety_violations|content[ _]policy|rejected as a result of our safety|moderation/i; + +/** + * Normalizes errors thrown by AI generation providers (OpenAI image/chat, + * LangChain DALL-E, Fal, Veo3, HeyGen, ElevenLabs, ...) into a clean + * HttpException so a provider rejection (most notably an OpenAI safety + * violation) returns a proper response instead of crashing the backend. + * + * When the provider reports a content-safety rejection, the flagged + * category/categories are surfaced back to the user. + */ +export function generationError(err: any): HttpException { + // Preserve errors we already raised intentionally (e.g. SubscriptionException). + if (err instanceof HttpException) { + return err; + } + + const message: string = + err?.error?.message || err?.message || String(err || ''); + + if (SAFETY_MESSAGE_REGEX.test(message)) { + const categories = message.match(SAFETY_VIOLATIONS_REGEX)?.[1]?.trim(); + const detail = categories ? ` Flagged categories: ${categories}.` : ''; + return new HttpException( + `Your request was rejected by the AI safety system.${detail} Please adjust your prompt and try again.`, + 422 + ); + } + + // Not a recognized safety rejection (e.g. an invalid-parameter 400) — return + // a generic message rather than mislabeling it as a content-safety issue. + return new HttpException('AI generation failed, please try again later.', 500); +} diff --git a/libraries/nestjs-libraries/src/openai/openai.service.ts b/libraries/nestjs-libraries/src/openai/openai.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1eb8ac5d7d9c0b5ff9e60e41aec3db30910cc41 --- /dev/null +++ b/libraries/nestjs-libraries/src/openai/openai.service.ts @@ -0,0 +1,272 @@ +import { Injectable } from '@nestjs/common'; +import OpenAI from 'openai'; +import { shuffle } from 'lodash'; +import { zodResponseFormat } from 'openai/helpers/zod'; +import { z } from 'zod'; + +const openai = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY || 'sk-proj-', +}); + +const PicturePrompt = z.object({ + prompt: z.string(), +}); + +const VoicePrompt = z.object({ + voice: z.string(), +}); + +@Injectable() +export class OpenaiService { + async generateImage(prompt: string, isVertical = false) { + // gpt-image models always return base64 (b64_json) and do not accept the + // `response_format` parameter, unlike the deprecated dall-e-3. + const generate = ( + await openai.images.generate({ + prompt, + model: 'chatgpt-image-latest', + size: isVertical ? '1024x1536' : '1024x1024', + }) + ).data[0]; + + return generate.b64_json; + } + + async generatePromptForPicture(prompt: string) { + return ( + ( + await openai.chat.completions.parse({ + model: 'gpt-4.1', + messages: [ + { + role: 'system', + content: `You are an assistant that take a description and style and generate a prompt that will be used later to generate images, make it a very long and descriptive explanation, and write a lot of things for the renderer like, if it${"'"}s realistic describe the camera`, + }, + { + role: 'user', + content: `prompt: ${prompt}`, + }, + ], + response_format: zodResponseFormat(PicturePrompt, 'picturePrompt'), + }) + ).choices[0].message.parsed?.prompt || '' + ); + } + + async generateVoiceFromText(prompt: string) { + return ( + ( + await openai.chat.completions.parse({ + model: 'gpt-4.1', + messages: [ + { + role: 'system', + content: `You are an assistant that takes a social media post and convert it to a normal human voice, to be later added to a character, when a person talk they don\'t use "-", and sometimes they add pause with "..." to make it sounds more natural, make sure you use a lot of pauses and make it sound like a real person`, + }, + { + role: 'user', + content: `prompt: ${prompt}`, + }, + ], + response_format: zodResponseFormat(VoicePrompt, 'voice'), + }) + ).choices[0].message.parsed?.voice || '' + ); + } + + async generatePosts(content: string) { + const posts = ( + await Promise.all([ + openai.chat.completions.create({ + messages: [ + { + role: 'assistant', + content: + 'Generate a Twitter post from the content without emojis in the following JSON format: { "post": string } put it in an array with one element', + }, + { + role: 'user', + content: content!, + }, + ], + n: 5, + temperature: 1, + model: 'gpt-4.1', + }), + openai.chat.completions.create({ + messages: [ + { + role: 'assistant', + content: + 'Generate a thread for social media in the following JSON format: Array<{ "post": string }> without emojis', + }, + { + role: 'user', + content: content!, + }, + ], + n: 5, + temperature: 1, + model: 'gpt-4.1', + }), + ]) + ).flatMap((p) => p.choices); + + return shuffle( + posts.map((choice) => { + const { content } = choice.message; + const start = content?.indexOf('[')!; + const end = content?.lastIndexOf(']')!; + try { + return JSON.parse( + '[' + + content + ?.slice(start + 1, end) + .replace(/\n/g, ' ') + .replace(/ {2,}/g, ' ') + + ']' + ); + } catch (e) { + return []; + } + }) + ); + } + async extractWebsiteText(content: string) { + const websiteContent = await openai.chat.completions.create({ + messages: [ + { + role: 'assistant', + content: + 'You take a full website text, and extract only the article content', + }, + { + role: 'user', + content, + }, + ], + model: 'gpt-4.1', + }); + + const { content: articleContent } = websiteContent.choices[0].message; + + return this.generatePosts(articleContent!); + } + + async separatePosts(content: string, len: number) { + const SeparatePostsPrompt = z.object({ + posts: z.array(z.string()), + }); + + const SeparatePostPrompt = z.object({ + post: z.string().max(len), + }); + + const posts = + ( + await openai.chat.completions.parse({ + model: 'gpt-4.1', + messages: [ + { + role: 'system', + content: `You are an assistant that take a social media post and break it to a thread, each post must be minimum ${ + len - 10 + } and maximum ${len} characters, keeping the exact wording and break lines, however make sure you split posts based on context`, + }, + { + role: 'user', + content: content, + }, + ], + response_format: zodResponseFormat( + SeparatePostsPrompt, + 'separatePosts' + ), + }) + ).choices[0].message.parsed?.posts || []; + + return { + posts: await Promise.all( + posts.map(async (post: any) => { + if (post.length <= len) { + return post; + } + + let retries = 4; + while (retries) { + try { + return ( + ( + await openai.chat.completions.parse({ + model: 'gpt-4.1', + messages: [ + { + role: 'system', + content: `You are an assistant that take a social media post and shrink it to be maximum ${len} characters, keeping the exact wording and break lines`, + }, + { + role: 'user', + content: post, + }, + ], + response_format: zodResponseFormat( + SeparatePostPrompt, + 'separatePost' + ), + }) + ).choices[0].message.parsed?.post || '' + ); + } catch (e) { + retries--; + } + } + + return post; + }) + ), + }; + } + + async generateSlidesFromText(text: string) { + for (let i = 0; i < 3; i++) { + try { + const message = `You are an assistant that takes a text and break it into slides, each slide should have an image prompt and voice text to be later used to generate a video and voice, image prompt should capture the essence of the slide and also have a back dark gradient on top, image prompt should not contain text in the picture, generate between 3-5 slides maximum`; + const parse = + ( + await openai.chat.completions.parse({ + model: 'gpt-4.1', + messages: [ + { + role: 'system', + content: message, + }, + { + role: 'user', + content: text, + }, + ], + response_format: zodResponseFormat( + z.object({ + slides: z + .array( + z.object({ + imagePrompt: z.string(), + voiceText: z.string(), + }) + ) + .describe('an array of slides'), + }), + 'slides' + ), + }) + ).choices[0].message.parsed?.slides || []; + + return parse; + } catch (err) { + console.log(err); + } + } + + return []; + } +} diff --git a/libraries/nestjs-libraries/src/redis/redis.service.ts b/libraries/nestjs-libraries/src/redis/redis.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..92a95a6a4ccb1ccec9119915c1eb7735f5b0bcf6 --- /dev/null +++ b/libraries/nestjs-libraries/src/redis/redis.service.ts @@ -0,0 +1,30 @@ +import { Redis } from 'ioredis'; + +// Create a mock Redis implementation for testing environments +class MockRedis { + private data: Map = new Map(); + + async get(key: string) { + return this.data.get(key); + } + + async set(key: string, value: any) { + this.data.set(key, value); + return 'OK'; + } + + async del(key: string) { + this.data.delete(key); + return 1; + } + + // Add other Redis methods as needed for your tests +} + +// Use real Redis if REDIS_URL is defined, otherwise use MockRedis +export const ioRedis = process.env.REDIS_URL + ? new Redis(process.env.REDIS_URL, { + maxRetriesPerRequest: null, + connectTimeout: 10000, + }) + : (new MockRedis() as unknown as Redis); // Type cast to Redis to maintain interface compatibility diff --git a/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts b/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts new file mode 100644 index 0000000000000000000000000000000000000000..b92c6627bbf5aa1e7cd9f105bff250c040f29b11 --- /dev/null +++ b/libraries/nestjs-libraries/src/sentry/initialize.sentry.ts @@ -0,0 +1,46 @@ +import * as Sentry from '@sentry/nestjs'; +import { nodeProfilingIntegration } from '@sentry/profiling-node'; +import { capitalize } from 'lodash'; + +export const initializeSentry = (appName: string, allowLogs = false) => { + if (!process.env.NEXT_PUBLIC_SENTRY_DSN) { + return null; + } + + try { + Sentry.init({ + initialScope: { + tags: { + service: appName, + component: 'nestjs', + }, + contexts: { + app: { + name: `Postiz ${capitalize(appName)}`, + }, + }, + }, + environment: process.env.NODE_ENV || 'development', + dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, + spotlight: process.env.SENTRY_SPOTLIGHT === '1', + integrations: [ + // Add our Profiling integration + nodeProfilingIntegration(), + Sentry.consoleLoggingIntegration({ levels: ['log', 'info', 'warn', 'error', 'debug', 'assert', 'trace'] }), + Sentry.openAIIntegration({ + recordInputs: true, + recordOutputs: true, + }), + ], + tracesSampleRate: 1.0, + enableLogs: true, + + // Profiling + profileSessionSampleRate: process.env.NODE_ENV === 'development' ? 1.0 : 0.45, + profileLifecycle: 'trace', + }); + } catch (err) { + console.log(err); + } + return true; +}; diff --git a/libraries/nestjs-libraries/src/sentry/sentry.exception.ts b/libraries/nestjs-libraries/src/sentry/sentry.exception.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8aec4cd210890f262cea48c6c2ebbde53d4e978 --- /dev/null +++ b/libraries/nestjs-libraries/src/sentry/sentry.exception.ts @@ -0,0 +1,7 @@ +import { APP_FILTER } from "@nestjs/core"; +import { SentryGlobalFilter } from "@sentry/nestjs/setup"; + +export const FILTER = { + provide: APP_FILTER, + useClass: SentryGlobalFilter, +}; diff --git a/libraries/nestjs-libraries/src/services/codes.service.ts b/libraries/nestjs-libraries/src/services/codes.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..770c69d6e49ae834dec673640384dd7f05708b66 --- /dev/null +++ b/libraries/nestjs-libraries/src/services/codes.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { AuthService } from '@gitroom/helpers/auth/auth.service'; + +@Injectable() +export class CodesService { + generateCodes(providerToken: string) { + try { + const decrypt = AuthService.fixedDecryption(providerToken); + return [...new Array(10000)] + .map((_, index) => { + return AuthService.fixedEncryption(`${decrypt}:${index}`); + }) + .join('\n'); + } catch (error) { + return ''; + } + } +} diff --git a/libraries/nestjs-libraries/src/services/email.service.ts b/libraries/nestjs-libraries/src/services/email.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..75157f58d4d7356a173e378b6aa4f0a4899fe28e --- /dev/null +++ b/libraries/nestjs-libraries/src/services/email.service.ts @@ -0,0 +1,150 @@ +import { Injectable } from '@nestjs/common'; +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 { TemporalService } from 'nestjs-temporal-core'; +import { timer } from '@gitroom/helpers/utils/timer'; + +@Injectable() +export class EmailService { + emailService: EmailInterface; + constructor(private _temporalService: TemporalService) { + this.emailService = this.selectProvider(process.env.EMAIL_PROVIDER!); + console.log('Email service provider:', this.emailService.name); + for (const key of this.emailService.validateEnvKeys) { + if (!process.env[key]) { + console.error(`Missing environment variable: ${key}`); + } + } + } + + hasProvider() { + return !(this.emailService instanceof EmptyProvider); + } + + selectProvider(provider: string) { + switch (provider) { + case 'resend': + return new ResendProvider(); + case 'nodemailer': + return new NodeMailerProvider(); + default: + return new EmptyProvider(); + } + } + + async sendEmail( + to: string, + subject: string, + html: string, + addTo: 'top' | 'bottom', + replyTo?: string + ) { + return this._temporalService.client + .getRawClient() + ?.workflow.signalWithStart('sendEmailWorkflow', { + taskQueue: 'main', + workflowId: 'send_email', + signal: 'sendEmail', + args: [{ queue: [] }], + signalArgs: [{ to, subject, html, replyTo, addTo }], + workflowIdConflictPolicy: 'USE_EXISTING', + }); + } + + async sendEmailSync( + to: string, + subject: string, + html: string, + replyTo?: string + ) { + if (to.indexOf('@') === -1) { + return; + } + + if (!process.env.EMAIL_FROM_ADDRESS || !process.env.EMAIL_FROM_NAME) { + console.log( + 'Email sender information not found in environment variables' + ); + return; + } + + const modifiedHtml = ` +

+
+

${subject}

+ +
+ ${html} +
+ +
+
+

${process.env.EMAIL_FROM_NAME}

+
+ You can change your notification preferences in your account settings. +
+
+
+
+
+ `; + + let lastErr: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const sends = await this.emailService.sendEmail( + to, + subject, + modifiedHtml, + process.env.EMAIL_FROM_NAME, + process.env.EMAIL_FROM_ADDRESS, + replyTo + ); + console.log(sends); + return; + } catch (err) { + lastErr = err; + console.log(`Email attempt ${attempt + 1}/3 failed:`, err); + if (attempt < 2) { + await timer(700); + } + } + } + console.log(`Email to ${to} failed after 3 attempts:`, lastErr); + } +} diff --git a/libraries/nestjs-libraries/src/services/exception.filter.ts b/libraries/nestjs-libraries/src/services/exception.filter.ts new file mode 100644 index 0000000000000000000000000000000000000000..f1a08951044edc914c57145423b354d1cb9b4d24 --- /dev/null +++ b/libraries/nestjs-libraries/src/services/exception.filter.ts @@ -0,0 +1,25 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpException, +} from '@nestjs/common'; +import { Response } from 'express'; +import { removeAuth } from '@gitroom/backend/services/auth/auth.middleware'; + +export class HttpForbiddenException extends HttpException { + constructor() { + super('Forbidden', 403); + } +} + +@Catch(HttpForbiddenException) +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpForbiddenException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + removeAuth(response); + + return response.status(401).send(); + } +} diff --git a/libraries/nestjs-libraries/src/services/make.is.ts b/libraries/nestjs-libraries/src/services/make.is.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9c7043bb1f82678505e628e6de7613997d3686d --- /dev/null +++ b/libraries/nestjs-libraries/src/services/make.is.ts @@ -0,0 +1,10 @@ +export const makeId = (length: number) => { + let text = ''; + const possible = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + for (let i = 0; i < length; i += 1) { + text += possible.charAt(Math.floor(Math.random() * possible.length)); + } + return text; +}; diff --git a/libraries/nestjs-libraries/src/services/stripe.country.list.ts b/libraries/nestjs-libraries/src/services/stripe.country.list.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ced7d41c78d8708e378138c32798f222873907a --- /dev/null +++ b/libraries/nestjs-libraries/src/services/stripe.country.list.ts @@ -0,0 +1,111 @@ +export const countries = [ + { value: 'AL', label: 'Albania' }, + { value: 'AG', label: 'Antigua & Barbuda' }, + { value: 'AR', label: 'Argentina' }, + { value: 'AM', label: 'Armenia' }, + { value: 'AU', label: 'Australia' }, + { value: 'AT', label: 'Austria' }, + { value: 'BS', label: 'Bahamas' }, + { value: 'BH', label: 'Bahrain' }, + { value: 'BE', label: 'Belgium' }, + { value: 'BJ', label: 'Benin' }, + { value: 'BO', label: 'Bolivia' }, + { value: 'BA', label: 'Bosnia & Herzegovina' }, + { value: 'BW', label: 'Botswana' }, + { value: 'BN', label: 'Brunei' }, + { value: 'BG', label: 'Bulgaria' }, + { value: 'KH', label: 'Cambodia' }, + { value: 'CA', label: 'Canada' }, + { value: 'CL', label: 'Chile' }, + { value: 'CO', label: 'Colombia' }, + { value: 'CR', label: 'Costa Rica' }, + { value: 'HR', label: 'Croatia' }, + { value: 'CY', label: 'Cyprus' }, + { value: 'CZ', label: 'Czech Republic' }, + { value: 'CI', label: 'Côte d’Ivoire' }, + { value: 'DK', label: 'Denmark' }, + { value: 'DO', label: 'Dominican Republic' }, + { value: 'EC', label: 'Ecuador' }, + { value: 'EG', label: 'Egypt' }, + { value: 'SV', label: 'El Salvador' }, + { value: 'EE', label: 'Estonia' }, + { value: 'ET', label: 'Ethiopia' }, + { value: 'FI', label: 'Finland' }, + { value: 'FR', label: 'France' }, + { value: 'GM', label: 'Gambia' }, + { value: 'DE', label: 'Germany' }, + { value: 'GH', label: 'Ghana' }, + { value: 'GI', label: 'Gibraltar' }, + { value: 'GR', label: 'Greece' }, + { value: 'GT', label: 'Guatemala' }, + { value: 'GY', label: 'Guyana' }, + { value: 'HK', label: 'Hong Kong SAR China' }, + { value: 'HU', label: 'Hungary' }, + { value: 'IS', label: 'Iceland' }, + { value: 'IN', label: 'India' }, + { value: 'ID', label: 'Indonesia' }, + { value: 'IE', label: 'Ireland' }, + { value: 'IL', label: 'Israel' }, + { value: 'IT', label: 'Italy' }, + { value: 'JM', label: 'Jamaica' }, + { value: 'JP', label: 'Japan' }, + { value: 'JO', label: 'Jordan' }, + { value: 'KE', label: 'Kenya' }, + { value: 'KW', label: 'Kuwait' }, + { value: 'LV', label: 'Latvia' }, + { value: 'LI', label: 'Liechtenstein' }, + { value: 'LT', label: 'Lithuania' }, + { value: 'LU', label: 'Luxembourg' }, + { value: 'MO', label: 'Macao SAR China' }, + { value: 'MG', label: 'Madagascar' }, + { value: 'MY', label: 'Malaysia' }, + { value: 'MT', label: 'Malta' }, + { value: 'MU', label: 'Mauritius' }, + { value: 'MX', label: 'Mexico' }, + { value: 'MD', label: 'Moldova' }, + { value: 'MC', label: 'Monaco' }, + { value: 'MN', label: 'Mongolia' }, + { value: 'MA', label: 'Morocco' }, + { value: 'NA', label: 'Namibia' }, + { value: 'NL', label: 'Netherlands' }, + { value: 'NZ', label: 'New Zealand' }, + { value: 'NG', label: 'Nigeria' }, + { value: 'MK', label: 'North Macedonia' }, + { value: 'NO', label: 'Norway' }, + { value: 'OM', label: 'Oman' }, + { value: 'PK', label: 'Pakistan' }, + { value: 'PA', label: 'Panama' }, + { value: 'PY', label: 'Paraguay' }, + { value: 'PE', label: 'Peru' }, + { value: 'PH', label: 'Philippines' }, + { value: 'PL', label: 'Poland' }, + { value: 'PT', label: 'Portugal' }, + { value: 'QA', label: 'Qatar' }, + { value: 'RO', label: 'Romania' }, + { value: 'RW', label: 'Rwanda' }, + { value: 'SA', label: 'Saudi Arabia' }, + { value: 'SN', label: 'Senegal' }, + { value: 'RS', label: 'Serbia' }, + { value: 'SG', label: 'Singapore' }, + { value: 'SK', label: 'Slovakia' }, + { value: 'SI', label: 'Slovenia' }, + { value: 'ZA', label: 'South Africa' }, + { value: 'KR', label: 'South Korea' }, + { value: 'ES', label: 'Spain' }, + { value: 'LK', label: 'Sri Lanka' }, + { value: 'LC', label: 'St. Lucia' }, + { value: 'SE', label: 'Sweden' }, + { value: 'CH', label: 'Switzerland' }, + { value: 'TW', label: 'Taiwan' }, + { value: 'TZ', label: 'Tanzania' }, + { value: 'TH', label: 'Thailand' }, + { value: 'TT', label: 'Trinidad & Tobago' }, + { value: 'TN', label: 'Tunisia' }, + { value: 'TR', label: 'Turkey' }, + { value: 'AE', label: 'United Arab Emirates' }, + { value: 'GB', label: 'United Kingdom' }, + { value: 'US', label: 'United States' }, + { value: 'UY', label: 'Uruguay' }, + { value: 'UZ', label: 'Uzbekistan' }, + { value: 'VN', label: 'Vietnam' }, +]; diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..35facc6c2c068ddd3e281f33b5a354dc86aaf305 --- /dev/null +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -0,0 +1,1135 @@ +import Stripe from 'stripe'; +import { Injectable } from '@nestjs/common'; +import { Organization, User } from '@prisma/client'; +import { SubscriptionService } from '@gitroom/nestjs-libraries/database/prisma/subscriptions/subscription.service'; +import { OrganizationService } from '@gitroom/nestjs-libraries/database/prisma/organizations/organization.service'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { BillingSubscribeDto } from '@gitroom/nestjs-libraries/dtos/billing/billing.subscribe.dto'; +import { groupBy } from 'lodash'; +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'; +import { UsersService } from '@gitroom/nestjs-libraries/database/prisma/users/users.service'; +import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum'; + +const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || 'sk_nothing'); + +@Injectable() +export class StripeService { + constructor( + private _subscriptionService: SubscriptionService, + private _organizationService: OrganizationService, + private _userService: UsersService, + private _trackService: TrackService + ) {} + validateRequest(rawBody: Buffer, signature: string, endpointSecret: string) { + return stripe.webhooks.constructEvent(rawBody, signature, endpointSecret); + } + + async checkValidCard( + event: + | Stripe.CustomerSubscriptionCreatedEvent + | Stripe.CustomerSubscriptionUpdatedEvent + ) { + if (event.data.object.status === 'incomplete') { + return false; + } + + const getOrgFromCustomer = + await this._organizationService.getOrgByCustomerId( + event.data.object.customer as string + ); + + if (!getOrgFromCustomer?.allowTrial) { + return true; + } + + console.log('Checking card'); + + const paymentMethods = await stripe.paymentMethods.list({ + customer: event.data.object.customer as string, + }); + + // find the last one created + const latestMethod = paymentMethods.data.reduce( + (prev, current) => { + if (prev.created < current.created) { + return current; + } + return prev; + }, + { created: -100 } as Stripe.PaymentMethod + ); + + if (!latestMethod.id) { + return false; + } + + try { + const paymentIntent = await stripe.paymentIntents.create({ + amount: 100, + currency: 'usd', + payment_method: latestMethod.id, + customer: event.data.object.customer as string, + off_session: true, + capture_method: 'manual', // Authorize without capturing + confirm: true, // Confirm the PaymentIntent + }); + + if (paymentIntent.status !== 'requires_capture') { + console.error('Cant charge'); + await stripe.paymentMethods.detach(paymentMethods.data[0].id); + await stripe.subscriptions.cancel(event.data.object.id as string); + return false; + } + + await stripe.paymentIntents.cancel(paymentIntent.id as string); + return true; + } catch (err) { + try { + await stripe.paymentMethods.detach(paymentMethods.data[0].id); + await stripe.subscriptions.cancel(event.data.object.id as string); + } catch (err) { + /*dont do anything*/ + } + return false; + } + } + + async createSubscription(event: Stripe.CustomerSubscriptionCreatedEvent) { + const { + uniqueId, + billing, + period, + } = event.data.object.metadata as { + billing: 'STANDARD' | 'PRO'; + period: 'MONTHLY' | 'YEARLY'; + uniqueId: string; + }; + + try { + const check = await this.checkValidCard(event); + if (!check) { + return { ok: false }; + } + } catch (err) { + return { ok: false }; + } + + return this._subscriptionService.createOrUpdateSubscription( + event.data.object.status !== 'active', + uniqueId, + event.data.object.customer as string, + pricing[billing].channel!, + billing, + period, + event.data.object.cancel_at + ); + } + async updateSubscription(event: Stripe.CustomerSubscriptionUpdatedEvent) { + const { + uniqueId, + billing, + period, + } = event.data.object.metadata as { + billing: 'STANDARD' | 'PRO'; + period: 'MONTHLY' | 'YEARLY'; + uniqueId: string; + }; + + const check = await this.checkValidCard(event); + if (!check) { + return { ok: false }; + } + + return this._subscriptionService.createOrUpdateSubscription( + event.data.object.status !== 'active', + uniqueId, + event.data.object.customer as string, + pricing[billing].channel!, + billing, + period, + event.data.object.cancel_at + ); + } + + async deleteSubscription(event: Stripe.CustomerSubscriptionDeletedEvent) { + await this._subscriptionService.deleteSubscription( + event.data.object.customer as string + ); + } + + async createOrGetCustomer(organization: Organization) { + if (organization.paymentId) { + return organization.paymentId; + } + + const users = await this._organizationService.getTeam(organization.id); + const customer = await stripe.customers.create({ + email: users.users[0].user.email.indexOf('@') > -1 ? users.users[0].user.email : `${users.users[0].user.email}@postiz.com`, + name: organization.name, + }); + await this._subscriptionService.updateCustomerId( + organization.id, + customer.id + ); + return customer.id; + } + + async getPackages() { + const products = await stripe.prices.list({ + active: true, + expand: ['data.tiers', 'data.product'], + lookup_keys: [ + 'standard_monthly', + 'standard_yearly', + 'pro_monthly', + 'pro_yearly', + ], + }); + + const productsList = groupBy( + products.data.map((p) => ({ + name: (p.product as Stripe.Product)?.name, + recurring: p?.recurring?.interval!, + price: p?.tiers?.[0]?.unit_amount! / 100, + })), + 'recurring' + ); + + return { ...productsList }; + } + + async prorate(organizationId: string, body: BillingSubscribeDto) { + const org = await this._organizationService.getOrgById(organizationId); + const customer = await this.createOrGetCustomer(org!); + const priceData = pricing[body.billing]; + const allProducts = await stripe.products.list({ + active: true, + expand: ['data.prices'], + }); + + const findProduct = + allProducts.data.find( + (product) => product.name.toUpperCase() === body.billing.toUpperCase() + ) || + (await stripe.products.create({ + active: true, + name: body.billing, + })); + + const pricesList = await stripe.prices.list({ + active: true, + product: findProduct!.id, + }); + + const findPrice = + pricesList.data.find( + (p) => + p?.recurring?.interval?.toLowerCase() === + (body.period === 'MONTHLY' ? 'month' : 'year') && + p?.nickname === body.billing + ' ' + body.period && + p?.unit_amount === + (body.period === 'MONTHLY' + ? priceData.month_price + : priceData.year_price) * + 100 + ) || + (await stripe.prices.create({ + active: true, + product: findProduct!.id, + currency: 'usd', + nickname: body.billing + ' ' + body.period, + unit_amount: + (body.period === 'MONTHLY' + ? priceData.month_price + : priceData.year_price) * 100, + recurring: { + interval: body.period === 'MONTHLY' ? 'month' : 'year', + }, + })); + + const proration_date = Math.floor(Date.now() / 1000); + + const currentUserSubscription = { + data: ( + await stripe.subscriptions.list({ + customer, + status: 'all', + }) + ).data.filter((f) => f.status === 'active' || f.status === 'trialing'), + }; + + try { + const price = await stripe.invoices.createPreview({ + customer, + subscription: currentUserSubscription?.data?.[0]?.id, + subscription_details: { + proration_behavior: 'create_prorations', + billing_cycle_anchor: 'now', + items: [ + { + id: currentUserSubscription?.data?.[0]?.items?.data?.[0]?.id, + price: findPrice?.id!, + quantity: 1, + }, + ], + proration_date: proration_date, + }, + }); + + return { + price: price?.amount_remaining ? price?.amount_remaining / 100 : 0, + }; + } catch (err) { + return { price: 0 }; + } + } + + async getCustomerSubscriptions(organizationId: string) { + const org = (await this._organizationService.getOrgById(organizationId))!; + const customer = org.paymentId; + return stripe.subscriptions.list({ + customer: customer!, + status: 'all', + }); + } + + async setToCancel(organizationId: string) { + const id = makeId(10); + const org = await this._organizationService.getOrgById(organizationId); + const customer = await this.createOrGetCustomer(org!); + const currentUserSubscription = { + data: ( + await stripe.subscriptions.list({ + customer, + status: 'all', + expand: ['data.latest_invoice'], + }) + ).data.filter((f) => f.status !== 'canceled'), + }; + + const sub = currentUserSubscription.data[0]; + + // If the user is toggling back (un-cancelling), just remove the cancel + if (sub.cancel_at_period_end) { + const { cancel_at } = await stripe.subscriptions.update(sub.id, { + cancel_at_period_end: false, + metadata: { service: 'gitroom', id }, + }); + + return { + id, + cancel_at: cancel_at ? new Date(cancel_at * 1000) : undefined, + }; + } + + // Check if the latest invoice has a failed payment + const latestInvoice = sub.latest_invoice as Stripe.Invoice | null; + const hasFailedPayment = + sub.status === 'past_due' || + latestInvoice?.status === 'open' || + latestInvoice?.status === 'uncollectible'; + + if (hasFailedPayment) { + // Payment already failed — cancel immediately and delete subscription + await stripe.subscriptions.cancel(sub.id); + await this._subscriptionService.deleteSubscription(customer); + + return { + id, + cancel_at: new Date(), + }; + } + + // Payment succeeded — cancel at end of billing period + const { cancel_at } = await stripe.subscriptions.update(sub.id, { + cancel_at_period_end: true, + metadata: { service: 'gitroom', id }, + }); + + return { + id, + cancel_at: cancel_at ? new Date(cancel_at * 1000) : undefined, + }; + } + + async getCustomerByOrganizationId(organizationId: string) { + const org = (await this._organizationService.getOrgById(organizationId))!; + return org.paymentId; + } + + async createBillingPortalLink(customer: string) { + return stripe.billingPortal.sessions.create({ + customer, + return_url: process.env['FRONTEND_URL'] + '/billing', + }); + } + + /** + * Find an active promotion code with autoapply: true metadata + * Only returns codes that are active and not expired + * Returns the promotion code string (not the ID) for frontend auto-apply + */ + private async findAutoApplyPromotionCode(): Promise { + try { + const promotionCodes = await stripe.promotionCodes.list({ + active: true, + limit: 100, + }); + + const now = Math.floor(Date.now() / 1000); + + for (const promoCode of promotionCodes.data) { + const coupon = + typeof promoCode.promotion.coupon === 'string' + ? null + : promoCode.promotion.coupon; + + // Check if it has autoapply metadata set to true (check both promo and coupon metadata) + const autoApply = Object.assign( + {}, + promoCode.metadata, + coupon?.metadata + )?.autoapply; + if (autoApply !== 'true') continue; + + // Check if the promotion code has expired + if (promoCode.expires_at && promoCode.expires_at < now) continue; + + // Check if the coupon has expired (redeem_by) + if (coupon?.redeem_by && coupon.redeem_by < now) continue; + + // Check if max redemptions reached + if ( + promoCode.max_redemptions && + promoCode.times_redeemed >= promoCode.max_redemptions + ) + continue; + + // Found a valid auto-apply promotion code - return the code string for frontend + return promoCode.code; + } + + return null; + } catch (err) { + console.error('Error finding auto-apply promotion code:', err); + return null; + } + } + + private async createEmbeddedCheckout( + ud: string, + uniqueId: string, + customer: string, + body: BillingSubscribeDto, + price: string, + userId: string, + allowTrial: boolean + ) { + const user = await this._userService.getUserById(userId); + + try { + await stripe.customers.update(customer, { + email: user.email.indexOf('@') > -1 ? user.email : `${user.email}@postiz.com`, + ...(body.dub + ? { + metadata: { + dubCustomerExternalId: userId, + dubClickId: body.dub, + }, + } + : {}), + }); + } catch (err) {} + + // Check for auto-apply promotion code (only for monthly plans) + let autoApplyPromoCode: string | null = null; + if (body.period === 'MONTHLY') { + autoApplyPromoCode = await this.findAutoApplyPromotionCode(); + } + + const isUtm = body.utm ? `&utm_source=${body.utm}` : ''; + const { client_secret } = await stripe.checkout.sessions.create({ + ui_mode: 'custom', + customer, + return_url: + process.env['FRONTEND_URL'] + + `/launches?onboarding=true&trialStart=true&check=${uniqueId}${isUtm}`, + mode: 'subscription', + subscription_data: { + ...(allowTrial ? { trial_period_days: 7 } : {}), + metadata: { + service: 'gitroom', + ...body, + userId, + uniqueId, + ud, + }, + }, + ...(body.datafast_session_id && body.datafast_visitor_id + ? { + metadata: { + datafast_visitor_id: body.datafast_visitor_id, + datafast_session_id: body.datafast_session_id, + }, + } + : {}), + allow_promotion_codes: body.period === 'MONTHLY', + line_items: [ + { + price, + quantity: 1, + }, + ], + }); + + // Return auto-apply promo code for frontend to apply + return { + client_secret, + ...(autoApplyPromoCode ? { auto_apply_coupon: autoApplyPromoCode } : {}), + }; + } + + private async createCheckoutSession( + ud: string, + uniqueId: string, + customer: string, + body: BillingSubscribeDto, + price: string, + userId: string, + allowTrial: boolean + ) { + const isUtm = body.utm ? `&utm_source=${body.utm}` : ''; + + if (body.dub) { + await stripe.customers.update(customer, { + metadata: { + dubCustomerExternalId: userId, + dubClickId: body.dub, + }, + }); + } + + const { url } = await stripe.checkout.sessions.create({ + customer, + cancel_url: process.env['FRONTEND_URL'] + `/billing?cancel=true${isUtm}`, + success_url: + process.env['FRONTEND_URL'] + + `/launches?onboarding=true&trialStart=true&check=${uniqueId}${isUtm}`, + mode: 'subscription', + subscription_data: { + ...(allowTrial ? { trial_period_days: 7 } : {}), + metadata: { + service: 'gitroom', + ...body, + userId, + uniqueId, + ud, + }, + }, + allow_promotion_codes: body.period === 'MONTHLY', + line_items: [ + { + price, + quantity: 1, + }, + ], + }); + + return { url }; + } + + async finishTrial(paymentId: string) { + const list = ( + await stripe.subscriptions.list({ + customer: paymentId, + }) + ).data.filter((f) => f.status === 'trialing'); + + return stripe.subscriptions.update(list[0].id, { + trial_end: 'now', + }); + } + + async checkDiscount(customer: string) { + if (!process.env.STRIPE_DISCOUNT_ID) { + return false; + } + + const list = await stripe.charges.list({ + customer, + limit: 1, + }); + + if (!list.data.filter((f) => f.amount > 1000).length) { + return false; + } + + const currentUserSubscription = { + data: ( + await stripe.subscriptions.list({ + customer, + status: 'all', + expand: ['data.discounts'], + }) + ).data.find((f) => f.status === 'active' || f.status === 'trialing'), + }; + + if (!currentUserSubscription) { + return false; + } + + if ( + currentUserSubscription.data?.items.data[0]?.price.recurring?.interval === + 'year' || + currentUserSubscription.data?.discounts.length + ) { + return false; + } + + return true; + } + + async applyDiscount(customer: string) { + const check = this.checkDiscount(customer); + if (!check) { + return false; + } + + const currentUserSubscription = { + data: ( + await stripe.subscriptions.list({ + customer, + status: 'all', + expand: ['data.discounts'], + }) + ).data.find((f) => f.status === 'active' || f.status === 'trialing'), + }; + + await stripe.subscriptions.update(currentUserSubscription.data.id, { + discounts: [ + { + coupon: process.env.STRIPE_DISCOUNT_ID!, + }, + ], + }); + + return true; + } + + async checkSubscription(organizationId: string, subscriptionId: string) { + const orgValue = await this._subscriptionService.checkSubscription( + organizationId, + subscriptionId + ); + + if (orgValue) { + return 2; + } + + const getCustomerSubscriptions = await this.getCustomerSubscriptions( + organizationId + ); + if (getCustomerSubscriptions.data.length === 0) { + return 0; + } + + if ( + getCustomerSubscriptions.data.find( + (p) => p.metadata.uniqueId === subscriptionId + )?.canceled_at + ) { + return 1; + } + + return 0; + } + + async embedded( + uniqueId: string, + organizationId: string, + userId: string, + body: BillingSubscribeDto, + allowTrial: boolean + ) { + const id = makeId(10); + const priceData = pricing[body.billing]; + const org = await this._organizationService.getOrgById(organizationId); + const customer = await this.createOrGetCustomer(org!); + const allProducts = await stripe.products.list({ + active: true, + expand: ['data.prices'], + }); + + const findProduct = + allProducts.data.find( + (product) => product.name.toUpperCase() === body.billing.toUpperCase() + ) || + (await stripe.products.create({ + active: true, + name: body.billing, + })); + + const pricesList = await stripe.prices.list({ + active: true, + product: findProduct!.id, + }); + + const findPrice = + pricesList.data.find( + (p) => + p?.recurring?.interval?.toLowerCase() === + (body.period === 'MONTHLY' ? 'month' : 'year') && + p?.unit_amount === + (body.period === 'MONTHLY' + ? priceData.month_price + : priceData.year_price) * + 100 + ) || + (await stripe.prices.create({ + active: true, + product: findProduct!.id, + currency: 'usd', + nickname: body.billing + ' ' + body.period, + unit_amount: + (body.period === 'MONTHLY' + ? priceData.month_price + : priceData.year_price) * 100, + recurring: { + interval: body.period === 'MONTHLY' ? 'month' : 'year', + }, + })); + + return this.createEmbeddedCheckout( + uniqueId, + id, + customer, + body, + findPrice!.id, + userId, + allowTrial + ); + } + + async subscribe( + uniqueId: string, + organizationId: string, + userId: string, + body: BillingSubscribeDto, + allowTrial: boolean + ) { + const id = makeId(10); + const priceData = pricing[body.billing]; + const org = await this._organizationService.getOrgById(organizationId); + const customer = await this.createOrGetCustomer(org!); + const allProducts = await stripe.products.list({ + active: true, + expand: ['data.prices'], + }); + + const findProduct = + allProducts.data.find( + (product) => product.name.toUpperCase() === body.billing.toUpperCase() + ) || + (await stripe.products.create({ + active: true, + name: body.billing, + })); + + const pricesList = await stripe.prices.list({ + active: true, + product: findProduct!.id, + }); + + const findPrice = + pricesList.data.find( + (p) => + p?.recurring?.interval?.toLowerCase() === + (body.period === 'MONTHLY' ? 'month' : 'year') && + p?.unit_amount === + (body.period === 'MONTHLY' + ? priceData.month_price + : priceData.year_price) * + 100 + ) || + (await stripe.prices.create({ + active: true, + product: findProduct!.id, + currency: 'usd', + nickname: body.billing + ' ' + body.period, + unit_amount: + (body.period === 'MONTHLY' + ? priceData.month_price + : priceData.year_price) * 100, + recurring: { + interval: body.period === 'MONTHLY' ? 'month' : 'year', + }, + })); + + const getCurrentSubscriptions = + await this._subscriptionService.getSubscription(organizationId); + + if (!getCurrentSubscriptions) { + return this.createCheckoutSession( + uniqueId, + id, + customer, + body, + findPrice!.id, + userId, + allowTrial + ); + } + + const currentUserSubscription = { + data: ( + await stripe.subscriptions.list({ + customer, + status: 'all', + }) + ).data.filter((f) => f.status === 'active' || f.status === 'trialing'), + }; + + try { + await stripe.subscriptions.update(currentUserSubscription.data[0].id, { + cancel_at_period_end: false, + metadata: { + service: 'gitroom', + ...body, + userId, + id, + ud: uniqueId, + }, + proration_behavior: 'always_invoice', + items: [ + { + id: currentUserSubscription.data[0].items.data[0].id, + price: findPrice!.id, + quantity: 1, + }, + ], + }); + + return { id }; + } catch (err) { + const { url } = await this.createBillingPortalLink(customer); + return { + portal: url, + }; + } + } + + async paymentSucceeded(event: Stripe.InvoicePaymentSucceededEvent) { + // get subscription from payment + const subscriptionId = + event.data.object.parent?.subscription_details?.subscription; + if (!subscriptionId) { + return { ok: true }; + } + const subscription = await stripe.subscriptions.retrieve( + typeof subscriptionId === 'string' ? subscriptionId : subscriptionId.id + ); + + const { userId, ud } = subscription.metadata; + const user = await this._userService.getUserById(userId); + if (user && user.ip && user.agent) { + this._trackService.track(ud, user.ip, user.agent, TrackEnum.Purchase, { + value: event.data.object.amount_paid / 100, + }); + } + + return { ok: true }; + } + + async getCharges(organizationId: string) { + const org = await this._organizationService.getOrgById(organizationId); + if (!org?.paymentId) { + return []; + } + + const charges = await stripe.charges.list({ + customer: org.paymentId, + limit: 100, + }); + + const chargeList = charges.data + .filter((f) => f.status === 'succeeded') + .map((charge) => ({ + id: charge.id, + amount: charge.amount, + currency: charge.currency, + created: charge.created, + status: charge.status, + refunded: charge.refunded, + amount_refunded: charge.amount_refunded, + description: charge.description, + receipt_url: charge.receipt_url || null, + invoice: (charge as any).invoice || null, + })); + + const invoiceIds = chargeList + .map((c) => c.invoice) + .filter((id): id is string => !!id && typeof id === 'string'); + + const invoicePdfMap: Record = {}; + for (const invoiceId of invoiceIds) { + try { + const inv = await stripe.invoices.retrieve(invoiceId); + if (inv.invoice_pdf) { + invoicePdfMap[invoiceId] = inv.invoice_pdf; + } + } catch { + // ignore if invoice can't be fetched + } + } + + return chargeList.map((charge) => ({ + ...charge, + invoice_pdf: + charge.invoice && invoicePdfMap[charge.invoice as string] + ? invoicePdfMap[charge.invoice as string] + : null, + })); + } + + async refundCharges(organizationId: string, chargeIds: string[]) { + const org = await this._organizationService.getOrgById(organizationId); + if (!org?.paymentId) { + throw new Error('No payment customer found for this organization'); + } + + const refunded: string[] = []; + const failed: string[] = []; + + for (const chargeId of chargeIds) { + try { + await stripe.refunds.create({ charge: chargeId }); + refunded.push(chargeId); + } catch (err) { + failed.push(chargeId); + } + } + + return { refunded, failed }; + } + + async cancelSubscription(organizationId: string) { + const org = await this._organizationService.getOrgById(organizationId); + if (!org?.paymentId) { + throw new Error('No payment customer found for this organization'); + } + + const customer = org.paymentId; + + const subscriptions = ( + await stripe.subscriptions.list({ + customer, + status: 'all', + }) + ).data.filter((f) => f.status !== 'canceled'); + + if (!subscriptions.length) { + throw new Error('No active subscription found'); + } + + await stripe.subscriptions.cancel(subscriptions[0].id); + await this._subscriptionService.deleteSubscription(customer); + + return { cancelled: true }; + } + + async chatbaseRefundPreview(organizationId: string) { + const org = await this._organizationService.getOrgById(organizationId); + if (!org?.paymentId) { + return { + eligible: false as const, + reason: 'No payment customer found for this organization', + }; + } + + const customer = org.paymentId; + + const subscriptions = ( + await stripe.subscriptions.list({ + customer, + status: 'all', + }) + ).data.filter((f) => f.status !== 'canceled'); + + if (!subscriptions.length) { + return { + eligible: false as const, + reason: 'No active subscription found for this customer', + }; + } + + const charges = ( + await stripe.charges.list({ + customer, + limit: 100, + }) + ).data.filter((f) => f.status === 'succeeded'); + + if (charges.some((f) => f.refunded || f.amount_refunded > 0)) { + return { + eligible: false as const, + reason: 'A refund was already issued for this customer', + }; + } + + // only refund a charge that was created by the active subscription, + // never a one-off payment + let lastCharge: (typeof charges)[number] | undefined = undefined; + let chargeSubscription: (typeof subscriptions)[number] | undefined = + undefined; + + for (const charge of charges) { + const invoiceId = (charge as any).invoice; + if (!invoiceId || typeof invoiceId !== 'string') { + continue; + } + + try { + const invoice = await stripe.invoices.retrieve(invoiceId); + const invoiceSubscription = + invoice.parent?.subscription_details?.subscription; + const subscriptionId = + typeof invoiceSubscription === 'string' + ? invoiceSubscription + : invoiceSubscription?.id; + + chargeSubscription = subscriptions.find( + (f) => f.id === subscriptionId + ); + + if (chargeSubscription) { + lastCharge = charge; + break; + } + } catch { + // ignore if invoice can't be fetched + } + } + + if (!lastCharge || !chargeSubscription) { + return { + eligible: false as const, + reason: 'No subscription payment found for this customer', + }; + } + + const sixtyDaysAgo = Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60; + if (lastCharge.created < sixtyDaysAgo) { + return { + eligible: false as const, + reason: 'The last subscription payment is older than 60 days', + }; + } + + const interval = + chargeSubscription.items?.data?.[0]?.price?.recurring?.interval; + + // maximum refund is one month worth of the subscription + const amount = + interval === 'year' + ? Math.floor(lastCharge.amount / 12) + : lastCharge.amount; + + const currentSubscription = + await this._subscriptionService.getSubscriptionByOrganizationId( + organizationId + ); + + return { + eligible: true as const, + chargeId: lastCharge.id, + amount: amount / 100, + currency: lastCharge.currency, + tier: currentSubscription?.subscriptionTier || null, + period: currentSubscription?.period || null, + subscriptionIds: subscriptions.map((f) => f.id), + }; + } + + async chatbaseRefund(organizationId: string) { + const preview = await this.chatbaseRefundPreview(organizationId); + if (!preview.eligible) { + return { + refunded: false, + reason: preview.reason, + }; + } + + const org = await this._organizationService.getOrgById(organizationId); + + await stripe.refunds.create({ + charge: preview.chargeId, + amount: Math.round(preview.amount * 100), + metadata: { + reason: 'chatbase_refund', + organizationId, + }, + }); + + for (const subscriptionId of preview.subscriptionIds) { + await stripe.subscriptions.cancel(subscriptionId); + } + + if (preview.subscriptionIds.length) { + await this._subscriptionService.deleteSubscription(org?.paymentId!); + } + + return { + refunded: true, + amount: preview.amount, + currency: preview.currency, + subscriptionCancelled: preview.subscriptionIds.length > 0, + }; + } + + async lifetimeDeal(organizationId: string, code: string) { + const getCurrentSubscription = + await this._subscriptionService.getSubscriptionByOrganizationId( + organizationId + ); + if (getCurrentSubscription && !getCurrentSubscription?.isLifetime) { + throw new Error('You already have a non lifetime subscription'); + } + + try { + const testCode = AuthService.fixedDecryption(code); + const findCode = await this._subscriptionService.getCode(testCode); + if (findCode) { + return { + success: false, + }; + } + + const nextPackage = !getCurrentSubscription ? 'STANDARD' : 'PRO'; + const findPricing = pricing[nextPackage]; + + await this._subscriptionService.createOrUpdateSubscription( + false, + makeId(10), + organizationId, + getCurrentSubscription?.subscriptionTier === 'PRO' + ? getCurrentSubscription.totalChannels + 5 + : findPricing.channel!, + nextPackage, + 'MONTHLY', + null, + testCode, + organizationId + ); + return { + success: true, + }; + } catch (err) { + console.log(err); + return { + success: false, + }; + } + } +} diff --git a/libraries/nestjs-libraries/src/short-linking/providers/dub.ts b/libraries/nestjs-libraries/src/short-linking/providers/dub.ts new file mode 100644 index 0000000000000000000000000000000000000000..06e2fcddef764d0e28a093c8f0fe9910d8a2e4ef --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/providers/dub.ts @@ -0,0 +1,88 @@ +import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; + +const DUB_API_ENDPOINT = process.env.DUB_API_ENDPOINT || 'https://api.dub.co'; +const DUB_SHORT_LINK_DOMAIN = process.env.DUB_SHORT_LINK_DOMAIN || 'dub.sh'; + +const getOptions = () => ({ + headers: { + Authorization: `Bearer ${process.env.DUB_TOKEN}`, + 'Content-Type': 'application/json', + }, +}); + +export class Dub implements ShortLinking { + shortLinkDomain = DUB_SHORT_LINK_DOMAIN; + + async linksStatistics(links: string[]) { + return Promise.all( + links.map(async (link) => { + const response = await ( + await fetch( + `${DUB_API_ENDPOINT}/links/info?domain=${ + this.shortLinkDomain + }&key=${link.split('/').pop()}`, + getOptions() + ) + ).json(); + + return { + short: link, + original: response.url, + clicks: response.clicks, + }; + }) + ); + } + + async convertLinkToShortLink(id: string, link: string) { + return ( + await ( + await fetch(`${DUB_API_ENDPOINT}/links`, { + ...getOptions(), + method: 'POST', + body: JSON.stringify({ + url: link, + tenantId: id, + domain: this.shortLinkDomain, + }), + }) + ).json() + ).shortLink; + } + + async convertShortLinkToLink(shortLink: string) { + return await ( + await ( + await fetch( + `${DUB_API_ENDPOINT}/links/info?domain=${shortLink}`, + getOptions() + ) + ).json() + ).url; + } + + // recursive functions that gets maximum 100 links per request if there are less than 100 links stop the recursion + async getAllLinksStatistics( + id: string, + page = 1 + ): Promise<{ short: string; original: string; clicks: string }[]> { + const response = await ( + await fetch( + `${DUB_API_ENDPOINT}/links?tenantId=${id}&page=${page}&pageSize=100`, + getOptions() + ) + ).json(); + + const mapLinks = response.links.map((link: any) => ({ + short: link, + original: response.url, + clicks: response.clicks, + })); + + if (mapLinks.length < 100) { + return mapLinks; + } + + return [...mapLinks, ...(await this.getAllLinksStatistics(id, page + 1))]; + } +} diff --git a/libraries/nestjs-libraries/src/short-linking/providers/empty.ts b/libraries/nestjs-libraries/src/short-linking/providers/empty.ts new file mode 100644 index 0000000000000000000000000000000000000000..8afd53992925579cc5cb3ad10b0ebc0e3d713017 --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/providers/empty.ts @@ -0,0 +1,24 @@ +import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; + +export class Empty implements ShortLinking { + shortLinkDomain = 'empty'; + + async linksStatistics(links: string[]) { + return []; + } + + async convertLinkToShortLink(link: string) { + return ''; + } + + async convertShortLinkToLink(shortLink: string) { + return ''; + } + + getAllLinksStatistics( + id: string, + page: number + ): Promise<{ short: string; original: string; clicks: string }[]> { + return Promise.resolve([]); + } +} diff --git a/libraries/nestjs-libraries/src/short-linking/providers/kutt.ts b/libraries/nestjs-libraries/src/short-linking/providers/kutt.ts new file mode 100644 index 0000000000000000000000000000000000000000..118d37ec2760d931892bf90594ec728e6e4d7d0b --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/providers/kutt.ts @@ -0,0 +1,123 @@ +import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; + +const KUTT_API_ENDPOINT = process.env.KUTT_API_ENDPOINT || 'https://kutt.it/api/v2'; +const KUTT_SHORT_LINK_DOMAIN = process.env.KUTT_SHORT_LINK_DOMAIN || 'kutt.it'; + +const getOptions = () => ({ + headers: { + 'X-API-Key': process.env.KUTT_API_KEY, + 'Content-Type': 'application/json', + }, +}); + +export class Kutt implements ShortLinking { + shortLinkDomain = KUTT_SHORT_LINK_DOMAIN; + + async linksStatistics(links: string[]) { + return Promise.all( + links.map(async (link) => { + const linkId = link.split('/').pop(); + + try { + const response = await fetch( + `${KUTT_API_ENDPOINT}/links/${linkId}/stats`, + getOptions() + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + return { + short: link, + original: data.address || '', + clicks: data.lastDay?.stats?.reduce((total: number, stat: any) => total + stat, 0)?.toString() || '0', + }; + } catch (error) { + return { + short: link, + original: '', + clicks: '0', + }; + } + }) + ); + } + + async convertLinkToShortLink(id: string, link: string) { + try { + const response = await fetch(`${KUTT_API_ENDPOINT}/links`, { + ...getOptions(), + method: 'POST', + body: JSON.stringify({ + target: link, + domain: this.shortLinkDomain, + reuse: false, + }), + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.link; + } catch (error) { + throw new Error(`Failed to create short link: ${error}`); + } + } + + async convertShortLinkToLink(shortLink: string) { + const linkId = shortLink.split('/').pop(); + + try { + const response = await fetch( + `${KUTT_API_ENDPOINT}/links/${linkId}/stats`, + getOptions() + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data.address || ''; + } catch (error) { + throw new Error(`Failed to get original link: ${error}`); + } + } + + async getAllLinksStatistics( + id: string, + page = 1 + ): Promise<{ short: string; original: string; clicks: string }[]> { + try { + const response = await fetch( + `${KUTT_API_ENDPOINT}/links?limit=100&skip=${(page - 1) * 100}`, + getOptions() + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + const mapLinks = data.data?.map((link: any) => ({ + short: link.link, + original: link.address, + clicks: link.visit_count?.toString() || '0', + })) || []; + + if (mapLinks.length < 100) { + return mapLinks; + } + + return [...mapLinks, ...(await this.getAllLinksStatistics(id, page + 1))]; + } catch (error) { + return []; + } + } +} \ No newline at end of file diff --git a/libraries/nestjs-libraries/src/short-linking/providers/linkdrip.ts b/libraries/nestjs-libraries/src/short-linking/providers/linkdrip.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba970fee08b3def8bacb506bff6b5ac3845c5025 --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/providers/linkdrip.ts @@ -0,0 +1,56 @@ +import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; + +const LINK_DRIP_API_ENDPOINT = + process.env.LINK_DRIP_API_ENDPOINT || 'https://api.linkdrip.com/v1/'; +const LINK_DRIP_SHORT_LINK_DOMAIN = + process.env.LINK_DRIP_SHORT_LINK_DOMAIN || 'dripl.ink'; + +const getOptions = () => ({ + headers: { + Authorization: `Bearer ${process.env.LINK_DRIP_API_KEY}`, + 'Content-Type': 'application/json', + }, +}); + +export class LinkDrip implements ShortLinking { + shortLinkDomain = LINK_DRIP_SHORT_LINK_DOMAIN; + + async linksStatistics(links: string[]) { + return Promise.resolve([]); + } + + async convertLinkToShortLink(id: string, link: string) { + try { + const response = await fetch(`${LINK_DRIP_API_ENDPOINT}/create`, { + ...getOptions(), + method: 'POST', + body: JSON.stringify({ + target_url: link, + custom_domain: this.shortLinkDomain, + }), + }); + + if (!response.ok) { + throw new Error( + `Failed to create LinkDrip API short link with status: ${response.status}` + ); + } + + const data = await response.json(); + return data.link; + } catch (error) { + throw new Error(`Failed to create LinkDrip short link: ${error}`); + } + } + + async convertShortLinkToLink(shortLink: string) { + return ''; + } + + getAllLinksStatistics( + id: string, + page: number + ): Promise<{ short: string; original: string; clicks: string }[]> { + return Promise.resolve([]); + } +} diff --git a/libraries/nestjs-libraries/src/short-linking/providers/short.io.ts b/libraries/nestjs-libraries/src/short-linking/providers/short.io.ts new file mode 100644 index 0000000000000000000000000000000000000000..7db4c8bfbecf46e6fd59149ba511eb9e8336792b --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/providers/short.io.ts @@ -0,0 +1,96 @@ +import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; + +const options = { + headers: { + Authorization: `Bearer ${process.env.SHORT_IO_SECRET_KEY}`, + 'Content-Type': 'application/json', + }, +}; + +export class ShortIo implements ShortLinking { + shortLinkDomain = 'short.io'; + + async linksStatistics(links: string[]) { + return Promise.all( + links.map(async (link) => { + const url = `https://api.short.io/links/expand?domain=${ + this.shortLinkDomain + }&path=${link.split('/').pop()}`; + const response = await fetch(url, options).then((res) => res.json()); + + const linkStatisticsUrl = `https://statistics.short.io/statistics/link/${response.id}?period=last30&tz=UTC`; + + const statResponse = await fetch(linkStatisticsUrl, options).then( + (res) => res.json() + ); + + return { + short: response.shortURL, + original: response.originalURL, + clicks: statResponse.totalClicks, + }; + }) + ); + } + + async convertLinkToShortLink(id: string, link: string) { + const response = await fetch(`https://api.short.io/links`, { + ...options, + method: 'POST', + body: JSON.stringify({ + url: link, + tenantId: id, + domain: this.shortLinkDomain, + originalURL: link, + }), + }).then((res) => res.json()); + + return response.shortURL; + } + + async convertShortLinkToLink(shortLink: string) { + return await ( + await ( + await fetch( + `https://api.short.io/links/expand?domain=${ + this.shortLinkDomain + }&path=${shortLink.split('/').pop()}`, + options + ) + ).json() + ).originalURL; + } + + // recursive functions that gets maximum 100 links per request if there are less than 100 links stop the recursion + async getAllLinksStatistics( + id: string, + page = 1 + ): Promise<{ short: string; original: string; clicks: string }[]> { + const response = await ( + await fetch( + `https://api.short.io/api/links?domain_id=${id}&limit=150`, + options + ) + ).json(); + + const mapLinks = response.links.map(async (link: any) => { + const linkStatisticsUrl = `https://statistics.short.io/statistics/link/${response.id}?period=last30&tz=UTC`; + + const statResponse = await fetch(linkStatisticsUrl, options).then((res) => + res.json() + ); + + return { + short: link, + original: response.url, + clicks: statResponse.totalClicks, + }; + }); + + if (mapLinks.length < 100) { + return mapLinks; + } + + return [...mapLinks, ...(await this.getAllLinksStatistics(id, page + 1))]; + } +} diff --git a/libraries/nestjs-libraries/src/short-linking/short-linking.interface.ts b/libraries/nestjs-libraries/src/short-linking/short-linking.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..096620c891d28a16a3200ea08529a570ff14bb23 --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/short-linking.interface.ts @@ -0,0 +1,12 @@ +export interface ShortLinking { + shortLinkDomain: string; + linksStatistics( + links: string[] + ): Promise<{ short: string; original: string; clicks: string }[]>; + convertLinkToShortLink(id: string, link: string): Promise; + convertShortLinkToLink(shortLink: string): Promise; + getAllLinksStatistics( + id: string, + page: number + ): Promise<{ short: string; original: string; clicks: string }[]>; +} diff --git a/libraries/nestjs-libraries/src/short-linking/short.link.service.ts b/libraries/nestjs-libraries/src/short-linking/short.link.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b97a0b7faf6c1d49115e9089acba6f48841c4c7d --- /dev/null +++ b/libraries/nestjs-libraries/src/short-linking/short.link.service.ts @@ -0,0 +1,159 @@ +import { Dub } from '@gitroom/nestjs-libraries/short-linking/providers/dub'; +import { Empty } from '@gitroom/nestjs-libraries/short-linking/providers/empty'; +import { ShortLinking } from '@gitroom/nestjs-libraries/short-linking/short-linking.interface'; +import { Injectable } from '@nestjs/common'; +import { ShortIo } from './providers/short.io'; +import { Kutt } from './providers/kutt'; +import { LinkDrip } from './providers/linkdrip'; +import { uniq } from 'lodash'; +import striptags from 'striptags'; + +const getProvider = (): ShortLinking => { + if (process.env.DUB_TOKEN) { + return new Dub(); + } + + if (process.env.SHORT_IO_SECRET_KEY) { + return new ShortIo(); + } + + if (process.env.KUTT_API_KEY) { + return new Kutt(); + } + + if (process.env.LINK_DRIP_API_KEY) { + return new LinkDrip(); + } + + return new Empty(); +}; + +@Injectable() +export class ShortLinkService { + static provider = getProvider(); + + askShortLinkedin(messages: string[]): boolean { + if (ShortLinkService.provider.shortLinkDomain === 'empty') { + return false; + } + + const mergeMessages = messages.join(' '); + const urlRegex = + /(https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*))/gm; + const urls = mergeMessages.match(urlRegex); + if (!urls) { + // No URLs found, return the original text + return false; + } + + return urls.some( + (url) => url.indexOf(ShortLinkService.provider.shortLinkDomain) === -1 + ); + } + + async convertTextToShortLinks(id: string, messagesList: string[]) { + if (ShortLinkService.provider.shortLinkDomain === 'empty') { + return messagesList; + } + + const messages = messagesList.map((text) => { + return text + .replace(/&/g, '&') + .replace(/?/g, '?') + .replace(/#/g, '#'); + }); + + const urlRegex = + /(https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*))/gm; + return Promise.all( + messages.map(async (text) => { + const urls = uniq(text.match(urlRegex)); + if (!urls) { + // No URLs found, return the original text + return text; + } + + const replacementMap: Record = {}; + + // Process each URL asynchronously + await Promise.all( + urls.map(async (url) => { + if (url.indexOf(ShortLinkService.provider.shortLinkDomain) === -1) { + replacementMap[url] = + await ShortLinkService.provider.convertLinkToShortLink(id, url); + } else { + replacementMap[url] = url; // Keep the original URL if it matches the prefix + } + }) + ); + + // Replace the URLs in the text with their replacements + return text.replace(urlRegex, (url) => replacementMap[url]); + }) + ); + } + + async convertShortLinksToLinks(messages: string[]) { + if (ShortLinkService.provider.shortLinkDomain === 'empty') { + return messages; + } + + const urlRegex = /https?:\/\/[^\s/$.?#].[^\s]*/g; + return Promise.all( + messages.map(async (text) => { + const urls = text.match(urlRegex); + if (!urls) { + // No URLs found, return the original text + return text; + } + + const replacementMap: Record = {}; + + // Process each URL asynchronously + await Promise.all( + urls.map(async (url) => { + if (url.indexOf(ShortLinkService.provider.shortLinkDomain) > -1) { + replacementMap[url] = + await ShortLinkService.provider.convertShortLinkToLink(url); + } else { + replacementMap[url] = url; // Keep the original URL if it matches the prefix + } + }) + ); + + // Replace the URLs in the text with their replacements + return text.replace(urlRegex, (url) => replacementMap[url]); + }) + ); + } + + async getStatistics(messages: string[]) { + if (ShortLinkService.provider.shortLinkDomain === 'empty') { + return []; + } + + const mergeMessages = messages.join(' '); + const regex = new RegExp( + `https?://${ShortLinkService.provider.shortLinkDomain.replace( + '.', + '\\.' + )}/[^\\s]*`, + 'g' + ); + const urls = striptags(mergeMessages).match(regex); + if (!urls) { + // No URLs found, return the original text + return []; + } + + return ShortLinkService.provider.linksStatistics(urls); + } + + async getAllLinks(id: string) { + if (ShortLinkService.provider.shortLinkDomain === 'empty') { + return []; + } + + return ShortLinkService.provider.getAllLinksStatistics(id, 1); + } +} diff --git a/libraries/nestjs-libraries/src/temporal/infinite.workflow.register.ts b/libraries/nestjs-libraries/src/temporal/infinite.workflow.register.ts new file mode 100644 index 0000000000000000000000000000000000000000..405bac5e3e901d5b95b3fd950925fa11e8a338e3 --- /dev/null +++ b/libraries/nestjs-libraries/src/temporal/infinite.workflow.register.ts @@ -0,0 +1,31 @@ +import { Global, Injectable, Module, OnModuleInit } from '@nestjs/common'; +import { TemporalService } from 'nestjs-temporal-core'; + +@Injectable() +export class InfiniteWorkflowRegister implements OnModuleInit { + constructor(private _temporalService: TemporalService) {} + + async onModuleInit(): Promise { + if (!!process.env.RUN_CRON) { + try { + await this._temporalService.client + ?.getRawClient() + ?.workflow?.start('missingPostWorkflow', { + workflowId: 'missing-post-workflow', + taskQueue: 'main', + }); + } catch (err) {} + } + } +} + +@Global() +@Module({ + imports: [], + controllers: [], + providers: [InfiniteWorkflowRegister], + get exports() { + return this.providers; + }, +}) +export class InfiniteWorkflowRegisterModule {} diff --git a/libraries/nestjs-libraries/src/temporal/temporal.module.ts b/libraries/nestjs-libraries/src/temporal/temporal.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..85c54bfd8a7e14576cdea1e0363d92ad563a9283 --- /dev/null +++ b/libraries/nestjs-libraries/src/temporal/temporal.module.ts @@ -0,0 +1,83 @@ +import { TemporalModule } from 'nestjs-temporal-core'; +import { socialIntegrationList } from '@gitroom/nestjs-libraries/integrations/integration.manager'; + +export const getTemporalModule = ( + isWorkers: boolean, + path?: string, + activityClasses?: any[] +) => { + // Queues this worker server should NOT run, comma-separated + // (e.g. EXCLUDE_QUEUE="reddit,x,twitch"). Use it to pin a queue to a single + // server: exclude it on every server except the one that should own it. + // Meant for the providers whose concurrency is too low to split (limit 1). + const excludeQueues = (process.env.EXCLUDE_QUEUE || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + // How many worker servers share each (non-excluded) queue. Per-server + // concurrency is divided by this so the GLOBAL concurrency stays correct. + // 1 server => 1 (full), 2 servers => 2 (half each), 3 servers => 3, etc. + const divider = Math.max( + 1, + Number(process.env.WORKER_CONCURRENCY_DIVIDER) || 1 + ); + + return TemporalModule.register({ + isGlobal: true, + connection: { + address: process.env.TEMPORAL_ADDRESS || 'localhost:7233', + ...(process.env.TEMPORAL_TLS === 'true' ? { tls: true } : {}), + ...(process.env.TEMPORAL_API_KEY + ? { apiKey: process.env.TEMPORAL_API_KEY } + : {}), + namespace: process.env.TEMPORAL_NAMESPACE || 'default', + }, + taskQueue: 'main', + logLevel: 'error', + ...(isWorkers + ? { + workers: [ + { identifier: 'main', maxConcurrentJob: undefined }, + ...socialIntegrationList, + ] + .filter((f) => f.identifier.indexOf('-') === -1) + .map((integration) => ({ + integration, + taskQueue: integration.identifier.split('-')[0], + })) + .filter(({ taskQueue }) => !excludeQueues.includes(taskQueue)) + .map(({ integration, taskQueue }) => { + // Split the per-provider cap across the servers sharing this + // queue. Floor (never below 1) so the global total never exceeds + // the provider's limit. Providers whose limit is smaller than the + // server count must be pinned via EXCLUDE_QUEUE instead. + const concurrency = integration.maxConcurrentJob + ? Math.max( + 1, + Math.floor(integration.maxConcurrentJob / divider) + ) + : undefined; + + return { + taskQueue, + workflowsPath: path!, + activityClasses: activityClasses!, + autoStart: true, + ...(concurrency + ? { + workerOptions: { + maxConcurrentActivityTaskExecutions: concurrency, + }, + } + : { + workerOptions: { + maxConcurrentActivityTaskExecutions: 1000000, + }, + }), + }; + }), + } + : {}), + }); +}; diff --git a/libraries/nestjs-libraries/src/temporal/temporal.register.ts b/libraries/nestjs-libraries/src/temporal/temporal.register.ts new file mode 100644 index 0000000000000000000000000000000000000000..10fe7a7e3cdf3410b9ab9522415780e726f33686 --- /dev/null +++ b/libraries/nestjs-libraries/src/temporal/temporal.register.ts @@ -0,0 +1,48 @@ +import { Global, Injectable, Module, OnModuleInit } from '@nestjs/common'; +import { TemporalService } from 'nestjs-temporal-core'; +import { Connection } from '@temporalio/client'; + +@Injectable() +export class TemporalRegister implements OnModuleInit { + constructor(private _client: TemporalService) {} + + async onModuleInit(): Promise { + if (process.env.TEMPORAL_TLS === 'true') { + return; + } + const connection = this._client?.client?.getRawClient() + ?.connection as Connection; + + const { customAttributes } = + await connection.operatorService.listSearchAttributes({ + namespace: process.env.TEMPORAL_NAMESPACE || 'default', + }); + + const neededAttribute = ['organizationId', 'postId']; + const missingAttributes = neededAttribute.filter( + (attr) => !customAttributes[attr] + ); + + if (missingAttributes.length > 0) { + await connection.operatorService.addSearchAttributes({ + namespace: process.env.TEMPORAL_NAMESPACE || 'default', + searchAttributes: missingAttributes.reduce((all, current) => { + // @ts-ignore + all[current] = 1; + return all; + }, {}), + }); + } + } +} + +@Global() +@Module({ + imports: [], + controllers: [], + providers: [TemporalRegister], + get exports() { + return this.providers; + }, +}) +export class TemporalRegisterMissingSearchAttributesModule {} diff --git a/libraries/nestjs-libraries/src/temporal/temporal.search.attribute.ts b/libraries/nestjs-libraries/src/temporal/temporal.search.attribute.ts new file mode 100644 index 0000000000000000000000000000000000000000..f037efd1284b5e9c764006e5b374b00cecf356d1 --- /dev/null +++ b/libraries/nestjs-libraries/src/temporal/temporal.search.attribute.ts @@ -0,0 +1,14 @@ +import { + defineSearchAttributeKey, + SearchAttributeType, +} from '@temporalio/common'; + +export const organizationId = defineSearchAttributeKey( + 'organizationId', + SearchAttributeType.TEXT +); + +export const postId = defineSearchAttributeKey( + 'postId', + SearchAttributeType.TEXT +); diff --git a/libraries/nestjs-libraries/src/throttler/throttler.provider.ts b/libraries/nestjs-libraries/src/throttler/throttler.provider.ts new file mode 100644 index 0000000000000000000000000000000000000000..72b218d559de3b3ec26e0ebeba5bd4962960c840 --- /dev/null +++ b/libraries/nestjs-libraries/src/throttler/throttler.provider.ts @@ -0,0 +1,25 @@ +import { ThrottlerGuard } from '@nestjs/throttler'; +import { ExecutionContext, Injectable } from '@nestjs/common'; +import { Request } from 'express'; + +@Injectable() +export class ThrottlerBehindProxyGuard extends ThrottlerGuard { + public override async canActivate( + context: ExecutionContext + ): Promise { + const { url, method } = context.switchToHttp().getRequest(); + if (method === 'POST' && url.includes('/public/v1/posts')) { + return super.canActivate(context); + } + + return true; + } + + protected override async getTracker( + req: Record + ): Promise { + return ( + req.org.id + '_' + (req.url.indexOf('/posts') > -1 ? 'posts' : 'other') + ); + } +} diff --git a/libraries/nestjs-libraries/src/track/track.service.ts b/libraries/nestjs-libraries/src/track/track.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..d74c213885f7dc36db9d66f825d78c887d75ceea --- /dev/null +++ b/libraries/nestjs-libraries/src/track/track.service.ts @@ -0,0 +1,85 @@ +import { TrackEnum } from '@gitroom/nestjs-libraries/user/track.enum'; +import { User } from '@prisma/client'; +import { Injectable } from '@nestjs/common'; +import { + ServerEvent, + EventRequest, + UserData, + CustomData, + FacebookAdsApi, +} from 'facebook-nodejs-business-sdk'; +import { createHash } from 'crypto'; + +const access_token = process.env.FACEBOOK_PIXEL_ACCESS_TOKEN!; +const pixel_id = process.env.NEXT_PUBLIC_FACEBOOK_PIXEL!; + +if (access_token && pixel_id) { + FacebookAdsApi.init(access_token || ''); +} + +@Injectable() +export class TrackService { + private hashValue(value: string) { + return createHash('sha256').update(value).digest('hex'); + } + track( + uniqueId: string, + ip: string, + agent: string, + tt: TrackEnum, + additional: Record, + fbclid?: string, + user?: User + ) { + if (!access_token || !pixel_id) { + return; + } + // @ts-ignore + const current_timestamp = Math.floor(new Date() / 1000); + + const userData = new UserData(); + if (ip || user?.ip) { + userData.setClientIpAddress(ip || user?.ip || ''); + } + + if (agent || user?.agent) { + userData.setClientUserAgent(agent || user?.agent || ''); + } + if (fbclid) { + userData.setFbc(fbclid); + } + + if (user && user.email) { + userData.setEmail(this.hashValue(user.email)); + } + + let customData = null; + if (additional?.value) { + customData = new CustomData(); + customData.setValue(additional.value).setCurrency('USD'); + } + + const serverEvent = new ServerEvent() + .setEventName(TrackEnum[tt]) + .setEventTime(current_timestamp) + .setActionSource('website'); + + if (user && user.id) { + serverEvent.setEventId(uniqueId || user.id); + } + + if (userData) { + serverEvent.setUserData(userData); + } + if (customData) { + serverEvent.setCustomData(customData); + } + + const eventsData = [serverEvent]; + const eventRequest = new EventRequest(access_token, pixel_id).setEvents( + eventsData + ); + + return eventRequest.execute(); + } +} diff --git a/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts b/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts new file mode 100644 index 0000000000000000000000000000000000000000..218d40eede9d83831e8ee39072d31f8b3003462c --- /dev/null +++ b/libraries/nestjs-libraries/src/upload/cloudflare.storage.ts @@ -0,0 +1,169 @@ +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import 'multer'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import mime from 'mime-types'; +// @ts-ignore +import { getExtension } from 'mime'; +import { IUploadProvider } from './upload.interface'; +import axios from 'axios'; +import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; +import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import { parseDataUrl } from '@gitroom/nestjs-libraries/upload/data.url'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromBuffer } = require('file-type'); + +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/tiff', + 'video/mp4', + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', +]); + +class CloudflareStorage implements IUploadProvider { + private _client: S3Client; + + constructor( + accountID: string, + accessKey: string, + secretKey: string, + private region: string, + private _bucketName: string, + private _uploadUrl: string + ) { + this._client = new S3Client({ + endpoint: `https://${accountID}.r2.cloudflarestorage.com`, + region, + credentials: { + accessKeyId: accessKey, + secretAccessKey: secretKey, + }, + requestChecksumCalculation: 'WHEN_REQUIRED', + }); + + this._client.middlewareStack.add( + (next) => + async (args): Promise => { + const request = args.request as RequestInit; + + // Remove checksum headers + const headers = request.headers as Record; + delete headers['x-amz-checksum-crc32']; + delete headers['x-amz-checksum-crc32c']; + delete headers['x-amz-checksum-sha1']; + delete headers['x-amz-checksum-sha256']; + request.headers = headers; + + Object.entries(request.headers).forEach( + // @ts-ignore + ([key, value]: [string, string]): void => { + if (!request.headers) { + request.headers = {}; + } + (request.headers as Record)[key] = value; + } + ); + + return next(args); + }, + { step: 'build', name: 'customHeaders' } + ); + } + + async uploadSimple(path: string) { + const dataUrl = path.startsWith('data:') ? parseDataUrl(path) : null; + + let body: Buffer; + if (dataUrl) { + body = dataUrl.buffer; + } else { + if (!(await isSafePublicHttpsUrl(path))) { + throw new Error('Unsafe URL'); + } + const loadImage = await fetch(path, { + // @ts-ignore — undici option, not in lib.dom fetch types + dispatcher: ssrfSafeDispatcher, + }); + body = Buffer.from(await loadImage.arrayBuffer()); + } + const detected = await fromBuffer(body); + if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) { + throw new Error('Unsupported file type.'); + } + const extension = detected.ext; + const safeContentType = detected.mime; + const id = makeId(10); + + const params = { + Bucket: this._bucketName, + Key: `${id}.${extension}`, + Body: body, + ContentType: safeContentType, + ChecksumMode: 'DISABLED', + }; + + const command = new PutObjectCommand({ ...params }); + await this._client.send(command); + + return `${this._uploadUrl}/${id}.${extension}`; + } + + async uploadFile(file: Express.Multer.File): Promise { + try { + const detected = await fromBuffer(file.buffer); + if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) { + throw new Error('Unsupported file type.'); + } + const id = makeId(10); + const extension = detected.ext; + const safeContentType = detected.mime; + + // Create the PutObjectCommand to upload the file to Cloudflare R2 + const command = new PutObjectCommand({ + Bucket: this._bucketName, + ACL: 'public-read', + Key: `${id}.${extension}`, + Body: file.buffer, + ContentType: safeContentType, + }); + + await this._client.send(command); + + return { + filename: `${id}.${extension}`, + mimetype: file.mimetype, + size: file.size, + buffer: file.buffer, + originalname: `${id}.${extension}`, + fieldname: 'file', + path: `${this._uploadUrl}/${id}.${extension}`, + destination: `${this._uploadUrl}/${id}.${extension}`, + encoding: '7bit', + stream: file.buffer as any, + }; + } catch (err) { + console.error('Error uploading file to Cloudflare R2:', err); + throw err; + } + } + + // Implement the removeFile method from IUploadProvider + async removeFile(filePath: string): Promise { + // const fileName = filePath.split('/').pop(); // Extract the filename from the path + // const command = new DeleteObjectCommand({ + // Bucket: this._bucketName, + // Key: fileName, + // }); + // await this._client.send(command); + } +} + +export { CloudflareStorage }; +export default CloudflareStorage; diff --git a/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts b/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts new file mode 100644 index 0000000000000000000000000000000000000000..49c414be5c65103e0c0a3afbbb4355c28b58fb5d --- /dev/null +++ b/libraries/nestjs-libraries/src/upload/custom.upload.validation.ts @@ -0,0 +1,68 @@ +import { + BadRequestException, + Injectable, + PipeTransform, +} from '@nestjs/common'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromBuffer } = require('file-type'); + +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/tiff', + 'video/mp4', +]); + +@Injectable() +export class CustomFileValidationPipe implements PipeTransform { + async transform(value: any) { + if (!value || typeof value !== 'object') { + return value; + } + + // Skip non-file parameters (org, body, query, etc.) + if (!('buffer' in value) && !('mimetype' in value) && !('fieldname' in value)) { + return value; + } + + if (!value.buffer || !Buffer.isBuffer(value.buffer)) { + throw new BadRequestException('Invalid file upload.'); + } + + const detected = await fromBuffer(value.buffer); + if (!detected || !ALLOWED_MIME_TYPES.has(detected.mime)) { + throw new BadRequestException('Unsupported file type.'); + } + + const maxSize = getMaxSize(detected.mime); + if (value.size > maxSize) { + throw new BadRequestException( + `File size exceeds the maximum allowed size of ${maxSize} bytes.` + ); + } + + value.mimetype = detected.mime; + const safeBase = (value.originalname || 'upload') + .replace(/\.[^./\\]*$/, '') + .replace(/[\\/]/g, '_') + .slice(0, 100) || 'upload'; + value.originalname = `${safeBase}.${detected.ext}`; + + return value; + } + +} + +export function getMaxSize(mimeType: string): number { + if (mimeType.startsWith('image/')) { + return 10 * 1024 * 1024; // 10 MB + } else if (mimeType.startsWith('video/')) { + return 1024 * 1024 * 1024; // 1 GB + } else { + throw new BadRequestException('Unsupported file type.'); + } +} diff --git a/libraries/nestjs-libraries/src/upload/data.url.ts b/libraries/nestjs-libraries/src/upload/data.url.ts new file mode 100644 index 0000000000000000000000000000000000000000..2a56c943bdce42f265773f1360f384ffe15828d7 --- /dev/null +++ b/libraries/nestjs-libraries/src/upload/data.url.ts @@ -0,0 +1,26 @@ +/** + * Parses a `data:` URL into a Buffer and its mime type. + * + * gpt-image models only return base64 image data (never a URL), so the image + * generation flow hands `uploadSimple` a data URL instead of a remote URL. + * + * Returns null when the value is not a valid data URL. + */ +export function parseDataUrl( + value: string +): { buffer: Buffer; mime: string } | null { + const match = /^data:([^;,]+)?(;base64)?,([\s\S]*)$/.exec(value); + if (!match) { + return null; + } + + const mime = match[1] || 'application/octet-stream'; + const isBase64 = !!match[2]; + const data = match[3]; + + const buffer = isBase64 + ? Buffer.from(data, 'base64') + : Buffer.from(decodeURIComponent(data), 'utf-8'); + + return { buffer, mime }; +} diff --git a/libraries/nestjs-libraries/src/upload/local.storage.ts b/libraries/nestjs-libraries/src/upload/local.storage.ts new file mode 100644 index 0000000000000000000000000000000000000000..b237aaf7e1744c2180ad3a5d9c84b5e4f190d4c8 --- /dev/null +++ b/libraries/nestjs-libraries/src/upload/local.storage.ts @@ -0,0 +1,128 @@ +import { IUploadProvider } from './upload.interface'; +import { mkdirSync, unlink, writeFileSync } from 'fs'; +import { isSafePublicHttpsUrl } from '@gitroom/nestjs-libraries/dtos/webhooks/webhook.url.validator'; +import { ssrfSafeDispatcher } from '@gitroom/nestjs-libraries/dtos/webhooks/ssrf.safe.dispatcher'; +import { parseDataUrl } from '@gitroom/nestjs-libraries/upload/data.url'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromBuffer } = require('file-type'); + +const LOCAL_STORAGE_ALLOWED_MIME = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/tiff', + 'video/mp4', + 'audio/mpeg', + 'audio/mp4', + 'audio/wav', + 'audio/ogg', +]); +export class LocalStorage implements IUploadProvider { + constructor(private uploadDirectory: string) {} + + async uploadSimple(path: string) { + const dataUrl = path.startsWith('data:') ? parseDataUrl(path) : null; + + let body: Buffer; + if (dataUrl) { + body = dataUrl.buffer; + } else { + if (!(await isSafePublicHttpsUrl(path))) { + throw new Error('Unsafe URL'); + } + const loadImage = await fetch(path, { + // @ts-ignore — undici option, not in lib.dom fetch types + dispatcher: ssrfSafeDispatcher, + }); + body = Buffer.from(await loadImage.arrayBuffer()); + } + + // Never trust the claimed mime/extension (data URL header, remote + // content-type, or URL path): sniff the real type from the bytes and + // only accept the allow-list, otherwise an attacker could write an + // arbitrary file (e.g. .html/.svg with embedded script) into the + // publicly served uploads directory on the app's own origin. + const detected = await fromBuffer(body); + if (!detected || !LOCAL_STORAGE_ALLOWED_MIME.has(detected.mime)) { + throw new Error('Unsupported file type.'); + } + const findExtension = detected.ext; + + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + + const innerPath = `/${year}/${month}/${day}`; + const dir = `${this.uploadDirectory}${innerPath}`; + mkdirSync(dir, { recursive: true }); + + const randomName = Array(32) + .fill(null) + .map(() => Math.round(Math.random() * 16).toString(16)) + .join(''); + + const filePath = `${dir}/${randomName}.${findExtension}`; + const publicPath = `${innerPath}/${randomName}.${findExtension}`; + // Logic to save the file to the filesystem goes here + writeFileSync(filePath, body); + + return process.env.FRONTEND_URL + '/uploads' + publicPath; + } + + async uploadFile(file: Express.Multer.File): Promise { + try { + const detected = await fromBuffer(file.buffer); + if (!detected || !LOCAL_STORAGE_ALLOWED_MIME.has(detected.mime)) { + throw new Error('Unsupported file type.'); + } + const safeExt = `.${detected.ext}`; + const safeMime = detected.mime; + + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + + const innerPath = `/${year}/${month}/${day}`; + const dir = `${this.uploadDirectory}${innerPath}`; + mkdirSync(dir, { recursive: true }); + + const randomName = Array(32) + .fill(null) + .map(() => Math.round(Math.random() * 16).toString(16)) + .join(''); + + const filePath = `${dir}/${randomName}${safeExt}`; + const publicPath = `${innerPath}/${randomName}${safeExt}`; + + writeFileSync(filePath, file.buffer); + + return { + filename: `${randomName}${safeExt}`, + path: process.env.FRONTEND_URL + '/uploads' + publicPath, + mimetype: safeMime, + originalname: `${randomName}${safeExt}`, + }; + } catch (err) { + console.error('Error uploading file to Local Storage:', err); + throw err; + } + } + + async removeFile(filePath: string): Promise { + // Logic to remove the file from the filesystem goes here + return new Promise((resolve, reject) => { + unlink(filePath, (err) => { + if (err) { + reject(err); + } else { + resolve(); + } + }); + }); + } +} diff --git a/libraries/nestjs-libraries/src/upload/r2.uploader.ts b/libraries/nestjs-libraries/src/upload/r2.uploader.ts new file mode 100644 index 0000000000000000000000000000000000000000..a0ba225001ff46c47512e8d3f32617405bf2218f --- /dev/null +++ b/libraries/nestjs-libraries/src/upload/r2.uploader.ts @@ -0,0 +1,282 @@ +import { + UploadPartCommand, + S3Client, + ListPartsCommand, + CreateMultipartUploadCommand, + CompleteMultipartUploadCommand, + AbortMultipartUploadCommand, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { Request, Response } from 'express'; +import crypto from 'crypto'; +import path from 'path'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromBuffer } = require('file-type'); + +const ALLOWED_EXT_TO_MIME: Record = { + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.png': 'image/png', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.bmp': 'image/bmp', + '.tif': 'image/tiff', + '.tiff': 'image/tiff', + '.mp4': 'video/mp4', +}; + +function normalizeExtension(filename: string): string | null { + const ext = path.extname(filename || '').toLowerCase(); + return ALLOWED_EXT_TO_MIME[ext] ? ext : null; +} + +const { + CLOUDFLARE_ACCOUNT_ID, + CLOUDFLARE_ACCESS_KEY, + CLOUDFLARE_SECRET_ACCESS_KEY, + CLOUDFLARE_BUCKETNAME, + CLOUDFLARE_BUCKET_URL, +} = process.env; + +const R2 = new S3Client({ + region: 'auto', + endpoint: `https://${CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: CLOUDFLARE_ACCESS_KEY!, + secretAccessKey: CLOUDFLARE_SECRET_ACCESS_KEY!, + }, +}); + +// Function to generate a random string +function generateRandomString() { + return makeId(20); +} + +export default async function handleR2Upload( + endpoint: string, + req: Request, + res: Response +) { + switch (endpoint) { + case 'create-multipart-upload': + return createMultipartUpload(req, res); + case 'prepare-upload-parts': + return prepareUploadParts(req, res); + case 'complete-multipart-upload': + return completeMultipartUpload(req, res); + case 'list-parts': + return listParts(req, res); + case 'abort-multipart-upload': + return abortMultipartUpload(req, res); + case 'sign-part': + return signPart(req, res); + } + return res.status(404).end(); +} + +export async function simpleUpload( + data: Buffer, + originalFilename: string, + _contentType: string +) { + const detected = await fromBuffer(data); + if (!detected || !Object.values(ALLOWED_EXT_TO_MIME).includes(detected.mime)) { + throw new Error('Unsupported file type.'); + } + const fileExtension = `.${detected.ext}`; + const safeContentType = detected.mime; + const randomFilename = generateRandomString() + fileExtension; + + const params = { + Bucket: CLOUDFLARE_BUCKETNAME, + Key: randomFilename, + Body: data, + ContentType: safeContentType, + }; + + const command = new PutObjectCommand({ ...params }); + await R2.send(command); + + return CLOUDFLARE_BUCKET_URL + '/' + randomFilename; +} + +export async function createMultipartUpload(req: Request, res: Response) { + const { file, fileHash } = req.body; + const safeExt = normalizeExtension(file?.name || ''); + if (!safeExt) { + return res.status(400).json({ message: 'Unsupported file type.' }); + } + const safeContentType = ALLOWED_EXT_TO_MIME[safeExt]; + const randomFilename = generateRandomString() + safeExt; + + try { + const params = { + Bucket: CLOUDFLARE_BUCKETNAME, + Key: `${randomFilename}`, + ContentType: safeContentType, + Metadata: { + 'x-amz-meta-file-hash': fileHash, + }, + }; + + const command = new CreateMultipartUploadCommand({ ...params }); + const response = await R2.send(command); + return res.status(200).json({ + uploadId: response.UploadId, + key: response.Key, + }); + } catch (err) { + console.log('Error', err); + return res.status(500).json({ source: { status: 500 } }); + } +} + +export async function prepareUploadParts(req: Request, res: Response) { + const { partData } = req.body; + + const parts = partData.parts; + + const response = { + presignedUrls: {}, + }; + + for (const part of parts) { + try { + const params = { + Bucket: CLOUDFLARE_BUCKETNAME, + Key: partData.key, + PartNumber: part.number, + UploadId: partData.uploadId, + }; + const command = new UploadPartCommand({ ...params }); + const url = await getSignedUrl(R2, command, { expiresIn: 3600 }); + + // @ts-ignore + response.presignedUrls[part.number] = url; + } catch (err) { + console.log('Error', err); + return res.status(500).json(err); + } + } + + return res.status(200).json(response); +} + +export async function listParts(req: Request, res: Response) { + const { key, uploadId } = req.body; + + try { + const params = { + Bucket: CLOUDFLARE_BUCKETNAME, + Key: key, + UploadId: uploadId, + }; + const command = new ListPartsCommand({ ...params }); + const response = await R2.send(command); + + return res.status(200).json(response['Parts']); + } catch (err) { + console.log('Error', err); + return res.status(500).json(err); + } +} + +export async function completeMultipartUpload(req: Request, res: Response) { + const { key, uploadId, parts } = req.body; + + try { + const command = new CompleteMultipartUploadCommand({ + Bucket: CLOUDFLARE_BUCKETNAME, + Key: key, + UploadId: uploadId, + MultipartUpload: { Parts: parts }, + }); + const response = await R2.send(command); + + const safeExt = normalizeExtension(key || ''); + if (!safeExt) { + await R2.send( + new DeleteObjectCommand({ Bucket: CLOUDFLARE_BUCKETNAME, Key: key }) + ); + return res.status(400).json({ message: 'Unsupported file type.' }); + } + const expectedMime = ALLOWED_EXT_TO_MIME[safeExt]; + + const head = await R2.send( + new GetObjectCommand({ + Bucket: CLOUDFLARE_BUCKETNAME, + Key: key, + Range: 'bytes=0-4100', + }) + ); + const chunks: Buffer[] = []; + // @ts-ignore + for await (const chunk of head.Body as AsyncIterable) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const prefix = Buffer.concat(chunks); + const detected = await fromBuffer(prefix); + + if (!detected || detected.mime !== expectedMime) { + await R2.send( + new DeleteObjectCommand({ Bucket: CLOUDFLARE_BUCKETNAME, Key: key }) + ); + return res + .status(400) + .json({ message: 'File contents do not match declared type.' }); + } + + response.Location = + process.env.CLOUDFLARE_BUCKET_URL + + '/' + + response?.Location?.split('/').at(-1); + return response; + } catch (err) { + console.log('Error', err); + return res.status(500).json(err); + } +} + +export async function abortMultipartUpload(req: Request, res: Response) { + const { key, uploadId } = req.body; + + try { + const params = { + Bucket: CLOUDFLARE_BUCKETNAME, + Key: key, + UploadId: uploadId, + }; + const command = new AbortMultipartUploadCommand({ ...params }); + const response = await R2.send(command); + + return res.status(200).json(response); + } catch (err) { + console.log('Error', err); + return res.status(500).json(err); + } +} + +export async function signPart(req: Request, res: Response) { + const { key, uploadId } = req.body; + const partNumber = parseInt(req.body.partNumber); + + const params = { + Bucket: CLOUDFLARE_BUCKETNAME, + Key: key, + PartNumber: partNumber, + UploadId: uploadId, + Expires: 3600, + }; + + const command = new UploadPartCommand({ ...params }); + const url = await getSignedUrl(R2, command, { expiresIn: 3600 }); + + return res.status(200).json({ + url: url, + }); +} diff --git a/libraries/nestjs-libraries/src/upload/upload.factory.ts b/libraries/nestjs-libraries/src/upload/upload.factory.ts new file mode 100644 index 0000000000000000000000000000000000000000..f89d310a8cbf3d1ce0ef6a6e1ca921e906ac4a10 --- /dev/null +++ b/libraries/nestjs-libraries/src/upload/upload.factory.ts @@ -0,0 +1,25 @@ +import { CloudflareStorage } from './cloudflare.storage'; +import { IUploadProvider } from './upload.interface'; +import { LocalStorage } from './local.storage'; + +export class UploadFactory { + static createStorage(): IUploadProvider { + const storageProvider = process.env.STORAGE_PROVIDER || 'local'; + + switch (storageProvider) { + case 'local': + return new LocalStorage(process.env.UPLOAD_DIRECTORY!); + case 'cloudflare': + return new CloudflareStorage( + process.env.CLOUDFLARE_ACCOUNT_ID!, + process.env.CLOUDFLARE_ACCESS_KEY!, + process.env.CLOUDFLARE_SECRET_ACCESS_KEY!, + process.env.CLOUDFLARE_REGION!, + process.env.CLOUDFLARE_BUCKETNAME!, + process.env.CLOUDFLARE_BUCKET_URL! + ); + default: + throw new Error(`Invalid storage type ${storageProvider}`); + } + } +} diff --git a/libraries/nestjs-libraries/src/upload/upload.interface.ts b/libraries/nestjs-libraries/src/upload/upload.interface.ts new file mode 100644 index 0000000000000000000000000000000000000000..52c2f030f54cc21c02c13e0dad7d723c09795911 --- /dev/null +++ b/libraries/nestjs-libraries/src/upload/upload.interface.ts @@ -0,0 +1,5 @@ +export interface IUploadProvider { + uploadSimple(path: string): Promise; + uploadFile(file: Express.Multer.File): Promise; + removeFile(filePath: string): Promise; +}