generated from fahricansecer/boilerplate-be
This commit is contained in:
337
docs/API_CONTRACTS.md
Normal file
337
docs/API_CONTRACTS.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Content Hunter - API Contracts
|
||||
|
||||
## Overview
|
||||
Complete API reference for all Content Hunter backend endpoints. All endpoints are RESTful and return JSON.
|
||||
|
||||
## Base URL
|
||||
```
|
||||
Production: https://api.contenthunter.app/v1
|
||||
Development: http://localhost:3000/v1
|
||||
```
|
||||
|
||||
## Authentication
|
||||
All protected endpoints require Bearer token:
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication (`/auth`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| POST | `/auth/register` | Register new user | ❌ |
|
||||
| POST | `/auth/login` | Login with email/password | ❌ |
|
||||
| POST | `/auth/google` | Google OAuth login | ❌ |
|
||||
| POST | `/auth/refresh` | Refresh access token | ⚠️ |
|
||||
| POST | `/auth/logout` | Logout user | ✅ |
|
||||
| POST | `/auth/forgot-password` | Request password reset | ❌ |
|
||||
| POST | `/auth/reset-password` | Reset password with token | ❌ |
|
||||
|
||||
### Request/Response Examples
|
||||
|
||||
```typescript
|
||||
// POST /auth/register
|
||||
Request: { email: string; password: string; name: string; }
|
||||
Response: { user: User; accessToken: string; refreshToken: string; }
|
||||
|
||||
// POST /auth/login
|
||||
Request: { email: string; password: string; }
|
||||
Response: { user: User; accessToken: string; refreshToken: string; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Users (`/users`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/users/me` | Get current user | ✅ |
|
||||
| PUT | `/users/me` | Update profile | ✅ |
|
||||
| PUT | `/users/me/preferences` | Update preferences | ✅ |
|
||||
| GET | `/users/me/credits` | Get credit balance | ✅ |
|
||||
| DELETE | `/users/me` | Delete account | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 3. Workspaces (`/workspaces`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/workspaces` | List user workspaces | ✅ |
|
||||
| POST | `/workspaces` | Create workspace | ✅ |
|
||||
| GET | `/workspaces/:id` | Get workspace | ✅ |
|
||||
| PUT | `/workspaces/:id` | Update workspace | ✅ |
|
||||
| DELETE | `/workspaces/:id` | Delete workspace | ✅ |
|
||||
| POST | `/workspaces/:id/members` | Add member | ✅ |
|
||||
| DELETE | `/workspaces/:id/members/:userId` | Remove member | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 4. Content (`/content`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/content` | List content | ✅ |
|
||||
| POST | `/content` | Create content | ✅ |
|
||||
| GET | `/content/:id` | Get content | ✅ |
|
||||
| PUT | `/content/:id` | Update content | ✅ |
|
||||
| DELETE | `/content/:id` | Delete content | ✅ |
|
||||
| POST | `/content/:id/variations` | Generate variations | ✅ |
|
||||
| POST | `/content/:id/repurpose` | Repurpose content | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 5. Content Generation (`/content-generation`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| POST | `/content-generation/generate` | Generate content | ✅ |
|
||||
| POST | `/content-generation/hooks` | Generate hooks | ✅ |
|
||||
| POST | `/content-generation/threads` | Generate thread | ✅ |
|
||||
| POST | `/content-generation/captions` | Generate captions | ✅ |
|
||||
| POST | `/content-generation/scripts` | Generate video script | ✅ |
|
||||
| GET | `/content-generation/styles` | Get writing styles | ✅ |
|
||||
| POST | `/content-generation/styles` | Create custom style | ✅ |
|
||||
|
||||
### Content Generation Request
|
||||
```typescript
|
||||
{
|
||||
type: 'post' | 'thread' | 'carousel' | 'video_script' | 'newsletter';
|
||||
platform: 'x' | 'instagram' | 'linkedin' | 'tiktok' | 'youtube';
|
||||
topic: string;
|
||||
style?: string;
|
||||
language?: ContentLanguage;
|
||||
niche?: string;
|
||||
variations?: number; // 1-3
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Trends (`/trends`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/trends` | Get aggregated trends | ✅ |
|
||||
| GET | `/trends/google` | Google Trends | ✅ |
|
||||
| GET | `/trends/twitter` | Twitter/X trends | ✅ |
|
||||
| GET | `/trends/reddit` | Reddit trends | ✅ |
|
||||
| GET | `/trends/news` | News trends | ✅ |
|
||||
| POST | `/trends/youtube/transcript` | Extract YouTube transcript | ✅ |
|
||||
| POST | `/trends/scrape` | Scrape web page | ✅ |
|
||||
| POST | `/trends/scan` | Manual trend scan | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 7. Source Accounts (`/source-accounts`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/source-accounts` | List source accounts | ✅ |
|
||||
| POST | `/source-accounts` | Add source account | ✅ |
|
||||
| GET | `/source-accounts/:id` | Get source account | ✅ |
|
||||
| DELETE | `/source-accounts/:id` | Remove source | ✅ |
|
||||
| GET | `/source-accounts/:id/posts` | Get source posts | ✅ |
|
||||
| POST | `/source-accounts/:id/analyze` | Analyze viral post | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 8. Neuro Marketing (`/neuro-marketing`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/neuro-marketing/triggers` | Get psychology triggers | ✅ |
|
||||
| GET | `/neuro-marketing/hooks` | Get emotional hooks | ✅ |
|
||||
| GET | `/neuro-marketing/patterns` | Get engagement patterns | ✅ |
|
||||
| POST | `/neuro-marketing/analyze` | Analyze content psychology | ✅ |
|
||||
| POST | `/neuro-marketing/optimize` | Optimize for engagement | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 9. SEO (`/seo`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/seo/keywords` | Search keywords | ✅ |
|
||||
| POST | `/seo/analyze` | Analyze content SEO | ✅ |
|
||||
| POST | `/seo/optimize` | Optimize for SEO | ✅ |
|
||||
| GET | `/seo/suggestions` | Get keyword suggestions | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 10. Visual Generation (`/visual-generation`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| POST | `/visual-generation/image` | Generate image | ✅ |
|
||||
| POST | `/visual-generation/video` | Generate video (Veo) | ✅ |
|
||||
| GET | `/visual-generation/templates` | Get templates | ✅ |
|
||||
| POST | `/visual-generation/templates` | Create template | ✅ |
|
||||
| PUT | `/visual-generation/templates/:id` | Update template | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 11. Video & Thumbnails (`/video-thumbnail`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| POST | `/video-thumbnail/script` | Generate video script | ✅ |
|
||||
| POST | `/video-thumbnail/thumbnail` | Generate thumbnail | ✅ |
|
||||
| POST | `/video-thumbnail/title` | Optimize video title | ✅ |
|
||||
| GET | `/video-thumbnail/patterns` | Get thumbnail patterns | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 12. Social Integration (`/social`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/social/accounts` | List connected accounts | ✅ |
|
||||
| POST | `/social/connect/:platform` | Connect platform | ✅ |
|
||||
| DELETE | `/social/disconnect/:platform` | Disconnect platform | ✅ |
|
||||
| POST | `/social/publish` | Publish content | ✅ |
|
||||
| GET | `/social/status/:postId` | Get publish status | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 13. Scheduling (`/scheduling`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/scheduling/calendar` | Get calendar view | ✅ |
|
||||
| POST | `/scheduling/calendar/event` | Create event | ✅ |
|
||||
| PUT | `/scheduling/calendar/event/:id` | Update event | ✅ |
|
||||
| DELETE | `/scheduling/calendar/event/:id` | Delete event | ✅ |
|
||||
| GET | `/scheduling/workflows` | Get workflow templates | ✅ |
|
||||
| POST | `/scheduling/workflows` | Create workflow | ✅ |
|
||||
| GET | `/scheduling/timing/:platform` | Get optimal times | ✅ |
|
||||
| GET | `/scheduling/queue` | Get post queue | ✅ |
|
||||
| POST | `/scheduling/queue` | Add to queue | ✅ |
|
||||
| GET | `/scheduling/automation` | Get automation rules | ✅ |
|
||||
| POST | `/scheduling/automation` | Create automation | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 14. Analytics (`/analytics`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| POST | `/analytics/engagement` | Record engagement | ✅ |
|
||||
| GET | `/analytics/dashboard/overview` | Dashboard overview | ✅ |
|
||||
| GET | `/analytics/dashboard/heatmap` | Engagement heatmap | ✅ |
|
||||
| GET | `/analytics/gold-posts` | Get gold posts | ✅ |
|
||||
| POST | `/analytics/ab-tests` | Create A/B test | ✅ |
|
||||
| GET | `/analytics/ab-tests/:id` | Get test results | ✅ |
|
||||
| GET | `/analytics/growth-formulas` | Get growth formulas | ✅ |
|
||||
| POST | `/analytics/growth-formulas` | Create formula | ✅ |
|
||||
| GET | `/analytics/webhooks` | List webhooks | ✅ |
|
||||
| POST | `/analytics/webhooks` | Create webhook | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 15. Subscriptions (`/subscriptions`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/subscriptions/plans` | Get available plans | ❌ |
|
||||
| GET | `/subscriptions/current` | Get current subscription | ✅ |
|
||||
| POST | `/subscriptions/checkout` | Create checkout session | ✅ |
|
||||
| POST | `/subscriptions/cancel` | Cancel subscription | ✅ |
|
||||
| POST | `/subscriptions/webhook` | Stripe webhook | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 16. Credits (`/credits`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/credits/balance` | Get balance | ✅ |
|
||||
| GET | `/credits/history` | Get usage history | ✅ |
|
||||
| POST | `/credits/purchase` | Purchase credits | ✅ |
|
||||
| GET | `/credits/packages` | Get credit packages | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 17. Languages (`/languages`)
|
||||
|
||||
| Method | Endpoint | Description | Auth |
|
||||
|--------|----------|-------------|------|
|
||||
| GET | `/languages` | List supported languages | ❌ |
|
||||
| GET | `/languages/:code` | Get language details | ❌ |
|
||||
| GET | `/languages/:code/guide` | Get cultural guide | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## Error Response Format
|
||||
|
||||
All errors follow this structure:
|
||||
```typescript
|
||||
{
|
||||
statusCode: number;
|
||||
message: string;
|
||||
error: string;
|
||||
timestamp: string;
|
||||
path: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Common Status Codes
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| 200 | Success |
|
||||
| 201 | Created |
|
||||
| 400 | Bad Request |
|
||||
| 401 | Unauthorized |
|
||||
| 403 | Forbidden |
|
||||
| 404 | Not Found |
|
||||
| 429 | Rate Limited |
|
||||
| 500 | Server Error |
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits
|
||||
|
||||
| Plan | Requests/min | Requests/day |
|
||||
|------|-------------|--------------|
|
||||
| Free | 10 | 100 |
|
||||
| Starter | 30 | 1,000 |
|
||||
| Pro | 60 | 5,000 |
|
||||
| Ultimate | 100 | 20,000 |
|
||||
| Enterprise | Unlimited | Unlimited |
|
||||
|
||||
---
|
||||
|
||||
## Pagination
|
||||
|
||||
List endpoints support pagination:
|
||||
```
|
||||
GET /content?page=1&limit=20&sort=createdAt&order=desc
|
||||
```
|
||||
|
||||
Response includes:
|
||||
```typescript
|
||||
{
|
||||
data: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
Connect to: `wss://api.contenthunter.app/ws`
|
||||
|
||||
| Event | Direction | Description |
|
||||
|-------|-----------|-------------|
|
||||
| `content.generated` | Server→Client | Content generation complete |
|
||||
| `publish.status` | Server→Client | Publish status update |
|
||||
| `trend.alert` | Server→Client | New trend detected |
|
||||
| `gold.detected` | Server→Client | Gold post detected |
|
||||
54
package-lock.json
generated
54
package-lock.json
generated
@@ -30,6 +30,7 @@
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"google-trends-api": "^4.9.2",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.9.0",
|
||||
"nestjs-i18n": "^10.6.0",
|
||||
@@ -1137,7 +1138,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -3075,7 +3075,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -3242,7 +3241,6 @@
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.11.tgz",
|
||||
"integrity": "sha512-R/+A8XFqLgN8zNs2twhrOaE7dJbRQhdPX3g46am4RT/x8xGLqDphrXkUIno4cGUZHxbczChBAaAPTdPv73wDZA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"file-type": "21.2.0",
|
||||
"iterare": "1.2.1",
|
||||
@@ -3288,7 +3286,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.11.tgz",
|
||||
"integrity": "sha512-H9i+zT3RvHi7tDc+lCmWHJ3ustXveABCr+Vcpl96dNOxgmrx4elQSTC4W93Mlav2opfLV+p0UTHY6L+bpUA4zA==",
|
||||
"hasInstallScript": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@nuxt/opencollective": "0.4.1",
|
||||
"fast-safe-stringify": "2.1.1",
|
||||
@@ -3368,7 +3365,6 @@
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.11.tgz",
|
||||
"integrity": "sha512-kyABSskdMRIAMWL0SlbwtDy4yn59RL4HDdwHDz/fxWuv7/53YP8Y2DtV3/sHqY5Er0msMVTZrM38MjqXhYL7gw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cors": "2.8.5",
|
||||
"express": "5.2.1",
|
||||
@@ -3389,7 +3385,6 @@
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.11.tgz",
|
||||
"integrity": "sha512-0z6pLg9CuTXtz7q2lRZoPOU94DN28OTa39f4cQrlZysKA6QrKM7w7z6xqb4g32qjF+LQHFNRmMJtE/pLrxBaig==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"socket.io": "4.8.3",
|
||||
"tslib": "2.8.1"
|
||||
@@ -3724,7 +3719,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
||||
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
||||
"hasInstallScript": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.13"
|
||||
},
|
||||
@@ -3789,7 +3783,6 @@
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2",
|
||||
"generic-pool": "3.9.0",
|
||||
@@ -4695,7 +4688,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "*",
|
||||
"@types/json-schema": "*"
|
||||
@@ -4810,7 +4802,6 @@
|
||||
"version": "22.19.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -4975,7 +4966,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz",
|
||||
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.52.0",
|
||||
"@typescript-eslint/types": "8.52.0",
|
||||
@@ -5613,7 +5603,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5667,7 +5656,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6157,7 +6145,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -6231,7 +6218,6 @@
|
||||
"version": "5.66.4",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.66.4.tgz",
|
||||
"integrity": "sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cron-parser": "4.9.0",
|
||||
"ioredis": "5.8.2",
|
||||
@@ -6305,7 +6291,6 @@
|
||||
"version": "7.2.7",
|
||||
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz",
|
||||
"integrity": "sha512-TKeeb9nSybk1e9E5yAiPVJ6YKdX9FYhwqqy8fBfVKAFVTJYZUNmeIvwjURW6+UikNsO6l2ta27thYgo/oumDsw==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cacheable/utils": "^2.3.2",
|
||||
"keyv": "^5.5.4"
|
||||
@@ -6457,7 +6442,6 @@
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@@ -6501,14 +6485,12 @@
|
||||
"node_modules/class-transformer": {
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
||||
"peer": true
|
||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="
|
||||
},
|
||||
"node_modules/class-validator": {
|
||||
"version": "0.14.3",
|
||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
|
||||
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/validator": "^13.15.3",
|
||||
"libphonenumber-js": "^1.11.1",
|
||||
@@ -7194,7 +7176,8 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
@@ -7253,7 +7236,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -7313,7 +7295,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
|
||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "bin/cli.js"
|
||||
},
|
||||
@@ -8163,6 +8144,12 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/google-trends-api": {
|
||||
"version": "4.9.2",
|
||||
"resolved": "https://registry.npmjs.org/google-trends-api/-/google-trends-api-4.9.2.tgz",
|
||||
"integrity": "sha512-gjVSHCM8B7LyAAUpXb4B0/TfnmpwQ2z1w/mQ2bL0AKpr2j3gLS1j2YOnifpfsGJRxAGXB/NoC+nGwC5qSnZIiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -8674,7 +8661,6 @@
|
||||
"resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz",
|
||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jest/core": "30.2.0",
|
||||
"@jest/types": "30.2.0",
|
||||
@@ -9518,7 +9504,6 @@
|
||||
"version": "5.5.5",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz",
|
||||
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@keyv/serialize": "^1.1.1"
|
||||
}
|
||||
@@ -10263,6 +10248,7 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
@@ -10446,7 +10432,6 @@
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"passport-strategy": "1.x.x",
|
||||
"pause": "0.0.1",
|
||||
@@ -10573,7 +10558,6 @@
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
|
||||
"integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
@@ -10603,7 +10587,6 @@
|
||||
"version": "11.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz",
|
||||
"integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"get-caller-file": "^2.0.5",
|
||||
"pino": "^10.0.0",
|
||||
@@ -10757,7 +10740,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
|
||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -10811,7 +10793,6 @@
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||
"hasInstallScript": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@prisma/engines": "5.22.0"
|
||||
},
|
||||
@@ -11876,7 +11857,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -12191,7 +12171,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -12329,7 +12308,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12668,6 +12646,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
},
|
||||
@@ -12685,6 +12664,7 @@
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
|
||||
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
},
|
||||
@@ -12697,6 +12677,7 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esrecurse": "^4.3.0",
|
||||
"estraverse": "^4.1.1"
|
||||
@@ -12710,6 +12691,7 @@
|
||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
|
||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
@@ -12718,13 +12700,15 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true
|
||||
"dev": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/webpack/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
@@ -12734,6 +12718,7 @@
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
@@ -12746,6 +12731,7 @@
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"ajv": "^8.9.0",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"cache-manager-redis-yet": "^5.1.5",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"google-trends-api": "^4.9.2",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.9.0",
|
||||
"nestjs-i18n": "^10.6.0",
|
||||
|
||||
2018
prisma/migrations/20260209105912_init/migration.sql
Normal file
2018
prisma/migrations/20260209105912_init/migration.sql
Normal file
File diff suppressed because it is too large
Load Diff
2154
prisma/schema.prisma
2154
prisma/schema.prisma
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,9 @@ import {
|
||||
i18nConfig,
|
||||
featuresConfig,
|
||||
throttleConfig,
|
||||
geminiConfig,
|
||||
} from './config/configuration';
|
||||
import { geminiConfig } from './modules/gemini/gemini.config';
|
||||
// import { geminiConfig } from './modules/gemini/gemini.config';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
|
||||
// Common
|
||||
@@ -31,7 +32,7 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||
|
||||
// Database
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { PrismaModule } from './database/prisma.module';
|
||||
|
||||
// Modules
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
@@ -39,6 +40,23 @@ import { UsersModule } from './modules/users/users.module';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { GeminiModule } from './modules/gemini/gemini.module';
|
||||
import { CreditsModule } from './modules/credits/credits.module';
|
||||
import { WorkspacesModule } from './modules/workspaces/workspaces.module';
|
||||
import { SubscriptionsModule } from './modules/subscriptions/subscriptions.module';
|
||||
import { LanguagesModule } from './modules/languages/languages.module';
|
||||
import { ApprovalsModule } from './modules/approvals/approvals.module';
|
||||
import { TrendsModule } from './modules/trends/trends.module';
|
||||
import { ContentModule } from './modules/content/content.module';
|
||||
import { NeuroMarketingModule } from './modules/neuro-marketing/neuro-marketing.module';
|
||||
import { SeoModule } from './modules/seo/seo.module';
|
||||
import { SourceAccountsModule } from './modules/source-accounts/source-accounts.module';
|
||||
import { ContentGenerationModule } from './modules/content-generation/content-generation.module';
|
||||
import { VisualGenerationModule } from './modules/visual-generation/visual-generation.module';
|
||||
import { VideoThumbnailModule } from './modules/video-thumbnail/video-thumbnail.module';
|
||||
import { SocialIntegrationModule } from './modules/social-integration/social-integration.module';
|
||||
import { SchedulingModule } from './modules/scheduling/scheduling.module';
|
||||
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
||||
import { ContentHunterI18nModule } from './modules/i18n/i18n.module';
|
||||
|
||||
// Guards
|
||||
import {
|
||||
@@ -75,11 +93,11 @@ import {
|
||||
level: configService.get('app.isDevelopment') ? 'debug' : 'info',
|
||||
transport: configService.get('app.isDevelopment')
|
||||
? {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
},
|
||||
}
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
singleLine: true,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
@@ -150,12 +168,29 @@ import {
|
||||
}),
|
||||
|
||||
// Database
|
||||
DatabaseModule,
|
||||
PrismaModule,
|
||||
|
||||
// Core Modules
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
AdminModule,
|
||||
CreditsModule,
|
||||
WorkspacesModule,
|
||||
SubscriptionsModule,
|
||||
LanguagesModule,
|
||||
ApprovalsModule,
|
||||
TrendsModule,
|
||||
ContentModule,
|
||||
NeuroMarketingModule,
|
||||
SeoModule,
|
||||
SourceAccountsModule,
|
||||
ContentGenerationModule,
|
||||
VisualGenerationModule,
|
||||
VideoThumbnailModule,
|
||||
SocialIntegrationModule,
|
||||
SchedulingModule,
|
||||
AnalyticsModule,
|
||||
ContentHunterI18nModule,
|
||||
|
||||
// Optional Modules (controlled by env variables)
|
||||
GeminiModule,
|
||||
@@ -199,4 +234,4 @@ import {
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
export class AppModule { }
|
||||
|
||||
@@ -55,3 +55,9 @@ export const throttleConfig = registerAs('throttle', () => ({
|
||||
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
|
||||
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 10),
|
||||
}));
|
||||
|
||||
export const geminiConfig = registerAs('gemini', () => ({
|
||||
enabled: process.env.ENABLE_GEMINI === 'true',
|
||||
apiKey: process.env.GOOGLE_API_KEY,
|
||||
defaultModel: process.env.GEMINI_MODEL || 'gemini-1.5-flash',
|
||||
}));
|
||||
|
||||
@@ -59,6 +59,11 @@ export const envSchema = z.object({
|
||||
// Throttle
|
||||
THROTTLE_TTL: z.coerce.number().default(60000),
|
||||
THROTTLE_LIMIT: z.coerce.number().default(100),
|
||||
|
||||
// Gemini AI
|
||||
ENABLE_GEMINI: booleanString,
|
||||
GOOGLE_API_KEY: z.string().optional(),
|
||||
GEMINI_MODEL: z.string().optional(),
|
||||
});
|
||||
|
||||
export type EnvConfig = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -6,4 +6,4 @@ import { PrismaService } from './prisma.service';
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
export class PrismaModule { }
|
||||
@@ -22,8 +22,7 @@ interface PrismaDelegate {
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
constructor() {
|
||||
@@ -44,11 +43,17 @@ export class PrismaService
|
||||
await this.$connect();
|
||||
this.logger.log('✅ Database connected successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`❌ Database connection failed: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
throw error;
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
this.logger.warn(
|
||||
`⚠️ Database connection failed: ${error.message}. Continuing in OFFLINE MODE (Development only).`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`❌ Database connection failed: ${error.message}`,
|
||||
error.stack,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
416
src/modules/analytics/analytics.controller.ts
Normal file
416
src/modules/analytics/analytics.controller.ts
Normal file
@@ -0,0 +1,416 @@
|
||||
// Analytics Controller - API endpoints
|
||||
// Path: src/modules/analytics/analytics.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
constructor(private readonly service: AnalyticsService) { }
|
||||
|
||||
// ========== ENGAGEMENT ==========
|
||||
|
||||
@Post('engagement/:userId/:postId')
|
||||
recordEngagement(
|
||||
@Param('userId') userId: string,
|
||||
@Param('postId') postId: string,
|
||||
@Body() body: { platform: string; metrics: any },
|
||||
) {
|
||||
return this.service.recordEngagement(userId, postId, body.platform, body.metrics);
|
||||
}
|
||||
|
||||
@Get('engagement/:userId/:postId/history')
|
||||
getPostEngagementHistory(@Param('userId') userId: string, @Param('postId') postId: string) {
|
||||
return this.service.getPostEngagementHistory(userId, postId);
|
||||
}
|
||||
|
||||
@Get('engagement/:userId/:postId/performance')
|
||||
getContentPerformance(@Param('userId') userId: string, @Param('postId') postId: string) {
|
||||
return this.service.getContentPerformance(userId, postId);
|
||||
}
|
||||
|
||||
@Get('engagement/:userId/trends')
|
||||
getEngagementTrends(
|
||||
@Param('userId') userId: string,
|
||||
@Query('period') period: 'day' | 'week' | 'month' = 'week',
|
||||
) {
|
||||
return this.service.getEngagementTrends(userId, period);
|
||||
}
|
||||
|
||||
@Get('engagement/:userId/top')
|
||||
getTopPerformingContent(
|
||||
@Param('userId') userId: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.service.getTopPerformingContent(userId, limit ? parseInt(limit, 10) : undefined);
|
||||
}
|
||||
|
||||
@Get('engagement/:userId/alerts')
|
||||
getVelocityAlerts(@Param('userId') userId: string) {
|
||||
return this.service.getVelocityAlerts(userId);
|
||||
}
|
||||
|
||||
@Get('engagement/:userId/benchmarks')
|
||||
getPlatformBenchmarks(@Param('userId') userId: string) {
|
||||
return this.service.getPlatformBenchmarks(userId);
|
||||
}
|
||||
|
||||
// ========== DASHBOARD ==========
|
||||
|
||||
@Get('dashboard/:userId/overview')
|
||||
getDashboardOverview(
|
||||
@Param('userId') userId: string,
|
||||
@Query('period') period: 'day' | 'week' | 'month' | 'quarter' | 'year' = 'week',
|
||||
) {
|
||||
return this.service.getDashboardOverview(userId, period);
|
||||
}
|
||||
|
||||
@Get('dashboard/:userId/platforms')
|
||||
getPlatformBreakdown(
|
||||
@Param('userId') userId: string,
|
||||
@Query('period') period: string = 'week',
|
||||
) {
|
||||
return this.service.getPlatformBreakdown(userId, period);
|
||||
}
|
||||
|
||||
@Get('dashboard/:userId/content-types')
|
||||
getContentTypeAnalysis(@Param('userId') userId: string) {
|
||||
return this.service.getContentTypeAnalysis(userId);
|
||||
}
|
||||
|
||||
@Get('dashboard/:userId/time-analysis')
|
||||
getTimeAnalysis(
|
||||
@Param('userId') userId: string,
|
||||
@Query('platform') platform?: string,
|
||||
) {
|
||||
return this.service.getTimeAnalysis(userId, platform);
|
||||
}
|
||||
|
||||
@Get('dashboard/:userId/heatmap')
|
||||
getEngagementHeatmap(@Param('userId') userId: string) {
|
||||
return this.service.getEngagementHeatmap(userId);
|
||||
}
|
||||
|
||||
@Get('dashboard/:userId/comparison')
|
||||
getComparisonData(
|
||||
@Param('userId') userId: string,
|
||||
@Query('period') period: string = 'week',
|
||||
) {
|
||||
return this.service.getComparisonData(userId, period);
|
||||
}
|
||||
|
||||
@Get('dashboard/:userId/layout')
|
||||
getDashboardLayout(@Param('userId') userId: string) {
|
||||
return this.service.getDashboardLayout(userId);
|
||||
}
|
||||
|
||||
@Put('dashboard/:userId/layout')
|
||||
updateDashboardLayout(@Param('userId') userId: string, @Body() updates: any) {
|
||||
return this.service.updateDashboardLayout(userId, updates);
|
||||
}
|
||||
|
||||
@Get('dashboard/:userId/export')
|
||||
exportDashboardData(
|
||||
@Param('userId') userId: string,
|
||||
@Query('format') format: 'json' | 'csv' = 'json',
|
||||
) {
|
||||
return this.service.exportDashboardData(userId, format);
|
||||
}
|
||||
|
||||
@Get('dashboard/:userId/insights')
|
||||
getInsights(@Param('userId') userId: string) {
|
||||
return this.service.getInsights(userId);
|
||||
}
|
||||
|
||||
// ========== A/B TESTING ==========
|
||||
|
||||
@Post('ab-tests/:userId')
|
||||
createABTest(@Param('userId') userId: string, @Body() body: any) {
|
||||
return this.service.createABTest(userId, body);
|
||||
}
|
||||
|
||||
@Post('ab-tests/:userId/:testId/start')
|
||||
startABTest(@Param('userId') userId: string, @Param('testId') testId: string) {
|
||||
return this.service.startABTest(userId, testId);
|
||||
}
|
||||
|
||||
@Post('ab-tests/:userId/:testId/pause')
|
||||
pauseABTest(@Param('userId') userId: string, @Param('testId') testId: string) {
|
||||
return this.service.pauseABTest(userId, testId);
|
||||
}
|
||||
|
||||
@Post('ab-tests/:userId/:testId/resume')
|
||||
resumeABTest(@Param('userId') userId: string, @Param('testId') testId: string) {
|
||||
return this.service.resumeABTest(userId, testId);
|
||||
}
|
||||
|
||||
@Post('ab-tests/:userId/:testId/end')
|
||||
endABTest(@Param('userId') userId: string, @Param('testId') testId: string) {
|
||||
return this.service.endABTest(userId, testId);
|
||||
}
|
||||
|
||||
@Post('ab-tests/:userId/:testId/metrics/:variantId')
|
||||
recordABTestMetrics(
|
||||
@Param('userId') userId: string,
|
||||
@Param('testId') testId: string,
|
||||
@Param('variantId') variantId: string,
|
||||
@Body() metrics: any,
|
||||
) {
|
||||
return this.service.recordABTestMetrics(userId, testId, variantId, metrics);
|
||||
}
|
||||
|
||||
@Get('ab-tests/:userId')
|
||||
getABTests(
|
||||
@Param('userId') userId: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.service.getABTests(userId, status as any);
|
||||
}
|
||||
|
||||
@Get('ab-tests/:userId/:testId')
|
||||
getABTest(@Param('userId') userId: string, @Param('testId') testId: string) {
|
||||
return this.service.getABTest(userId, testId);
|
||||
}
|
||||
|
||||
@Get('ab-tests/:userId/:testId/report')
|
||||
getABTestReport(@Param('userId') userId: string, @Param('testId') testId: string) {
|
||||
return this.service.getABTestReport(userId, testId);
|
||||
}
|
||||
|
||||
@Get('ab-tests/templates')
|
||||
getABTestTemplates() {
|
||||
return this.service.getABTestTemplates();
|
||||
}
|
||||
|
||||
@Delete('ab-tests/:userId/:testId')
|
||||
deleteABTest(@Param('userId') userId: string, @Param('testId') testId: string) {
|
||||
return { success: this.service.deleteABTest(userId, testId) };
|
||||
}
|
||||
|
||||
// ========== GOLD POSTS ==========
|
||||
|
||||
@Get('gold-posts/:userId')
|
||||
getGoldPosts(
|
||||
@Param('userId') userId: string,
|
||||
@Query('level') level?: string,
|
||||
@Query('platform') platform?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.service.getGoldPosts(userId, {
|
||||
level: level as any,
|
||||
platform,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('gold-posts/:userId/:goldPostId')
|
||||
getGoldPost(@Param('userId') userId: string, @Param('goldPostId') goldPostId: string) {
|
||||
return this.service.getGoldPost(userId, goldPostId);
|
||||
}
|
||||
|
||||
@Post('gold-posts/:userId/:goldPostId/spinoffs')
|
||||
addSpinoff(
|
||||
@Param('userId') userId: string,
|
||||
@Param('goldPostId') goldPostId: string,
|
||||
@Body() spinoff: any,
|
||||
) {
|
||||
return this.service.addSpinoff(userId, goldPostId, spinoff);
|
||||
}
|
||||
|
||||
@Put('gold-posts/:userId/:goldPostId/spinoffs/:spinoffId')
|
||||
updateSpinoff(
|
||||
@Param('userId') userId: string,
|
||||
@Param('goldPostId') goldPostId: string,
|
||||
@Param('spinoffId') spinoffId: string,
|
||||
@Body() updates: any,
|
||||
) {
|
||||
return this.service.updateSpinoff(userId, goldPostId, spinoffId, updates);
|
||||
}
|
||||
|
||||
@Get('gold-posts/:userId/analytics')
|
||||
getGoldPostAnalytics(@Param('userId') userId: string) {
|
||||
return this.service.getGoldPostAnalytics(userId);
|
||||
}
|
||||
|
||||
@Get('gold-posts/:userId/patterns')
|
||||
getSuccessPatterns(@Param('userId') userId: string) {
|
||||
return this.service.getSuccessPatterns(userId);
|
||||
}
|
||||
|
||||
@Get('gold-posts/:userId/:goldPostId/spinoff-suggestions')
|
||||
getSpinoffSuggestions(@Param('userId') userId: string, @Param('goldPostId') goldPostId: string) {
|
||||
return this.service.getSpinoffSuggestions(userId, goldPostId);
|
||||
}
|
||||
|
||||
// ========== GROWTH FORMULAS ==========
|
||||
|
||||
@Post('growth-formulas/:userId')
|
||||
createGrowthFormula(@Param('userId') userId: string, @Body() body: any) {
|
||||
return this.service.createGrowthFormula(userId, body);
|
||||
}
|
||||
|
||||
@Post('growth-formulas/:userId/from-template')
|
||||
createGrowthFormulaFromTemplate(
|
||||
@Param('userId') userId: string,
|
||||
@Body() body: { templateName: string },
|
||||
) {
|
||||
return this.service.createGrowthFormulaFromTemplate(userId, body.templateName);
|
||||
}
|
||||
|
||||
@Get('growth-formulas/templates')
|
||||
getGrowthFormulaTemplates() {
|
||||
return this.service.getGrowthFormulaTemplates();
|
||||
}
|
||||
|
||||
@Get('growth-formulas/:userId')
|
||||
getGrowthFormulas(@Param('userId') userId: string) {
|
||||
return this.service.getGrowthFormulas(userId);
|
||||
}
|
||||
|
||||
@Get('growth-formulas/:userId/:formulaId')
|
||||
getGrowthFormula(@Param('userId') userId: string, @Param('formulaId') formulaId: string) {
|
||||
return this.service.getGrowthFormula(userId, formulaId);
|
||||
}
|
||||
|
||||
@Post('growth-formulas/:userId/:formulaId/test')
|
||||
recordFormulaTestResult(
|
||||
@Param('userId') userId: string,
|
||||
@Param('formulaId') formulaId: string,
|
||||
@Body() result: any,
|
||||
) {
|
||||
return this.service.recordFormulaTestResult(userId, formulaId, result);
|
||||
}
|
||||
|
||||
@Post('growth-experiments/:userId/:formulaId')
|
||||
createGrowthExperiment(
|
||||
@Param('userId') userId: string,
|
||||
@Param('formulaId') formulaId: string,
|
||||
@Body() input: any,
|
||||
) {
|
||||
return this.service.createGrowthExperiment(userId, formulaId, input);
|
||||
}
|
||||
|
||||
@Post('growth-experiments/:userId/:experimentId/complete')
|
||||
completeGrowthExperiment(
|
||||
@Param('userId') userId: string,
|
||||
@Param('experimentId') experimentId: string,
|
||||
@Body() results: any,
|
||||
) {
|
||||
return this.service.completeGrowthExperiment(userId, experimentId, results);
|
||||
}
|
||||
|
||||
@Get('growth-experiments/:userId')
|
||||
getGrowthExperiments(@Param('userId') userId: string, @Query('status') status?: string) {
|
||||
return this.service.getGrowthExperiments(userId, status);
|
||||
}
|
||||
|
||||
@Get('growth-report/:userId')
|
||||
getGrowthReport(
|
||||
@Param('userId') userId: string,
|
||||
@Query('period') period: 'week' | 'month' | 'quarter' = 'week',
|
||||
) {
|
||||
return this.service.getGrowthReport(userId, period);
|
||||
}
|
||||
|
||||
@Get('growth-suggestions')
|
||||
getFormulaSuggestions(@Query('goal') goal: 'engagement' | 'reach' | 'followers' | 'authority') {
|
||||
return this.service.getFormulaSuggestions(goal);
|
||||
}
|
||||
|
||||
@Delete('growth-formulas/:userId/:formulaId')
|
||||
deleteGrowthFormula(@Param('userId') userId: string, @Param('formulaId') formulaId: string) {
|
||||
return { success: this.service.deleteGrowthFormula(userId, formulaId) };
|
||||
}
|
||||
|
||||
// ========== WEBHOOKS ==========
|
||||
|
||||
@Post('webhooks/:userId')
|
||||
createWebhook(@Param('userId') userId: string, @Body() body: any) {
|
||||
return this.service.createWebhook(userId, body);
|
||||
}
|
||||
|
||||
@Get('webhooks/:userId')
|
||||
getWebhooks(@Param('userId') userId: string) {
|
||||
return this.service.getWebhooks(userId);
|
||||
}
|
||||
|
||||
@Get('webhooks/:userId/:webhookId')
|
||||
getWebhook(@Param('userId') userId: string, @Param('webhookId') webhookId: string) {
|
||||
return this.service.getWebhook(userId, webhookId);
|
||||
}
|
||||
|
||||
@Put('webhooks/:userId/:webhookId')
|
||||
updateWebhook(
|
||||
@Param('userId') userId: string,
|
||||
@Param('webhookId') webhookId: string,
|
||||
@Body() updates: any,
|
||||
) {
|
||||
return this.service.updateWebhook(userId, webhookId, updates);
|
||||
}
|
||||
|
||||
@Post('webhooks/:userId/:webhookId/toggle')
|
||||
toggleWebhook(@Param('userId') userId: string, @Param('webhookId') webhookId: string) {
|
||||
return this.service.toggleWebhook(userId, webhookId);
|
||||
}
|
||||
|
||||
@Delete('webhooks/:userId/:webhookId')
|
||||
deleteWebhook(@Param('userId') userId: string, @Param('webhookId') webhookId: string) {
|
||||
return { success: this.service.deleteWebhook(userId, webhookId) };
|
||||
}
|
||||
|
||||
@Post('webhooks/:userId/:webhookId/test')
|
||||
testWebhook(@Param('userId') userId: string, @Param('webhookId') webhookId: string) {
|
||||
return this.service.testWebhook(userId, webhookId);
|
||||
}
|
||||
|
||||
@Get('webhooks/:userId/:webhookId/deliveries')
|
||||
getWebhookDeliveries(
|
||||
@Param('userId') userId: string,
|
||||
@Param('webhookId') webhookId: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.service.getWebhookDeliveries(userId, webhookId, limit ? parseInt(limit, 10) : undefined);
|
||||
}
|
||||
|
||||
@Post('webhooks/:userId/deliveries/:deliveryId/retry')
|
||||
retryWebhookDelivery(@Param('userId') userId: string, @Param('deliveryId') deliveryId: string) {
|
||||
return this.service.retryWebhookDelivery(userId, deliveryId);
|
||||
}
|
||||
|
||||
@Get('webhook-events')
|
||||
getAvailableWebhookEvents() {
|
||||
return this.service.getAvailableWebhookEvents();
|
||||
}
|
||||
|
||||
// External APIs
|
||||
@Post('external-apis/:userId')
|
||||
configureExternalApi(@Param('userId') userId: string, @Body() input: any) {
|
||||
return this.service.configureExternalApi(userId, input);
|
||||
}
|
||||
|
||||
@Get('external-apis/:userId')
|
||||
getExternalApis(@Param('userId') userId: string) {
|
||||
return this.service.getExternalApis(userId);
|
||||
}
|
||||
|
||||
@Get('integration-templates')
|
||||
getIntegrationTemplates() {
|
||||
return this.service.getIntegrationTemplates();
|
||||
}
|
||||
|
||||
// ========== OVERVIEW ==========
|
||||
|
||||
@Get('overview/:userId')
|
||||
getAnalyticsOverview(@Param('userId') userId: string) {
|
||||
return this.service.getAnalyticsOverview(userId);
|
||||
}
|
||||
}
|
||||
29
src/modules/analytics/analytics.module.ts
Normal file
29
src/modules/analytics/analytics.module.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Analytics Module - Phase 12: Analytics & Intelligence
|
||||
// Path: src/modules/analytics/analytics.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../database/prisma.module';
|
||||
import { AnalyticsService } from './analytics.service';
|
||||
import { AnalyticsController } from './analytics.controller';
|
||||
import { EngagementTrackerService } from './services/engagement-tracker.service';
|
||||
import { PerformanceDashboardService } from './services/performance-dashboard.service';
|
||||
import { AbTestingService } from './services/ab-testing.service';
|
||||
import { GoldPostDetectorService } from './services/gold-post-detector.service';
|
||||
import { GrowthFormulaService } from './services/growth-formula.service';
|
||||
import { WebhookService } from './services/webhook.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
AnalyticsService,
|
||||
EngagementTrackerService,
|
||||
PerformanceDashboardService,
|
||||
AbTestingService,
|
||||
GoldPostDetectorService,
|
||||
GrowthFormulaService,
|
||||
WebhookService,
|
||||
],
|
||||
controllers: [AnalyticsController],
|
||||
exports: [AnalyticsService],
|
||||
})
|
||||
export class AnalyticsModule { }
|
||||
378
src/modules/analytics/analytics.service.ts
Normal file
378
src/modules/analytics/analytics.service.ts
Normal file
@@ -0,0 +1,378 @@
|
||||
// Analytics Service - Main orchestration
|
||||
// Path: src/modules/analytics/analytics.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { EngagementTrackerService, EngagementMetrics, ContentPerformance } from './services/engagement-tracker.service';
|
||||
import { PerformanceDashboardService, DashboardOverview, DashboardLayout } from './services/performance-dashboard.service';
|
||||
import { AbTestingService, ABTest, ABTestReport } from './services/ab-testing.service';
|
||||
import { GoldPostDetectorService, GoldPost } from './services/gold-post-detector.service';
|
||||
import { GrowthFormulaService, GrowthFormula, GrowthReport } from './services/growth-formula.service';
|
||||
import { WebhookService, Webhook, WebhookEvent } from './services/webhook.service';
|
||||
|
||||
@Injectable()
|
||||
export class AnalyticsService {
|
||||
private readonly logger = new Logger(AnalyticsService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly engagementService: EngagementTrackerService,
|
||||
private readonly dashboardService: PerformanceDashboardService,
|
||||
private readonly abTestingService: AbTestingService,
|
||||
private readonly goldPostService: GoldPostDetectorService,
|
||||
private readonly growthService: GrowthFormulaService,
|
||||
private readonly webhookService: WebhookService,
|
||||
) { }
|
||||
|
||||
// ========== ENGAGEMENT ==========
|
||||
|
||||
recordEngagement(
|
||||
userId: string,
|
||||
postId: string,
|
||||
platform: string,
|
||||
metrics: Partial<EngagementMetrics>,
|
||||
) {
|
||||
const snapshot = this.engagementService.recordEngagement(userId, postId, platform, metrics);
|
||||
|
||||
// Check for Gold Post
|
||||
this.checkForGoldPost(userId, postId, metrics, platform);
|
||||
|
||||
// Trigger webhook if threshold reached
|
||||
if (metrics.engagementRate && metrics.engagementRate > 10) {
|
||||
this.webhookService.triggerEvent(userId, 'engagement.threshold', {
|
||||
postId,
|
||||
platform,
|
||||
engagementRate: metrics.engagementRate,
|
||||
});
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
getPostEngagementHistory(userId: string, postId: string) {
|
||||
return this.engagementService.getPostEngagementHistory(userId, postId);
|
||||
}
|
||||
|
||||
getContentPerformance(userId: string, postId: string): ContentPerformance | null {
|
||||
return this.engagementService.getContentPerformance(userId, postId);
|
||||
}
|
||||
|
||||
getEngagementTrends(userId: string, period: 'day' | 'week' | 'month') {
|
||||
return this.engagementService.getEngagementTrends(userId, period);
|
||||
}
|
||||
|
||||
getTopPerformingContent(userId: string, limit?: number) {
|
||||
return this.engagementService.getTopPerformingContent(userId, limit);
|
||||
}
|
||||
|
||||
getVelocityAlerts(userId: string) {
|
||||
return this.engagementService.getVelocityAlerts(userId);
|
||||
}
|
||||
|
||||
getPlatformBenchmarks(userId: string) {
|
||||
return this.engagementService.getPlatformBenchmarks(userId);
|
||||
}
|
||||
|
||||
// ========== DASHBOARD ==========
|
||||
|
||||
getDashboardOverview(userId: string, period: 'day' | 'week' | 'month' | 'quarter' | 'year'): DashboardOverview {
|
||||
return this.dashboardService.getDashboardOverview(userId, period);
|
||||
}
|
||||
|
||||
getPlatformBreakdown(userId: string, period: string) {
|
||||
return this.dashboardService.getPlatformBreakdown(userId, period);
|
||||
}
|
||||
|
||||
getContentTypeAnalysis(userId: string) {
|
||||
return this.dashboardService.getContentTypeAnalysis(userId);
|
||||
}
|
||||
|
||||
getTimeAnalysis(userId: string, platform?: string) {
|
||||
return this.dashboardService.getTimeAnalysis(userId, platform);
|
||||
}
|
||||
|
||||
getEngagementHeatmap(userId: string) {
|
||||
return this.dashboardService.getEngagementHeatmap(userId);
|
||||
}
|
||||
|
||||
getComparisonData(userId: string, period: string) {
|
||||
return this.dashboardService.getComparisonData(userId, period);
|
||||
}
|
||||
|
||||
getDashboardLayout(userId: string): DashboardLayout {
|
||||
return this.dashboardService.getDashboardLayout(userId);
|
||||
}
|
||||
|
||||
updateDashboardLayout(userId: string, updates: Partial<DashboardLayout>) {
|
||||
return this.dashboardService.updateDashboardLayout(userId, updates);
|
||||
}
|
||||
|
||||
exportDashboardData(userId: string, format: 'json' | 'csv') {
|
||||
return this.dashboardService.exportDashboardData(userId, format);
|
||||
}
|
||||
|
||||
getInsights(userId: string) {
|
||||
return this.dashboardService.getInsights(userId);
|
||||
}
|
||||
|
||||
// ========== A/B TESTING ==========
|
||||
|
||||
createABTest(userId: string, input: Parameters<typeof this.abTestingService.createTest>[1]) {
|
||||
return this.abTestingService.createTest(userId, input);
|
||||
}
|
||||
|
||||
startABTest(userId: string, testId: string) {
|
||||
return this.abTestingService.startTest(userId, testId);
|
||||
}
|
||||
|
||||
pauseABTest(userId: string, testId: string) {
|
||||
return this.abTestingService.pauseTest(userId, testId);
|
||||
}
|
||||
|
||||
resumeABTest(userId: string, testId: string) {
|
||||
return this.abTestingService.resumeTest(userId, testId);
|
||||
}
|
||||
|
||||
endABTest(userId: string, testId: string) {
|
||||
const test = this.abTestingService.endTest(userId, testId);
|
||||
if (test) {
|
||||
this.webhookService.triggerEvent(userId, 'ab_test.completed', {
|
||||
testId: test.id,
|
||||
winner: test.winner,
|
||||
results: test.results,
|
||||
});
|
||||
}
|
||||
return test;
|
||||
}
|
||||
|
||||
recordABTestMetrics(
|
||||
userId: string,
|
||||
testId: string,
|
||||
variantId: string,
|
||||
metrics: Parameters<typeof this.abTestingService.recordMetrics>[3],
|
||||
) {
|
||||
return this.abTestingService.recordMetrics(userId, testId, variantId, metrics);
|
||||
}
|
||||
|
||||
getABTest(userId: string, testId: string): ABTest | null {
|
||||
return this.abTestingService.getTest(userId, testId);
|
||||
}
|
||||
|
||||
getABTests(userId: string, status?: ABTest['status']) {
|
||||
return this.abTestingService.getTests(userId, status);
|
||||
}
|
||||
|
||||
getABTestReport(userId: string, testId: string): ABTestReport | null {
|
||||
return this.abTestingService.getTestReport(userId, testId);
|
||||
}
|
||||
|
||||
getABTestTemplates() {
|
||||
return this.abTestingService.getTestTemplates();
|
||||
}
|
||||
|
||||
deleteABTest(userId: string, testId: string) {
|
||||
return this.abTestingService.deleteTest(userId, testId);
|
||||
}
|
||||
|
||||
// ========== GOLD POSTS ==========
|
||||
|
||||
getGoldPosts(userId: string, options?: { level?: GoldPost['goldLevel']; platform?: string; limit?: number }) {
|
||||
return this.goldPostService.getGoldPosts(userId, options);
|
||||
}
|
||||
|
||||
getGoldPost(userId: string, goldPostId: string) {
|
||||
return this.goldPostService.getGoldPost(userId, goldPostId);
|
||||
}
|
||||
|
||||
addSpinoff(userId: string, goldPostId: string, spinoff: any) {
|
||||
return this.goldPostService.addSpinoff(userId, goldPostId, spinoff);
|
||||
}
|
||||
|
||||
updateSpinoff(userId: string, goldPostId: string, spinoffId: string, updates: any) {
|
||||
return this.goldPostService.updateSpinoff(userId, goldPostId, spinoffId, updates);
|
||||
}
|
||||
|
||||
getGoldPostAnalytics(userId: string) {
|
||||
return this.goldPostService.getGoldPostAnalytics(userId);
|
||||
}
|
||||
|
||||
getSuccessPatterns(userId: string) {
|
||||
return this.goldPostService.getSuccessPatterns(userId);
|
||||
}
|
||||
|
||||
getSpinoffSuggestions(userId: string, goldPostId: string) {
|
||||
return this.goldPostService.generateSpinoffSuggestions(userId, goldPostId);
|
||||
}
|
||||
|
||||
// ========== GROWTH FORMULAS ==========
|
||||
|
||||
createGrowthFormula(userId: string, input: Parameters<typeof this.growthService.createFormula>[1]) {
|
||||
return this.growthService.createFormula(userId, input);
|
||||
}
|
||||
|
||||
createGrowthFormulaFromTemplate(userId: string, templateName: string) {
|
||||
return this.growthService.createFromTemplate(userId, templateName);
|
||||
}
|
||||
|
||||
getGrowthFormulaTemplates() {
|
||||
return this.growthService.getTemplates();
|
||||
}
|
||||
|
||||
getGrowthFormulas(userId: string) {
|
||||
return this.growthService.getFormulas(userId);
|
||||
}
|
||||
|
||||
getGrowthFormula(userId: string, formulaId: string) {
|
||||
return this.growthService.getFormula(userId, formulaId);
|
||||
}
|
||||
|
||||
recordFormulaTestResult(userId: string, formulaId: string, result: any) {
|
||||
return this.growthService.recordTestResult(userId, formulaId, result);
|
||||
}
|
||||
|
||||
createGrowthExperiment(userId: string, formulaId: string, input: any) {
|
||||
return this.growthService.createExperiment(userId, formulaId, input);
|
||||
}
|
||||
|
||||
completeGrowthExperiment(userId: string, experimentId: string, results: any) {
|
||||
return this.growthService.completeExperiment(userId, experimentId, results);
|
||||
}
|
||||
|
||||
getGrowthExperiments(userId: string, status?: string) {
|
||||
return this.growthService.getExperiments(userId, status as any);
|
||||
}
|
||||
|
||||
getGrowthReport(userId: string, period: 'week' | 'month' | 'quarter'): GrowthReport {
|
||||
return this.growthService.getGrowthReport(userId, period);
|
||||
}
|
||||
|
||||
getFormulaSuggestions(goal: 'engagement' | 'reach' | 'followers' | 'authority') {
|
||||
return this.growthService.getFormulaSuggestions(goal);
|
||||
}
|
||||
|
||||
deleteGrowthFormula(userId: string, formulaId: string) {
|
||||
return this.growthService.deleteFormula(userId, formulaId);
|
||||
}
|
||||
|
||||
// ========== WEBHOOKS ==========
|
||||
|
||||
createWebhook(userId: string, input: Parameters<typeof this.webhookService.createWebhook>[1]) {
|
||||
return this.webhookService.createWebhook(userId, input);
|
||||
}
|
||||
|
||||
getWebhooks(userId: string) {
|
||||
return this.webhookService.getWebhooks(userId);
|
||||
}
|
||||
|
||||
getWebhook(userId: string, webhookId: string) {
|
||||
return this.webhookService.getWebhook(userId, webhookId);
|
||||
}
|
||||
|
||||
updateWebhook(userId: string, webhookId: string, updates: any) {
|
||||
return this.webhookService.updateWebhook(userId, webhookId, updates);
|
||||
}
|
||||
|
||||
toggleWebhook(userId: string, webhookId: string) {
|
||||
return this.webhookService.toggleWebhook(userId, webhookId);
|
||||
}
|
||||
|
||||
deleteWebhook(userId: string, webhookId: string) {
|
||||
return this.webhookService.deleteWebhook(userId, webhookId);
|
||||
}
|
||||
|
||||
triggerWebhookEvent(userId: string, event: WebhookEvent, payload: any) {
|
||||
return this.webhookService.triggerEvent(userId, event, payload);
|
||||
}
|
||||
|
||||
getWebhookDeliveries(userId: string, webhookId?: string, limit?: number) {
|
||||
return this.webhookService.getDeliveries(userId, webhookId, limit);
|
||||
}
|
||||
|
||||
retryWebhookDelivery(userId: string, deliveryId: string) {
|
||||
return this.webhookService.retryDelivery(userId, deliveryId);
|
||||
}
|
||||
|
||||
testWebhook(userId: string, webhookId: string) {
|
||||
return this.webhookService.testWebhook(userId, webhookId);
|
||||
}
|
||||
|
||||
getAvailableWebhookEvents() {
|
||||
return this.webhookService.getAvailableEvents();
|
||||
}
|
||||
|
||||
// External APIs
|
||||
configureExternalApi(userId: string, input: any) {
|
||||
return this.webhookService.configureExternalApi(userId, input);
|
||||
}
|
||||
|
||||
getExternalApis(userId: string) {
|
||||
return this.webhookService.getExternalApis(userId);
|
||||
}
|
||||
|
||||
getIntegrationTemplates() {
|
||||
return this.webhookService.getIntegrationTemplates();
|
||||
}
|
||||
|
||||
// ========== COMBINED ==========
|
||||
|
||||
/**
|
||||
* Get comprehensive analytics overview
|
||||
*/
|
||||
getAnalyticsOverview(userId: string): {
|
||||
dashboard: DashboardOverview;
|
||||
goldPosts: ReturnType<typeof this.goldPostService.getGoldPostAnalytics>;
|
||||
growth: GrowthReport;
|
||||
activeTests: number;
|
||||
insights: ReturnType<typeof this.dashboardService.getInsights>;
|
||||
alerts: ReturnType<typeof this.engagementService.getVelocityAlerts>;
|
||||
} {
|
||||
return {
|
||||
dashboard: this.getDashboardOverview(userId, 'week'),
|
||||
goldPosts: this.getGoldPostAnalytics(userId),
|
||||
growth: this.getGrowthReport(userId, 'week'),
|
||||
activeTests: this.getABTests(userId, 'running').length,
|
||||
insights: this.getInsights(userId),
|
||||
alerts: this.getVelocityAlerts(userId),
|
||||
};
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private checkForGoldPost(
|
||||
userId: string,
|
||||
postId: string,
|
||||
metrics: Partial<EngagementMetrics>,
|
||||
platform: string,
|
||||
): void {
|
||||
if (!metrics.engagementRate) return;
|
||||
|
||||
const goldPost = this.goldPostService.detectGoldPost(
|
||||
userId,
|
||||
postId,
|
||||
{
|
||||
engagementRate: metrics.engagementRate,
|
||||
likes: metrics.likes || 0,
|
||||
comments: metrics.comments || 0,
|
||||
shares: metrics.shares || 0,
|
||||
saves: metrics.saves || 0,
|
||||
reach: metrics.reach || 0,
|
||||
impressions: metrics.impressions || 0,
|
||||
},
|
||||
{
|
||||
platform,
|
||||
title: `Post ${postId}`,
|
||||
contentType: 'post',
|
||||
publishedAt: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
if (goldPost) {
|
||||
this.webhookService.triggerEvent(userId, 'gold_post.detected', {
|
||||
goldPostId: goldPost.id,
|
||||
postId: goldPost.postId,
|
||||
level: goldPost.goldLevel,
|
||||
multiplier: goldPost.metrics.multiplier,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/modules/analytics/index.ts
Normal file
12
src/modules/analytics/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Analytics Module - Index exports
|
||||
// Path: src/modules/analytics/index.ts
|
||||
|
||||
export * from './analytics.module';
|
||||
export * from './analytics.service';
|
||||
export * from './analytics.controller';
|
||||
export * from './services/engagement-tracker.service';
|
||||
export * from './services/performance-dashboard.service';
|
||||
export * from './services/ab-testing.service';
|
||||
export * from './services/gold-post-detector.service';
|
||||
export * from './services/growth-formula.service';
|
||||
export * from './services/webhook.service';
|
||||
512
src/modules/analytics/services/ab-testing.service.ts
Normal file
512
src/modules/analytics/services/ab-testing.service.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
// A/B Testing Service - Content experimentation system
|
||||
// Path: src/modules/analytics/services/ab-testing.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface ABTest {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'draft' | 'running' | 'paused' | 'completed' | 'cancelled';
|
||||
testType: 'title' | 'content' | 'timing' | 'format' | 'hashtags' | 'cta' | 'visual';
|
||||
platform: string;
|
||||
startDate: Date;
|
||||
endDate?: Date;
|
||||
variants: ABVariant[];
|
||||
winner?: string;
|
||||
results?: ABTestResults;
|
||||
settings: ABTestSettings;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ABVariant {
|
||||
id: string;
|
||||
name: string;
|
||||
content: Record<string, any>;
|
||||
trafficAllocation: number;
|
||||
metrics: VariantMetrics;
|
||||
isControl: boolean;
|
||||
}
|
||||
|
||||
export interface VariantMetrics {
|
||||
impressions: number;
|
||||
engagements: number;
|
||||
clicks: number;
|
||||
conversions: number;
|
||||
engagementRate: number;
|
||||
clickRate: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
|
||||
export interface ABTestResults {
|
||||
winner: string;
|
||||
confidence: number;
|
||||
uplift: number;
|
||||
significanceReached: boolean;
|
||||
insights: string[];
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export interface ABTestSettings {
|
||||
minSampleSize: number;
|
||||
minConfidence: number;
|
||||
autoEndOnSignificance: boolean;
|
||||
maxDuration: number; // days
|
||||
successMetric: 'engagement' | 'clicks' | 'conversions' | 'reach';
|
||||
}
|
||||
|
||||
export interface ABTestReport {
|
||||
test: ABTest;
|
||||
summary: {
|
||||
totalImpressions: number;
|
||||
totalEngagements: number;
|
||||
duration: number;
|
||||
isSignificant: boolean;
|
||||
};
|
||||
variantComparison: Array<{
|
||||
variant: ABVariant;
|
||||
vsControl: number;
|
||||
isWinning: boolean;
|
||||
}>;
|
||||
timeline: Array<{
|
||||
date: string;
|
||||
variantMetrics: Record<string, VariantMetrics>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AbTestingService {
|
||||
private readonly logger = new Logger(AbTestingService.name);
|
||||
private tests: Map<string, ABTest[]> = new Map();
|
||||
|
||||
/**
|
||||
* Create new A/B test
|
||||
*/
|
||||
createTest(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
description: string;
|
||||
testType: ABTest['testType'];
|
||||
platform: string;
|
||||
variants: Array<{
|
||||
name: string;
|
||||
content: Record<string, any>;
|
||||
trafficAllocation?: number;
|
||||
isControl?: boolean;
|
||||
}>;
|
||||
settings?: Partial<ABTestSettings>;
|
||||
},
|
||||
): ABTest {
|
||||
// Normalize traffic allocation
|
||||
const variantCount = input.variants.length;
|
||||
const defaultAllocation = 100 / variantCount;
|
||||
|
||||
const variants: ABVariant[] = input.variants.map((v, i) => ({
|
||||
id: `variant-${Date.now()}-${i}`,
|
||||
name: v.name,
|
||||
content: v.content,
|
||||
trafficAllocation: v.trafficAllocation || defaultAllocation,
|
||||
isControl: v.isControl || i === 0,
|
||||
metrics: {
|
||||
impressions: 0,
|
||||
engagements: 0,
|
||||
clicks: 0,
|
||||
conversions: 0,
|
||||
engagementRate: 0,
|
||||
clickRate: 0,
|
||||
conversionRate: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
const test: ABTest = {
|
||||
id: `test-${Date.now()}`,
|
||||
userId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
status: 'draft',
|
||||
testType: input.testType,
|
||||
platform: input.platform,
|
||||
startDate: new Date(),
|
||||
variants,
|
||||
settings: {
|
||||
minSampleSize: input.settings?.minSampleSize || 1000,
|
||||
minConfidence: input.settings?.minConfidence || 95,
|
||||
autoEndOnSignificance: input.settings?.autoEndOnSignificance ?? true,
|
||||
maxDuration: input.settings?.maxDuration || 14,
|
||||
successMetric: input.settings?.successMetric || 'engagement',
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const userTests = this.tests.get(userId) || [];
|
||||
userTests.push(test);
|
||||
this.tests.set(userId, userTests);
|
||||
|
||||
this.logger.log(`Created A/B test: ${test.id} for user ${userId}`);
|
||||
return test;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start test
|
||||
*/
|
||||
startTest(userId: string, testId: string): ABTest | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test) return null;
|
||||
|
||||
test.status = 'running';
|
||||
test.startDate = new Date();
|
||||
test.updatedAt = new Date();
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause test
|
||||
*/
|
||||
pauseTest(userId: string, testId: string): ABTest | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test || test.status !== 'running') return null;
|
||||
|
||||
test.status = 'paused';
|
||||
test.updatedAt = new Date();
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume paused test
|
||||
*/
|
||||
resumeTest(userId: string, testId: string): ABTest | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test || test.status !== 'paused') return null;
|
||||
|
||||
test.status = 'running';
|
||||
test.updatedAt = new Date();
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
/**
|
||||
* End test and calculate results
|
||||
*/
|
||||
endTest(userId: string, testId: string): ABTest | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test) return null;
|
||||
|
||||
test.status = 'completed';
|
||||
test.endDate = new Date();
|
||||
test.results = this.calculateResults(test);
|
||||
test.winner = test.results.winner;
|
||||
test.updatedAt = new Date();
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record variant metrics
|
||||
*/
|
||||
recordMetrics(
|
||||
userId: string,
|
||||
testId: string,
|
||||
variantId: string,
|
||||
metrics: Partial<VariantMetrics>,
|
||||
): ABVariant | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test || test.status !== 'running') return null;
|
||||
|
||||
const variant = test.variants.find(v => v.id === variantId);
|
||||
if (!variant) return null;
|
||||
|
||||
// Update metrics
|
||||
if (metrics.impressions) variant.metrics.impressions += metrics.impressions;
|
||||
if (metrics.engagements) variant.metrics.engagements += metrics.engagements;
|
||||
if (metrics.clicks) variant.metrics.clicks += metrics.clicks;
|
||||
if (metrics.conversions) variant.metrics.conversions += metrics.conversions;
|
||||
|
||||
// Recalculate rates
|
||||
if (variant.metrics.impressions > 0) {
|
||||
variant.metrics.engagementRate = (variant.metrics.engagements / variant.metrics.impressions) * 100;
|
||||
variant.metrics.clickRate = (variant.metrics.clicks / variant.metrics.impressions) * 100;
|
||||
variant.metrics.conversionRate = (variant.metrics.conversions / variant.metrics.impressions) * 100;
|
||||
}
|
||||
|
||||
// Check if we should auto-end
|
||||
if (test.settings.autoEndOnSignificance) {
|
||||
const results = this.calculateResults(test);
|
||||
if (results.significanceReached) {
|
||||
this.endTest(userId, testId);
|
||||
}
|
||||
}
|
||||
|
||||
return variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test
|
||||
*/
|
||||
getTest(userId: string, testId: string): ABTest | null {
|
||||
const userTests = this.tests.get(userId) || [];
|
||||
return userTests.find(t => t.id === testId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tests
|
||||
*/
|
||||
getTests(userId: string, status?: ABTest['status']): ABTest[] {
|
||||
let tests = this.tests.get(userId) || [];
|
||||
if (status) {
|
||||
tests = tests.filter(t => t.status === status);
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active tests
|
||||
*/
|
||||
getActiveTests(userId: string): ABTest[] {
|
||||
return this.getTests(userId, 'running');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test report
|
||||
*/
|
||||
getTestReport(userId: string, testId: string): ABTestReport | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test) return null;
|
||||
|
||||
const control = test.variants.find(v => v.isControl)!;
|
||||
const totalImpressions = test.variants.reduce((sum, v) => sum + v.metrics.impressions, 0);
|
||||
const totalEngagements = test.variants.reduce((sum, v) => sum + v.metrics.engagements, 0);
|
||||
|
||||
return {
|
||||
test,
|
||||
summary: {
|
||||
totalImpressions,
|
||||
totalEngagements,
|
||||
duration: test.endDate
|
||||
? Math.ceil((test.endDate.getTime() - test.startDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: Math.ceil((Date.now() - test.startDate.getTime()) / (1000 * 60 * 60 * 24)),
|
||||
isSignificant: test.results?.significanceReached || false,
|
||||
},
|
||||
variantComparison: test.variants.map(v => ({
|
||||
variant: v,
|
||||
vsControl: v.isControl ? 0 : this.calculateUplift(v.metrics, control.metrics, test.settings.successMetric),
|
||||
isWinning: v.id === test.winner,
|
||||
})),
|
||||
timeline: this.generateTimeline(test),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variant to serve (for A/B serving logic)
|
||||
*/
|
||||
getVariantToServe(userId: string, testId: string): ABVariant | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test || test.status !== 'running') return null;
|
||||
|
||||
// Simple weighted random selection
|
||||
const random = Math.random() * 100;
|
||||
let cumulative = 0;
|
||||
|
||||
for (const variant of test.variants) {
|
||||
cumulative += variant.trafficAllocation;
|
||||
if (random <= cumulative) {
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
|
||||
return test.variants[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test templates
|
||||
*/
|
||||
getTestTemplates(): Array<{
|
||||
name: string;
|
||||
testType: ABTest['testType'];
|
||||
description: string;
|
||||
variantTemplate: any;
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
name: 'Title Test',
|
||||
testType: 'title',
|
||||
description: 'Test different titles for the same content',
|
||||
variantTemplate: { title: '' },
|
||||
},
|
||||
{
|
||||
name: 'Hook Comparison',
|
||||
testType: 'content',
|
||||
description: 'Compare different opening hooks',
|
||||
variantTemplate: { hook: '' },
|
||||
},
|
||||
{
|
||||
name: 'CTA Test',
|
||||
testType: 'cta',
|
||||
description: 'Test different calls to action',
|
||||
variantTemplate: { cta: '' },
|
||||
},
|
||||
{
|
||||
name: 'Timing Test',
|
||||
testType: 'timing',
|
||||
description: 'Test posting at different times',
|
||||
variantTemplate: { postTime: '' },
|
||||
},
|
||||
{
|
||||
name: 'Format Test',
|
||||
testType: 'format',
|
||||
description: 'Compare content formats (image vs video vs carousel)',
|
||||
variantTemplate: { format: '' },
|
||||
},
|
||||
{
|
||||
name: 'Hashtag Strategy',
|
||||
testType: 'hashtags',
|
||||
description: 'Test different hashtag combinations',
|
||||
variantTemplate: { hashtags: [] },
|
||||
},
|
||||
{
|
||||
name: 'Visual Test',
|
||||
testType: 'visual',
|
||||
description: 'Compare different visuals/thumbnails',
|
||||
variantTemplate: { imageUrl: '' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete test
|
||||
*/
|
||||
deleteTest(userId: string, testId: string): boolean {
|
||||
const userTests = this.tests.get(userId) || [];
|
||||
const filtered = userTests.filter(t => t.id !== testId);
|
||||
|
||||
if (filtered.length === userTests.length) return false;
|
||||
|
||||
this.tests.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Private calculation methods
|
||||
|
||||
private calculateResults(test: ABTest): ABTestResults {
|
||||
const control = test.variants.find(v => v.isControl)!;
|
||||
const challengers = test.variants.filter(v => !v.isControl);
|
||||
|
||||
let winner = control;
|
||||
let maxUplift = 0;
|
||||
|
||||
for (const challenger of challengers) {
|
||||
const uplift = this.calculateUplift(challenger.metrics, control.metrics, test.settings.successMetric);
|
||||
if (uplift > maxUplift) {
|
||||
maxUplift = uplift;
|
||||
winner = challenger;
|
||||
}
|
||||
}
|
||||
|
||||
const totalSamples = test.variants.reduce((sum, v) => sum + v.metrics.impressions, 0);
|
||||
const hasEnoughSamples = totalSamples >= test.settings.minSampleSize;
|
||||
const confidence = this.calculateConfidence(winner.metrics, control.metrics);
|
||||
const significanceReached = hasEnoughSamples && confidence >= test.settings.minConfidence;
|
||||
|
||||
return {
|
||||
winner: winner.id,
|
||||
confidence,
|
||||
uplift: maxUplift,
|
||||
significanceReached,
|
||||
insights: this.generateInsights(test, winner, control),
|
||||
recommendations: this.generateRecommendations(test, winner, maxUplift),
|
||||
};
|
||||
}
|
||||
|
||||
private calculateUplift(challenger: VariantMetrics, control: VariantMetrics, metric: string): number {
|
||||
const metricKey = metric === 'engagement' ? 'engagementRate' :
|
||||
metric === 'clicks' ? 'clickRate' :
|
||||
metric === 'conversions' ? 'conversionRate' : 'engagementRate';
|
||||
|
||||
const controlValue = control[metricKey];
|
||||
const challengerValue = challenger[metricKey];
|
||||
|
||||
if (controlValue === 0) return 0;
|
||||
return ((challengerValue - controlValue) / controlValue) * 100;
|
||||
}
|
||||
|
||||
private calculateConfidence(challenger: VariantMetrics, control: VariantMetrics): number {
|
||||
// Simplified confidence calculation (would use proper statistical test in production)
|
||||
const n1 = control.impressions;
|
||||
const n2 = challenger.impressions;
|
||||
|
||||
if (n1 < 100 || n2 < 100) return 50;
|
||||
if (n1 < 500 || n2 < 500) return 70 + Math.random() * 10;
|
||||
if (n1 < 1000 || n2 < 1000) return 85 + Math.random() * 10;
|
||||
return 90 + Math.random() * 10;
|
||||
}
|
||||
|
||||
private generateInsights(test: ABTest, winner: ABVariant, control: ABVariant): string[] {
|
||||
const insights: string[] = [];
|
||||
|
||||
if (winner.id !== control.id) {
|
||||
insights.push(`Variant "${winner.name}" outperformed the control`);
|
||||
} else {
|
||||
insights.push('The control variant performed best');
|
||||
}
|
||||
|
||||
if (test.testType === 'title') {
|
||||
insights.push('Title length and power words significantly impact engagement');
|
||||
}
|
||||
if (test.testType === 'cta') {
|
||||
insights.push('Clear, action-oriented CTAs drive higher conversions');
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
|
||||
private generateRecommendations(test: ABTest, winner: ABVariant, uplift: number): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (uplift > 20) {
|
||||
recommendations.push('Strong winner detected! Apply this variant to all future content.');
|
||||
} else if (uplift > 10) {
|
||||
recommendations.push('Consider using the winning variant for important content.');
|
||||
} else {
|
||||
recommendations.push('Difference is marginal. Continue testing with new variants.');
|
||||
}
|
||||
|
||||
recommendations.push('Run follow-up tests to validate results.');
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
private generateTimeline(test: ABTest): ABTestReport['timeline'] {
|
||||
// Generate simulated daily timeline
|
||||
const days = Math.min(14, Math.ceil((Date.now() - test.startDate.getTime()) / (1000 * 60 * 60 * 24)));
|
||||
const timeline: ABTestReport['timeline'] = [];
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(test.startDate);
|
||||
date.setDate(date.getDate() + i);
|
||||
|
||||
const variantMetrics: Record<string, VariantMetrics> = {};
|
||||
for (const v of test.variants) {
|
||||
variantMetrics[v.id] = {
|
||||
impressions: Math.floor(v.metrics.impressions / days),
|
||||
engagements: Math.floor(v.metrics.engagements / days),
|
||||
clicks: Math.floor(v.metrics.clicks / days),
|
||||
conversions: Math.floor(v.metrics.conversions / days),
|
||||
engagementRate: v.metrics.engagementRate,
|
||||
clickRate: v.metrics.clickRate,
|
||||
conversionRate: v.metrics.conversionRate,
|
||||
};
|
||||
}
|
||||
|
||||
timeline.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
variantMetrics,
|
||||
});
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
}
|
||||
357
src/modules/analytics/services/engagement-tracker.service.ts
Normal file
357
src/modules/analytics/services/engagement-tracker.service.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
// Engagement Tracker Service - Real-time engagement data collection
|
||||
// Path: src/modules/analytics/services/engagement-tracker.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface EngagementMetrics {
|
||||
postId: string;
|
||||
platform: string;
|
||||
timestamp: Date;
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
saves: number;
|
||||
views: number;
|
||||
impressions: number;
|
||||
reach: number;
|
||||
clicks: number;
|
||||
profileVisits: number;
|
||||
follows: number;
|
||||
engagementRate: number;
|
||||
viralityScore: number;
|
||||
}
|
||||
|
||||
export interface EngagementSnapshot {
|
||||
id: string;
|
||||
postId: string;
|
||||
metrics: EngagementMetrics;
|
||||
hoursSincePublish: number;
|
||||
velocityScore: number;
|
||||
}
|
||||
|
||||
export interface EngagementTrend {
|
||||
period: string;
|
||||
metrics: {
|
||||
totalEngagements: number;
|
||||
avgEngagementRate: number;
|
||||
topPerformingType: string;
|
||||
growthRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentPerformance {
|
||||
postId: string;
|
||||
title: string;
|
||||
platform: string;
|
||||
publishedAt: Date;
|
||||
currentMetrics: EngagementMetrics;
|
||||
performanceScore: number;
|
||||
benchmarkComparison: {
|
||||
vsAverage: number;
|
||||
vsSimilarContent: number;
|
||||
vsTimeSlot: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EngagementTrackerService {
|
||||
private readonly logger = new Logger(EngagementTrackerService.name);
|
||||
private engagementData: Map<string, EngagementSnapshot[]> = new Map();
|
||||
private userBenchmarks: Map<string, Record<string, number>> = new Map();
|
||||
|
||||
// Platform-specific weight configs
|
||||
private readonly platformWeights: Record<string, Record<string, number>> = {
|
||||
twitter: { likes: 1, retweets: 2, replies: 3, bookmarks: 1.5, impressions: 0.01 },
|
||||
instagram: { likes: 1, comments: 3, shares: 4, saves: 5, reach: 0.02, views: 0.1 },
|
||||
linkedin: { likes: 1, comments: 3, shares: 4, clicks: 2, impressions: 0.01 },
|
||||
facebook: { likes: 1, comments: 2, shares: 3, clicks: 1.5, reach: 0.01 },
|
||||
tiktok: { likes: 0.5, comments: 2, shares: 4, views: 0.1, watchTime: 3 },
|
||||
youtube: { likes: 1, comments: 3, shares: 2, views: 0.2, watchTime: 2, subscribers: 5 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Record engagement snapshot
|
||||
*/
|
||||
recordEngagement(userId: string, postId: string, platform: string, rawMetrics: Partial<EngagementMetrics>): EngagementSnapshot {
|
||||
const metrics: EngagementMetrics = {
|
||||
postId,
|
||||
platform,
|
||||
timestamp: new Date(),
|
||||
likes: rawMetrics.likes || 0,
|
||||
comments: rawMetrics.comments || 0,
|
||||
shares: rawMetrics.shares || 0,
|
||||
saves: rawMetrics.saves || 0,
|
||||
views: rawMetrics.views || 0,
|
||||
impressions: rawMetrics.impressions || 0,
|
||||
reach: rawMetrics.reach || 0,
|
||||
clicks: rawMetrics.clicks || 0,
|
||||
profileVisits: rawMetrics.profileVisits || 0,
|
||||
follows: rawMetrics.follows || 0,
|
||||
engagementRate: this.calculateEngagementRate(rawMetrics),
|
||||
viralityScore: this.calculateViralityScore(platform, rawMetrics),
|
||||
};
|
||||
|
||||
const snapshots = this.engagementData.get(userId) || [];
|
||||
const publishedAt = snapshots.find(s => s.postId === postId)?.metrics.timestamp || new Date();
|
||||
|
||||
const snapshot: EngagementSnapshot = {
|
||||
id: `snap-${Date.now()}`,
|
||||
postId,
|
||||
metrics,
|
||||
hoursSincePublish: (Date.now() - publishedAt.getTime()) / (1000 * 60 * 60),
|
||||
velocityScore: this.calculateVelocityScore(userId, postId, metrics),
|
||||
};
|
||||
|
||||
snapshots.push(snapshot);
|
||||
this.engagementData.set(userId, snapshots);
|
||||
|
||||
// Update benchmarks
|
||||
this.updateBenchmarks(userId, platform, metrics);
|
||||
|
||||
this.logger.log(`Recorded engagement for post ${postId}: ER=${metrics.engagementRate.toFixed(2)}%`);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement history for a post
|
||||
*/
|
||||
getPostEngagementHistory(userId: string, postId: string): EngagementSnapshot[] {
|
||||
const snapshots = this.engagementData.get(userId) || [];
|
||||
return snapshots.filter(s => s.postId === postId).sort((a, b) =>
|
||||
a.metrics.timestamp.getTime() - b.metrics.timestamp.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content performance summary
|
||||
*/
|
||||
getContentPerformance(userId: string, postId: string): ContentPerformance | null {
|
||||
const snapshots = this.getPostEngagementHistory(userId, postId);
|
||||
if (snapshots.length === 0) return null;
|
||||
|
||||
const latest = snapshots[snapshots.length - 1];
|
||||
const benchmarks = this.userBenchmarks.get(userId) || {};
|
||||
|
||||
return {
|
||||
postId,
|
||||
title: `Post ${postId}`,
|
||||
platform: latest.metrics.platform,
|
||||
publishedAt: snapshots[0].metrics.timestamp,
|
||||
currentMetrics: latest.metrics,
|
||||
performanceScore: this.calculatePerformanceScore(latest.metrics, benchmarks),
|
||||
benchmarkComparison: {
|
||||
vsAverage: (latest.metrics.engagementRate / (benchmarks[`${latest.metrics.platform}_avg`] || 1)) * 100,
|
||||
vsSimilarContent: Math.random() * 50 + 75, // Simulated - would compare to similar content types
|
||||
vsTimeSlot: Math.random() * 30 + 85, // Simulated - would compare to same time slot
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement trends
|
||||
*/
|
||||
getEngagementTrends(userId: string, period: 'day' | 'week' | 'month'): EngagementTrend {
|
||||
const snapshots = this.engagementData.get(userId) || [];
|
||||
const now = new Date();
|
||||
|
||||
const periodMs = {
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
week: 7 * 24 * 60 * 60 * 1000,
|
||||
month: 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const relevantSnapshots = snapshots.filter(s =>
|
||||
now.getTime() - s.metrics.timestamp.getTime() < periodMs[period]
|
||||
);
|
||||
|
||||
const totalEngagements = relevantSnapshots.reduce((sum, s) =>
|
||||
sum + s.metrics.likes + s.metrics.comments + s.metrics.shares + s.metrics.saves, 0
|
||||
);
|
||||
|
||||
const avgRate = relevantSnapshots.length > 0
|
||||
? relevantSnapshots.reduce((sum, s) => sum + s.metrics.engagementRate, 0) / relevantSnapshots.length
|
||||
: 0;
|
||||
|
||||
// Find top performing content type (simulated)
|
||||
const contentTypes = ['video', 'carousel', 'single_image', 'text'];
|
||||
const topType = contentTypes[Math.floor(Math.random() * contentTypes.length)];
|
||||
|
||||
return {
|
||||
period,
|
||||
metrics: {
|
||||
totalEngagements,
|
||||
avgEngagementRate: avgRate,
|
||||
topPerformingType: topType,
|
||||
growthRate: Math.random() * 20 - 5, // Simulated growth rate
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top performing content
|
||||
*/
|
||||
getTopPerformingContent(userId: string, limit: number = 10): ContentPerformance[] {
|
||||
const snapshots = this.engagementData.get(userId) || [];
|
||||
const postIds = [...new Set(snapshots.map(s => s.postId))];
|
||||
|
||||
const performances = postIds
|
||||
.map(postId => this.getContentPerformance(userId, postId))
|
||||
.filter((p): p is ContentPerformance => p !== null)
|
||||
.sort((a, b) => b.performanceScore - a.performanceScore);
|
||||
|
||||
return performances.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time velocity alerts (posts gaining traction fast)
|
||||
*/
|
||||
getVelocityAlerts(userId: string): Array<{
|
||||
postId: string;
|
||||
velocityScore: number;
|
||||
trend: 'accelerating' | 'stable' | 'declining';
|
||||
recommendation: string;
|
||||
}> {
|
||||
const snapshots = this.engagementData.get(userId) || [];
|
||||
const alerts: ReturnType<typeof this.getVelocityAlerts> = [];
|
||||
|
||||
// Get unique posts from last 24 hours
|
||||
const recentPosts = new Map<string, EngagementSnapshot[]>();
|
||||
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const snap of snapshots) {
|
||||
if (snap.metrics.timestamp.getTime() > oneDayAgo) {
|
||||
const existing = recentPosts.get(snap.postId) || [];
|
||||
existing.push(snap);
|
||||
recentPosts.set(snap.postId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [postId, postSnapshots] of recentPosts) {
|
||||
if (postSnapshots.length < 2) continue;
|
||||
|
||||
const sorted = postSnapshots.sort((a, b) =>
|
||||
a.metrics.timestamp.getTime() - b.metrics.timestamp.getTime()
|
||||
);
|
||||
|
||||
const latest = sorted[sorted.length - 1];
|
||||
const previous = sorted[sorted.length - 2];
|
||||
|
||||
const velocityChange = latest.velocityScore - previous.velocityScore;
|
||||
let trend: 'accelerating' | 'stable' | 'declining';
|
||||
let recommendation: string;
|
||||
|
||||
if (velocityChange > 5) {
|
||||
trend = 'accelerating';
|
||||
recommendation = 'This post is going viral! Consider boosting or creating follow-up content.';
|
||||
} else if (velocityChange < -5) {
|
||||
trend = 'declining';
|
||||
recommendation = 'Engagement is slowing. Consider engaging with comments to revive interest.';
|
||||
} else {
|
||||
trend = 'stable';
|
||||
recommendation = 'Engagement is steady. Monitor for changes.';
|
||||
}
|
||||
|
||||
if (trend !== 'stable') {
|
||||
alerts.push({
|
||||
postId,
|
||||
velocityScore: latest.velocityScore,
|
||||
trend,
|
||||
recommendation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-specific benchmarks
|
||||
*/
|
||||
getPlatformBenchmarks(userId: string): Record<string, {
|
||||
avgEngagementRate: number;
|
||||
avgReach: number;
|
||||
bestPerformingDay: string;
|
||||
bestPerformingTime: string;
|
||||
}> {
|
||||
const benchmarks = this.userBenchmarks.get(userId) || {};
|
||||
const platforms = ['twitter', 'instagram', 'linkedin', 'facebook', 'tiktok', 'youtube'];
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const times = ['9:00 AM', '12:00 PM', '3:00 PM', '6:00 PM', '9:00 PM'];
|
||||
|
||||
for (const platform of platforms) {
|
||||
result[platform] = {
|
||||
avgEngagementRate: benchmarks[`${platform}_avg`] || 2.5 + Math.random() * 3,
|
||||
avgReach: benchmarks[`${platform}_reach`] || 1000 + Math.random() * 5000,
|
||||
bestPerformingDay: days[Math.floor(Math.random() * days.length)],
|
||||
bestPerformingTime: times[Math.floor(Math.random() * times.length)],
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Private calculation methods
|
||||
|
||||
private calculateEngagementRate(metrics: Partial<EngagementMetrics>): number {
|
||||
const interactions = (metrics.likes || 0) + (metrics.comments || 0) +
|
||||
(metrics.shares || 0) + (metrics.saves || 0);
|
||||
const reach = metrics.reach || metrics.impressions || 1;
|
||||
return (interactions / reach) * 100;
|
||||
}
|
||||
|
||||
private calculateViralityScore(platform: string, metrics: Partial<EngagementMetrics>): number {
|
||||
const weights = this.platformWeights[platform] || this.platformWeights.twitter;
|
||||
let score = 0;
|
||||
|
||||
if (metrics.likes) score += metrics.likes * (weights.likes || 1);
|
||||
if (metrics.comments) score += metrics.comments * (weights.comments || 1);
|
||||
if (metrics.shares) score += metrics.shares * (weights.shares || 1);
|
||||
if (metrics.saves) score += metrics.saves * (weights.saves || 1);
|
||||
if (metrics.impressions) score += metrics.impressions * (weights.impressions || 0.01);
|
||||
if (metrics.views) score += metrics.views * (weights.views || 0.1);
|
||||
|
||||
return Math.min(100, score / 10);
|
||||
}
|
||||
|
||||
private calculateVelocityScore(userId: string, postId: string, currentMetrics: EngagementMetrics): number {
|
||||
const history = this.getPostEngagementHistory(userId, postId);
|
||||
if (history.length === 0) return 50; // Baseline for new posts
|
||||
|
||||
const previous = history[history.length - 1];
|
||||
const timeDiff = (currentMetrics.timestamp.getTime() - previous.metrics.timestamp.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
if (timeDiff === 0) return previous.velocityScore;
|
||||
|
||||
const engagementGrowth =
|
||||
(currentMetrics.likes - previous.metrics.likes) +
|
||||
(currentMetrics.comments - previous.metrics.comments) * 3 +
|
||||
(currentMetrics.shares - previous.metrics.shares) * 4;
|
||||
|
||||
const velocityChange = engagementGrowth / timeDiff;
|
||||
return Math.min(100, Math.max(0, previous.velocityScore + velocityChange));
|
||||
}
|
||||
|
||||
private calculatePerformanceScore(metrics: EngagementMetrics, benchmarks: Record<string, number>): number {
|
||||
const avgRate = benchmarks[`${metrics.platform}_avg`] || 2.5;
|
||||
const relative = metrics.engagementRate / avgRate;
|
||||
return Math.min(100, relative * 50);
|
||||
}
|
||||
|
||||
private updateBenchmarks(userId: string, platform: string, metrics: EngagementMetrics): void {
|
||||
const benchmarks = this.userBenchmarks.get(userId) || {};
|
||||
const key = `${platform}_avg`;
|
||||
const reachKey = `${platform}_reach`;
|
||||
|
||||
benchmarks[key] = benchmarks[key]
|
||||
? (benchmarks[key] * 0.9 + metrics.engagementRate * 0.1)
|
||||
: metrics.engagementRate;
|
||||
|
||||
benchmarks[reachKey] = benchmarks[reachKey]
|
||||
? (benchmarks[reachKey] * 0.9 + metrics.reach * 0.1)
|
||||
: metrics.reach;
|
||||
|
||||
this.userBenchmarks.set(userId, benchmarks);
|
||||
}
|
||||
}
|
||||
526
src/modules/analytics/services/gold-post-detector.service.ts
Normal file
526
src/modules/analytics/services/gold-post-detector.service.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
// Gold Post Detector Service - Identify and track high-performing content
|
||||
// Path: src/modules/analytics/services/gold-post-detector.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface GoldPost {
|
||||
id: string;
|
||||
postId: string;
|
||||
userId: string;
|
||||
platform: string;
|
||||
title: string;
|
||||
contentType: string;
|
||||
publishedAt: Date;
|
||||
detectedAt: Date;
|
||||
metrics: GoldPostMetrics;
|
||||
goldScore: number;
|
||||
goldLevel: 'bronze' | 'silver' | 'gold' | 'platinum' | 'diamond';
|
||||
viralFactors: ViralFactor[];
|
||||
spinoffs: SpinoffContent[];
|
||||
dna: ContentDNA;
|
||||
}
|
||||
|
||||
export interface GoldPostMetrics {
|
||||
engagementRate: number;
|
||||
avgEngagementRate: number;
|
||||
multiplier: number;
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
saves: number;
|
||||
reach: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
export interface ViralFactor {
|
||||
factor: string;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SpinoffContent {
|
||||
id: string;
|
||||
type: 'variation' | 'sequel' | 'different_platform' | 'different_format' | 'deep_dive';
|
||||
title: string;
|
||||
status: 'planned' | 'draft' | 'published';
|
||||
publishedAt?: Date;
|
||||
performance?: {
|
||||
engagementRate: number;
|
||||
comparedToOriginal: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentDNA {
|
||||
hook: {
|
||||
type: string;
|
||||
text: string;
|
||||
score: number;
|
||||
};
|
||||
structure: string;
|
||||
psychologyTriggers: string[];
|
||||
emotionalTone: string;
|
||||
topicCategory: string;
|
||||
contentLength: 'short' | 'medium' | 'long';
|
||||
visualStyle?: string;
|
||||
ctaType?: string;
|
||||
}
|
||||
|
||||
export interface GoldPostCriteria {
|
||||
minMultiplier: number;
|
||||
minEngagementRate: number;
|
||||
minSamplePeriod: number; // hours
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GoldPostDetectorService {
|
||||
private readonly logger = new Logger(GoldPostDetectorService.name);
|
||||
private goldPosts: Map<string, GoldPost[]> = new Map();
|
||||
private userBenchmarks: Map<string, Record<string, number>> = new Map();
|
||||
|
||||
// Gold level thresholds
|
||||
private readonly goldLevels = {
|
||||
bronze: 3, // 3x average engagement
|
||||
silver: 5, // 5x average
|
||||
gold: 10, // 10x average
|
||||
platinum: 20, // 20x average
|
||||
diamond: 50, // 50x average
|
||||
};
|
||||
|
||||
// Default detection criteria
|
||||
private readonly defaultCriteria: GoldPostCriteria = {
|
||||
minMultiplier: 3,
|
||||
minEngagementRate: 5,
|
||||
minSamplePeriod: 24,
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if content qualifies as gold post
|
||||
*/
|
||||
detectGoldPost(
|
||||
userId: string,
|
||||
postId: string,
|
||||
metrics: {
|
||||
engagementRate: number;
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
saves: number;
|
||||
reach: number;
|
||||
impressions: number;
|
||||
},
|
||||
context: {
|
||||
platform: string;
|
||||
title: string;
|
||||
contentType: string;
|
||||
publishedAt: Date;
|
||||
content?: string;
|
||||
},
|
||||
): GoldPost | null {
|
||||
const avgRate = this.getUserAverageEngagement(userId, context.platform);
|
||||
const multiplier = avgRate > 0 ? metrics.engagementRate / avgRate : 0;
|
||||
|
||||
// Check if it qualifies
|
||||
if (multiplier < this.defaultCriteria.minMultiplier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const goldLevel = this.calculateGoldLevel(multiplier);
|
||||
const goldScore = this.calculateGoldScore(metrics, multiplier, context.contentType);
|
||||
|
||||
const goldPost: GoldPost = {
|
||||
id: `gold-${Date.now()}`,
|
||||
postId,
|
||||
userId,
|
||||
platform: context.platform,
|
||||
title: context.title,
|
||||
contentType: context.contentType,
|
||||
publishedAt: context.publishedAt,
|
||||
detectedAt: new Date(),
|
||||
metrics: {
|
||||
avgEngagementRate: avgRate,
|
||||
multiplier,
|
||||
...metrics,
|
||||
},
|
||||
goldScore,
|
||||
goldLevel,
|
||||
viralFactors: this.analyzeViralFactors(metrics, context),
|
||||
spinoffs: [],
|
||||
dna: this.extractContentDNA(context),
|
||||
};
|
||||
|
||||
// Store gold post
|
||||
const userGoldPosts = this.goldPosts.get(userId) || [];
|
||||
userGoldPosts.push(goldPost);
|
||||
this.goldPosts.set(userId, userGoldPosts);
|
||||
|
||||
this.logger.log(`Gold Post detected! ${postId} - ${goldLevel} (${multiplier.toFixed(1)}x)`);
|
||||
return goldPost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's gold posts
|
||||
*/
|
||||
getGoldPosts(userId: string, options?: {
|
||||
level?: GoldPost['goldLevel'];
|
||||
platform?: string;
|
||||
limit?: number;
|
||||
}): GoldPost[] {
|
||||
let posts = this.goldPosts.get(userId) || [];
|
||||
|
||||
if (options?.level) {
|
||||
posts = posts.filter(p => p.goldLevel === options.level);
|
||||
}
|
||||
if (options?.platform) {
|
||||
posts = posts.filter(p => p.platform === options.platform);
|
||||
}
|
||||
|
||||
posts = posts.sort((a, b) => b.goldScore - a.goldScore);
|
||||
|
||||
if (options?.limit) {
|
||||
posts = posts.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gold post by ID
|
||||
*/
|
||||
getGoldPost(userId: string, goldPostId: string): GoldPost | null {
|
||||
const posts = this.goldPosts.get(userId) || [];
|
||||
return posts.find(p => p.id === goldPostId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add spinoff content
|
||||
*/
|
||||
addSpinoff(
|
||||
userId: string,
|
||||
goldPostId: string,
|
||||
spinoff: Omit<SpinoffContent, 'id'>,
|
||||
): SpinoffContent | null {
|
||||
const goldPost = this.getGoldPost(userId, goldPostId);
|
||||
if (!goldPost) return null;
|
||||
|
||||
const newSpinoff: SpinoffContent = {
|
||||
...spinoff,
|
||||
id: `spinoff-${Date.now()}`,
|
||||
};
|
||||
|
||||
goldPost.spinoffs.push(newSpinoff);
|
||||
return newSpinoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update spinoff status
|
||||
*/
|
||||
updateSpinoff(
|
||||
userId: string,
|
||||
goldPostId: string,
|
||||
spinoffId: string,
|
||||
updates: Partial<SpinoffContent>,
|
||||
): SpinoffContent | null {
|
||||
const goldPost = this.getGoldPost(userId, goldPostId);
|
||||
if (!goldPost) return null;
|
||||
|
||||
const spinoff = goldPost.spinoffs.find(s => s.id === spinoffId);
|
||||
if (!spinoff) return null;
|
||||
|
||||
Object.assign(spinoff, updates);
|
||||
return spinoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gold post analytics
|
||||
*/
|
||||
getGoldPostAnalytics(userId: string): {
|
||||
total: number;
|
||||
byLevel: Record<GoldPost['goldLevel'], number>;
|
||||
byPlatform: Record<string, number>;
|
||||
avgMultiplier: number;
|
||||
topViralFactors: Array<{ factor: string; count: number }>;
|
||||
spinoffSuccessRate: number;
|
||||
} {
|
||||
const posts = this.goldPosts.get(userId) || [];
|
||||
|
||||
const byLevel: Record<GoldPost['goldLevel'], number> = {
|
||||
bronze: 0, silver: 0, gold: 0, platinum: 0, diamond: 0,
|
||||
};
|
||||
const byPlatform: Record<string, number> = {};
|
||||
const viralFactorCounts: Record<string, number> = {};
|
||||
let totalMultiplier = 0;
|
||||
let totalSpinoffs = 0;
|
||||
let successfulSpinoffs = 0;
|
||||
|
||||
for (const post of posts) {
|
||||
byLevel[post.goldLevel]++;
|
||||
byPlatform[post.platform] = (byPlatform[post.platform] || 0) + 1;
|
||||
totalMultiplier += post.metrics.multiplier;
|
||||
|
||||
for (const factor of post.viralFactors) {
|
||||
viralFactorCounts[factor.factor] = (viralFactorCounts[factor.factor] || 0) + 1;
|
||||
}
|
||||
|
||||
for (const spinoff of post.spinoffs) {
|
||||
totalSpinoffs++;
|
||||
if (spinoff.performance && spinoff.performance.comparedToOriginal > 50) {
|
||||
successfulSpinoffs++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topViralFactors = Object.entries(viralFactorCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([factor, count]) => ({ factor, count }));
|
||||
|
||||
return {
|
||||
total: posts.length,
|
||||
byLevel,
|
||||
byPlatform,
|
||||
avgMultiplier: posts.length > 0 ? totalMultiplier / posts.length : 0,
|
||||
topViralFactors,
|
||||
spinoffSuccessRate: totalSpinoffs > 0 ? (successfulSpinoffs / totalSpinoffs) * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get success patterns from gold posts
|
||||
*/
|
||||
getSuccessPatterns(userId: string): {
|
||||
bestContentTypes: Array<{ type: string; count: number; avgMultiplier: number }>;
|
||||
bestTimes: Array<{ day: string; hour: number; count: number }>;
|
||||
commonTriggers: string[];
|
||||
winningStructures: string[];
|
||||
recommendations: string[];
|
||||
} {
|
||||
const posts = this.goldPosts.get(userId) || [];
|
||||
|
||||
// Analyze content types
|
||||
const contentTypes: Record<string, { count: number; multipliers: number[] }> = {};
|
||||
const postingTimes: Record<string, { count: number }> = {};
|
||||
const triggers = new Set<string>();
|
||||
const structures = new Set<string>();
|
||||
|
||||
for (const post of posts) {
|
||||
// Content types
|
||||
if (!contentTypes[post.contentType]) {
|
||||
contentTypes[post.contentType] = { count: 0, multipliers: [] };
|
||||
}
|
||||
contentTypes[post.contentType].count++;
|
||||
contentTypes[post.contentType].multipliers.push(post.metrics.multiplier);
|
||||
|
||||
// Posting times
|
||||
const day = post.publishedAt.toLocaleDateString('en-US', { weekday: 'long' });
|
||||
const hour = post.publishedAt.getHours();
|
||||
const timeKey = `${day}-${hour}`;
|
||||
postingTimes[timeKey] = postingTimes[timeKey] || { count: 0 };
|
||||
postingTimes[timeKey].count++;
|
||||
|
||||
// Triggers and structures
|
||||
post.dna.psychologyTriggers.forEach(t => triggers.add(t));
|
||||
structures.add(post.dna.structure);
|
||||
}
|
||||
|
||||
const bestContentTypes = Object.entries(contentTypes)
|
||||
.map(([type, data]) => ({
|
||||
type,
|
||||
count: data.count,
|
||||
avgMultiplier: data.multipliers.reduce((a, b) => a + b, 0) / data.multipliers.length,
|
||||
}))
|
||||
.sort((a, b) => b.avgMultiplier - a.avgMultiplier);
|
||||
|
||||
const bestTimes = Object.entries(postingTimes)
|
||||
.map(([key, data]) => {
|
||||
const [day, hour] = key.split('-');
|
||||
return { day, hour: parseInt(hour), count: data.count };
|
||||
})
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
bestContentTypes,
|
||||
bestTimes,
|
||||
commonTriggers: Array.from(triggers).slice(0, 10),
|
||||
winningStructures: Array.from(structures).slice(0, 5),
|
||||
recommendations: this.generatePatternRecommendations(bestContentTypes, bestTimes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate spinoff suggestions
|
||||
*/
|
||||
generateSpinoffSuggestions(userId: string, goldPostId: string): Array<{
|
||||
type: SpinoffContent['type'];
|
||||
title: string;
|
||||
description: string;
|
||||
estimatedPerformance: number;
|
||||
}> {
|
||||
const goldPost = this.getGoldPost(userId, goldPostId);
|
||||
if (!goldPost) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'variation',
|
||||
title: `${goldPost.title} - Part 2`,
|
||||
description: 'Create a sequel exploring the topic in more depth',
|
||||
estimatedPerformance: 70,
|
||||
},
|
||||
{
|
||||
type: 'different_platform',
|
||||
title: `${goldPost.title} for LinkedIn`,
|
||||
description: 'Adapt this content for a professional audience',
|
||||
estimatedPerformance: 65,
|
||||
},
|
||||
{
|
||||
type: 'different_format',
|
||||
title: `${goldPost.title} - Video Version`,
|
||||
description: 'Transform this content into a video format',
|
||||
estimatedPerformance: 80,
|
||||
},
|
||||
{
|
||||
type: 'deep_dive',
|
||||
title: `${goldPost.title} - Complete Guide`,
|
||||
description: 'Expand into a comprehensive guide or article',
|
||||
estimatedPerformance: 60,
|
||||
},
|
||||
{
|
||||
type: 'sequel',
|
||||
title: `What happened after ${goldPost.title}`,
|
||||
description: 'Follow-up content with updates or results',
|
||||
estimatedPerformance: 75,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user benchmark
|
||||
*/
|
||||
setUserBenchmark(userId: string, platform: string, avgEngagement: number): void {
|
||||
const benchmarks = this.userBenchmarks.get(userId) || {};
|
||||
benchmarks[platform] = avgEngagement;
|
||||
this.userBenchmarks.set(userId, benchmarks);
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private getUserAverageEngagement(userId: string, platform: string): number {
|
||||
const benchmarks = this.userBenchmarks.get(userId) || {};
|
||||
return benchmarks[platform] || 2.5; // Default benchmark
|
||||
}
|
||||
|
||||
private calculateGoldLevel(multiplier: number): GoldPost['goldLevel'] {
|
||||
if (multiplier >= this.goldLevels.diamond) return 'diamond';
|
||||
if (multiplier >= this.goldLevels.platinum) return 'platinum';
|
||||
if (multiplier >= this.goldLevels.gold) return 'gold';
|
||||
if (multiplier >= this.goldLevels.silver) return 'silver';
|
||||
return 'bronze';
|
||||
}
|
||||
|
||||
private calculateGoldScore(
|
||||
metrics: { shares: number; saves: number; comments: number },
|
||||
multiplier: number,
|
||||
contentType: string,
|
||||
): number {
|
||||
// Score based on multiplier + engagement quality
|
||||
const baseScore = Math.min(100, multiplier * 5);
|
||||
const shareScore = Math.min(20, metrics.shares / 10);
|
||||
const saveScore = Math.min(20, metrics.saves / 10);
|
||||
const commentScore = Math.min(10, metrics.comments / 20);
|
||||
|
||||
return Math.min(100, baseScore + shareScore + saveScore + commentScore);
|
||||
}
|
||||
|
||||
private analyzeViralFactors(
|
||||
metrics: { shares: number; saves: number; comments: number; reach: number },
|
||||
context: { contentType: string },
|
||||
): ViralFactor[] {
|
||||
const factors: ViralFactor[] = [];
|
||||
|
||||
if (metrics.shares > 100) {
|
||||
factors.push({
|
||||
factor: 'High Shareability',
|
||||
impact: 'high',
|
||||
description: 'Content is highly share-worthy, indicating strong value or emotional resonance',
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.saves > 50) {
|
||||
factors.push({
|
||||
factor: 'Save-Worthy Content',
|
||||
impact: 'high',
|
||||
description: 'Users are saving for later, indicating practical value',
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.comments > 50) {
|
||||
factors.push({
|
||||
factor: 'Conversation Starter',
|
||||
impact: 'medium',
|
||||
description: 'Content sparks discussion and debate',
|
||||
});
|
||||
}
|
||||
|
||||
if (context.contentType === 'video' || context.contentType === 'reel') {
|
||||
factors.push({
|
||||
factor: 'Video Format',
|
||||
impact: 'high',
|
||||
description: 'Video content typically sees higher engagement',
|
||||
});
|
||||
}
|
||||
|
||||
// Add some common viral factors
|
||||
factors.push(
|
||||
{ factor: 'Strong Hook', impact: 'high', description: 'Opening grabs attention immediately' },
|
||||
{ factor: 'Emotional Resonance', impact: 'medium', description: 'Content triggers emotional response' },
|
||||
);
|
||||
|
||||
return factors;
|
||||
}
|
||||
|
||||
private extractContentDNA(context: { title: string; content?: string; contentType: string }): ContentDNA {
|
||||
return {
|
||||
hook: {
|
||||
type: 'curiosity_gap',
|
||||
text: context.title.substring(0, 50),
|
||||
score: 85,
|
||||
},
|
||||
structure: 'hook-story-cta',
|
||||
psychologyTriggers: ['curiosity', 'social_proof', 'scarcity', 'authority'],
|
||||
emotionalTone: 'inspiring',
|
||||
topicCategory: 'general',
|
||||
contentLength: context.content
|
||||
? (context.content.length < 280 ? 'short' : context.content.length < 1000 ? 'medium' : 'long')
|
||||
: 'medium',
|
||||
visualStyle: context.contentType.includes('video') ? 'dynamic' : 'static',
|
||||
ctaType: 'engagement',
|
||||
};
|
||||
}
|
||||
|
||||
private generatePatternRecommendations(
|
||||
contentTypes: Array<{ type: string; avgMultiplier: number }>,
|
||||
times: Array<{ day: string; hour: number }>,
|
||||
): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (contentTypes.length > 0) {
|
||||
recommendations.push(
|
||||
`Focus on ${contentTypes[0].type} content - it has the highest viral potential`,
|
||||
);
|
||||
}
|
||||
|
||||
if (times.length > 0) {
|
||||
recommendations.push(
|
||||
`Post on ${times[0].day}s around ${times[0].hour}:00 for best results`,
|
||||
);
|
||||
}
|
||||
|
||||
recommendations.push(
|
||||
'Replicate the hook style from your gold posts',
|
||||
'Use similar emotional triggers in new content',
|
||||
'Create spinoffs from your best performers',
|
||||
);
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
}
|
||||
431
src/modules/analytics/services/growth-formula.service.ts
Normal file
431
src/modules/analytics/services/growth-formula.service.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
// Growth Formula Service - Track and analyze content growth patterns
|
||||
// Path: src/modules/analytics/services/growth-formula.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface GrowthFormula {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
components: FormulaComponent[];
|
||||
performance: FormulaPerformance;
|
||||
status: 'testing' | 'validated' | 'optimizing' | 'retired';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface FormulaComponent {
|
||||
type: 'content_type' | 'posting_time' | 'hook_style' | 'format' | 'topic' | 'cta' | 'hashtags' | 'visual';
|
||||
value: string;
|
||||
weight: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export interface FormulaPerformance {
|
||||
testsRun: number;
|
||||
successfulTests: number;
|
||||
avgEngagement: number;
|
||||
avgReach: number;
|
||||
goldPostsProduced: number;
|
||||
confidenceScore: number;
|
||||
}
|
||||
|
||||
export interface GrowthExperiment {
|
||||
id: string;
|
||||
userId: string;
|
||||
formulaId: string;
|
||||
hypothesis: string;
|
||||
variables: Record<string, any>;
|
||||
startDate: Date;
|
||||
endDate?: Date;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
results?: ExperimentResults;
|
||||
}
|
||||
|
||||
export interface ExperimentResults {
|
||||
success: boolean;
|
||||
metrics: {
|
||||
engagementRate: number;
|
||||
reach: number;
|
||||
growth: number;
|
||||
};
|
||||
insights: string[];
|
||||
}
|
||||
|
||||
export interface GrowthReport {
|
||||
period: string;
|
||||
overallGrowth: number;
|
||||
topFormulas: GrowthFormula[];
|
||||
activeExperiments: number;
|
||||
recommendations: string[];
|
||||
projectedGrowth: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GrowthFormulaService {
|
||||
private readonly logger = new Logger(GrowthFormulaService.name);
|
||||
private formulas: Map<string, GrowthFormula[]> = new Map();
|
||||
private experiments: Map<string, GrowthExperiment[]> = new Map();
|
||||
|
||||
// Pre-built growth formulas
|
||||
private readonly prebuiltFormulas: Array<Omit<GrowthFormula, 'id' | 'userId' | 'createdAt' | 'updatedAt'>> = [
|
||||
{
|
||||
name: 'Viral Video Formula',
|
||||
description: 'Optimized for video content with high share rate',
|
||||
components: [
|
||||
{ type: 'content_type', value: 'video', weight: 30, successRate: 85 },
|
||||
{ type: 'hook_style', value: 'curiosity_gap', weight: 25, successRate: 80 },
|
||||
{ type: 'posting_time', value: 'evening', weight: 15, successRate: 75 },
|
||||
{ type: 'cta', value: 'share_request', weight: 15, successRate: 70 },
|
||||
{ type: 'format', value: 'short_form', weight: 15, successRate: 78 },
|
||||
],
|
||||
performance: { testsRun: 50, successfulTests: 42, avgEngagement: 8.5, avgReach: 25000, goldPostsProduced: 5, confidenceScore: 84 },
|
||||
status: 'validated',
|
||||
},
|
||||
{
|
||||
name: 'Engagement Magnet',
|
||||
description: 'Maximizes comments and discussions',
|
||||
components: [
|
||||
{ type: 'hook_style', value: 'controversial_opinion', weight: 30, successRate: 88 },
|
||||
{ type: 'content_type', value: 'carousel', weight: 25, successRate: 82 },
|
||||
{ type: 'cta', value: 'question', weight: 25, successRate: 85 },
|
||||
{ type: 'topic', value: 'trending', weight: 20, successRate: 75 },
|
||||
],
|
||||
performance: { testsRun: 35, successfulTests: 28, avgEngagement: 12.3, avgReach: 15000, goldPostsProduced: 3, confidenceScore: 80 },
|
||||
status: 'validated',
|
||||
},
|
||||
{
|
||||
name: 'Authority Builder',
|
||||
description: 'Builds thought leadership and credibility',
|
||||
components: [
|
||||
{ type: 'content_type', value: 'long_form', weight: 30, successRate: 70 },
|
||||
{ type: 'topic', value: 'expertise', weight: 30, successRate: 75 },
|
||||
{ type: 'format', value: 'thread', weight: 20, successRate: 68 },
|
||||
{ type: 'hook_style', value: 'data_driven', weight: 20, successRate: 72 },
|
||||
],
|
||||
performance: { testsRun: 40, successfulTests: 30, avgEngagement: 5.5, avgReach: 20000, goldPostsProduced: 2, confidenceScore: 75 },
|
||||
status: 'validated',
|
||||
},
|
||||
{
|
||||
name: 'Rapid Growth',
|
||||
description: 'Optimized for follower acquisition',
|
||||
components: [
|
||||
{ type: 'hook_style', value: 'value_promise', weight: 25, successRate: 82 },
|
||||
{ type: 'content_type', value: 'reel', weight: 25, successRate: 85 },
|
||||
{ type: 'cta', value: 'follow_request', weight: 25, successRate: 65 },
|
||||
{ type: 'visual', value: 'trending_style', weight: 25, successRate: 78 },
|
||||
],
|
||||
performance: { testsRun: 30, successfulTests: 24, avgEngagement: 9.2, avgReach: 35000, goldPostsProduced: 4, confidenceScore: 80 },
|
||||
status: 'validated',
|
||||
},
|
||||
{
|
||||
name: 'Save-Worthy Content',
|
||||
description: 'Creates highly saveable educational content',
|
||||
components: [
|
||||
{ type: 'content_type', value: 'carousel', weight: 30, successRate: 88 },
|
||||
{ type: 'topic', value: 'how_to', weight: 30, successRate: 85 },
|
||||
{ type: 'format', value: 'step_by_step', weight: 25, successRate: 82 },
|
||||
{ type: 'visual', value: 'infographic', weight: 15, successRate: 80 },
|
||||
],
|
||||
performance: { testsRun: 45, successfulTests: 38, avgEngagement: 7.8, avgReach: 18000, goldPostsProduced: 6, confidenceScore: 84 },
|
||||
status: 'validated',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Create custom growth formula
|
||||
*/
|
||||
createFormula(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
description: string;
|
||||
components: FormulaComponent[];
|
||||
},
|
||||
): GrowthFormula {
|
||||
const formula: GrowthFormula = {
|
||||
id: `formula-${Date.now()}`,
|
||||
userId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
components: input.components,
|
||||
performance: {
|
||||
testsRun: 0,
|
||||
successfulTests: 0,
|
||||
avgEngagement: 0,
|
||||
avgReach: 0,
|
||||
goldPostsProduced: 0,
|
||||
confidenceScore: 0,
|
||||
},
|
||||
status: 'testing',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const userFormulas = this.formulas.get(userId) || [];
|
||||
userFormulas.push(formula);
|
||||
this.formulas.set(userId, userFormulas);
|
||||
|
||||
return formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from prebuilt template
|
||||
*/
|
||||
createFromTemplate(userId: string, templateName: string): GrowthFormula | null {
|
||||
const template = this.prebuiltFormulas.find(f => f.name === templateName);
|
||||
if (!template) return null;
|
||||
|
||||
return this.createFormula(userId, {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
components: [...template.components],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available templates
|
||||
*/
|
||||
getTemplates(): typeof this.prebuiltFormulas {
|
||||
return this.prebuiltFormulas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user formulas
|
||||
*/
|
||||
getFormulas(userId: string): GrowthFormula[] {
|
||||
return this.formulas.get(userId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formula by ID
|
||||
*/
|
||||
getFormula(userId: string, formulaId: string): GrowthFormula | null {
|
||||
const formulas = this.formulas.get(userId) || [];
|
||||
return formulas.find(f => f.id === formulaId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update formula
|
||||
*/
|
||||
updateFormula(
|
||||
userId: string,
|
||||
formulaId: string,
|
||||
updates: Partial<Pick<GrowthFormula, 'name' | 'description' | 'components' | 'status'>>,
|
||||
): GrowthFormula | null {
|
||||
const formula = this.getFormula(userId, formulaId);
|
||||
if (!formula) return null;
|
||||
|
||||
Object.assign(formula, updates, { updatedAt: new Date() });
|
||||
return formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record formula test result
|
||||
*/
|
||||
recordTestResult(
|
||||
userId: string,
|
||||
formulaId: string,
|
||||
result: {
|
||||
success: boolean;
|
||||
engagementRate: number;
|
||||
reach: number;
|
||||
isGoldPost?: boolean;
|
||||
},
|
||||
): GrowthFormula | null {
|
||||
const formula = this.getFormula(userId, formulaId);
|
||||
if (!formula) return null;
|
||||
|
||||
formula.performance.testsRun++;
|
||||
if (result.success) {
|
||||
formula.performance.successfulTests++;
|
||||
}
|
||||
if (result.isGoldPost) {
|
||||
formula.performance.goldPostsProduced++;
|
||||
}
|
||||
|
||||
// Update averages (rolling average)
|
||||
const n = formula.performance.testsRun;
|
||||
formula.performance.avgEngagement =
|
||||
((formula.performance.avgEngagement * (n - 1)) + result.engagementRate) / n;
|
||||
formula.performance.avgReach =
|
||||
((formula.performance.avgReach * (n - 1)) + result.reach) / n;
|
||||
|
||||
// Update confidence score
|
||||
formula.performance.confidenceScore =
|
||||
(formula.performance.successfulTests / formula.performance.testsRun) * 100;
|
||||
|
||||
// Auto-update status
|
||||
if (formula.performance.testsRun >= 10) {
|
||||
if (formula.performance.confidenceScore >= 80) {
|
||||
formula.status = 'validated';
|
||||
} else if (formula.performance.confidenceScore >= 60) {
|
||||
formula.status = 'optimizing';
|
||||
}
|
||||
}
|
||||
|
||||
formula.updatedAt = new Date();
|
||||
return formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create experiment
|
||||
*/
|
||||
createExperiment(
|
||||
userId: string,
|
||||
formulaId: string,
|
||||
input: {
|
||||
hypothesis: string;
|
||||
variables: Record<string, any>;
|
||||
},
|
||||
): GrowthExperiment {
|
||||
const experiment: GrowthExperiment = {
|
||||
id: `exp-${Date.now()}`,
|
||||
userId,
|
||||
formulaId,
|
||||
hypothesis: input.hypothesis,
|
||||
variables: input.variables,
|
||||
startDate: new Date(),
|
||||
status: 'running',
|
||||
};
|
||||
|
||||
const userExperiments = this.experiments.get(userId) || [];
|
||||
userExperiments.push(experiment);
|
||||
this.experiments.set(userId, userExperiments);
|
||||
|
||||
return experiment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete experiment
|
||||
*/
|
||||
completeExperiment(
|
||||
userId: string,
|
||||
experimentId: string,
|
||||
results: ExperimentResults,
|
||||
): GrowthExperiment | null {
|
||||
const experiments = this.experiments.get(userId) || [];
|
||||
const experiment = experiments.find(e => e.id === experimentId);
|
||||
if (!experiment) return null;
|
||||
|
||||
experiment.status = results.success ? 'completed' : 'failed';
|
||||
experiment.endDate = new Date();
|
||||
experiment.results = results;
|
||||
|
||||
// Record to formula
|
||||
if (experiment.formulaId) {
|
||||
this.recordTestResult(userId, experiment.formulaId, {
|
||||
success: results.success,
|
||||
engagementRate: results.metrics.engagementRate,
|
||||
reach: results.metrics.reach,
|
||||
});
|
||||
}
|
||||
|
||||
return experiment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get experiments
|
||||
*/
|
||||
getExperiments(userId: string, status?: GrowthExperiment['status']): GrowthExperiment[] {
|
||||
let experiments = this.experiments.get(userId) || [];
|
||||
if (status) {
|
||||
experiments = experiments.filter(e => e.status === status);
|
||||
}
|
||||
return experiments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get growth report
|
||||
*/
|
||||
getGrowthReport(userId: string, period: 'week' | 'month' | 'quarter'): GrowthReport {
|
||||
const formulas = this.getFormulas(userId);
|
||||
const experiments = this.getExperiments(userId);
|
||||
|
||||
const topFormulas = formulas
|
||||
.filter(f => f.status === 'validated')
|
||||
.sort((a, b) => b.performance.confidenceScore - a.performance.confidenceScore)
|
||||
.slice(0, 3);
|
||||
|
||||
const activeExperiments = experiments.filter(e => e.status === 'running').length;
|
||||
|
||||
// Calculate overall growth (simulated based on formula performance)
|
||||
const overallGrowth = topFormulas.length > 0
|
||||
? topFormulas.reduce((sum, f) => sum + f.performance.avgEngagement, 0) / topFormulas.length * 10
|
||||
: 5;
|
||||
|
||||
return {
|
||||
period,
|
||||
overallGrowth,
|
||||
topFormulas,
|
||||
activeExperiments,
|
||||
recommendations: this.generateRecommendations(formulas, experiments),
|
||||
projectedGrowth: overallGrowth * 1.15, // 15% projected improvement
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formula suggestions based on goals
|
||||
*/
|
||||
getFormulaSuggestions(goal: 'engagement' | 'reach' | 'followers' | 'authority'): string[] {
|
||||
const suggestions: Record<string, string[]> = {
|
||||
engagement: ['Engagement Magnet', 'Save-Worthy Content', 'Viral Video Formula'],
|
||||
reach: ['Viral Video Formula', 'Rapid Growth', 'Engagement Magnet'],
|
||||
followers: ['Rapid Growth', 'Authority Builder', 'Viral Video Formula'],
|
||||
authority: ['Authority Builder', 'Save-Worthy Content', 'Engagement Magnet'],
|
||||
};
|
||||
|
||||
return suggestions[goal] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete formula
|
||||
*/
|
||||
deleteFormula(userId: string, formulaId: string): boolean {
|
||||
const formulas = this.formulas.get(userId) || [];
|
||||
const filtered = formulas.filter(f => f.id !== formulaId);
|
||||
|
||||
if (filtered.length === formulas.length) return false;
|
||||
|
||||
this.formulas.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private generateRecommendations(
|
||||
formulas: GrowthFormula[],
|
||||
experiments: GrowthExperiment[],
|
||||
): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (formulas.length === 0) {
|
||||
recommendations.push('Start with a prebuilt formula to establish a baseline');
|
||||
}
|
||||
|
||||
const testingFormulas = formulas.filter(f => f.status === 'testing');
|
||||
if (testingFormulas.length > 0) {
|
||||
recommendations.push(`Run more tests on ${testingFormulas.length} formula(s) to validate them`);
|
||||
}
|
||||
|
||||
const lowPerformers = formulas.filter(f =>
|
||||
f.performance.testsRun >= 10 && f.performance.confidenceScore < 50
|
||||
);
|
||||
if (lowPerformers.length > 0) {
|
||||
recommendations.push('Consider retiring low-performing formulas and trying new approaches');
|
||||
}
|
||||
|
||||
const runningExperiments = experiments.filter(e => e.status === 'running');
|
||||
if (runningExperiments.length === 0) {
|
||||
recommendations.push('Start new experiments to continuously improve your growth strategy');
|
||||
}
|
||||
|
||||
const validatedFormulas = formulas.filter(f => f.status === 'validated');
|
||||
if (validatedFormulas.length > 0) {
|
||||
recommendations.push('Scale your validated formulas by increasing post frequency');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
}
|
||||
403
src/modules/analytics/services/performance-dashboard.service.ts
Normal file
403
src/modules/analytics/services/performance-dashboard.service.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
// Performance Dashboard Service - Comprehensive analytics dashboard
|
||||
// Path: src/modules/analytics/services/performance-dashboard.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface DashboardOverview {
|
||||
period: string;
|
||||
totalPosts: number;
|
||||
totalEngagements: number;
|
||||
avgEngagementRate: number;
|
||||
totalReach: number;
|
||||
totalImpressions: number;
|
||||
followerGrowth: number;
|
||||
topPlatform: string;
|
||||
goldPostsCount: number;
|
||||
abTestsActive: number;
|
||||
}
|
||||
|
||||
export interface PlatformBreakdown {
|
||||
platform: string;
|
||||
posts: number;
|
||||
engagements: number;
|
||||
avgEngagementRate: number;
|
||||
reach: number;
|
||||
impressions: number;
|
||||
bestContentType: string;
|
||||
growthTrend: 'up' | 'down' | 'stable';
|
||||
comparedToLastPeriod: number;
|
||||
}
|
||||
|
||||
export interface ContentTypeAnalysis {
|
||||
type: string;
|
||||
count: number;
|
||||
avgEngagement: number;
|
||||
avgReach: number;
|
||||
performanceScore: number;
|
||||
trend: 'improving' | 'declining' | 'stable';
|
||||
}
|
||||
|
||||
export interface TimeAnalysis {
|
||||
dayOfWeek: string;
|
||||
hourSlot: string;
|
||||
avgEngagement: number;
|
||||
postCount: number;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
export interface DashboardWidget {
|
||||
id: string;
|
||||
type: 'chart' | 'metric' | 'table' | 'heatmap' | 'comparison';
|
||||
title: string;
|
||||
data: any;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DashboardLayout {
|
||||
userId: string;
|
||||
widgets: DashboardWidget[];
|
||||
customization: {
|
||||
theme: 'light' | 'dark';
|
||||
refreshInterval: number;
|
||||
dateRange: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PerformanceDashboardService {
|
||||
private readonly logger = new Logger(PerformanceDashboardService.name);
|
||||
private dashboardData: Map<string, any> = new Map();
|
||||
private userLayouts: Map<string, DashboardLayout> = new Map();
|
||||
|
||||
/**
|
||||
* Get dashboard overview
|
||||
*/
|
||||
getDashboardOverview(userId: string, period: 'day' | 'week' | 'month' | 'quarter' | 'year'): DashboardOverview {
|
||||
// Generate realistic dashboard data
|
||||
const multipliers = { day: 1, week: 7, month: 30, quarter: 90, year: 365 };
|
||||
const m = multipliers[period];
|
||||
|
||||
return {
|
||||
period,
|
||||
totalPosts: Math.floor(3 * m + Math.random() * 2 * m),
|
||||
totalEngagements: Math.floor(1500 * m + Math.random() * 1000 * m),
|
||||
avgEngagementRate: 2.5 + Math.random() * 3,
|
||||
totalReach: Math.floor(15000 * m + Math.random() * 10000 * m),
|
||||
totalImpressions: Math.floor(25000 * m + Math.random() * 15000 * m),
|
||||
followerGrowth: Math.floor(50 * m + Math.random() * 30 * m),
|
||||
topPlatform: ['instagram', 'twitter', 'linkedin', 'tiktok'][Math.floor(Math.random() * 4)],
|
||||
goldPostsCount: Math.floor(m / 10) + 1,
|
||||
abTestsActive: Math.floor(Math.random() * 5) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform breakdown
|
||||
*/
|
||||
getPlatformBreakdown(userId: string, period: string): PlatformBreakdown[] {
|
||||
const platforms = ['twitter', 'instagram', 'linkedin', 'facebook', 'tiktok', 'youtube'];
|
||||
const contentTypes = ['video', 'carousel', 'single_image', 'text', 'story', 'reel'];
|
||||
|
||||
return platforms.map(platform => ({
|
||||
platform,
|
||||
posts: Math.floor(10 + Math.random() * 40),
|
||||
engagements: Math.floor(500 + Math.random() * 2000),
|
||||
avgEngagementRate: 1.5 + Math.random() * 4,
|
||||
reach: Math.floor(5000 + Math.random() * 20000),
|
||||
impressions: Math.floor(10000 + Math.random() * 40000),
|
||||
bestContentType: contentTypes[Math.floor(Math.random() * contentTypes.length)],
|
||||
growthTrend: ['up', 'down', 'stable'][Math.floor(Math.random() * 3)] as 'up' | 'down' | 'stable',
|
||||
comparedToLastPeriod: -15 + Math.random() * 40,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type analysis
|
||||
*/
|
||||
getContentTypeAnalysis(userId: string): ContentTypeAnalysis[] {
|
||||
const types = [
|
||||
{ type: 'video', base: 4.5 },
|
||||
{ type: 'carousel', base: 3.8 },
|
||||
{ type: 'single_image', base: 2.5 },
|
||||
{ type: 'text', base: 1.8 },
|
||||
{ type: 'story', base: 3.2 },
|
||||
{ type: 'reel', base: 5.5 },
|
||||
{ type: 'live', base: 6.0 },
|
||||
{ type: 'poll', base: 4.0 },
|
||||
];
|
||||
|
||||
return types.map(t => ({
|
||||
type: t.type,
|
||||
count: Math.floor(5 + Math.random() * 30),
|
||||
avgEngagement: t.base + Math.random() * 2,
|
||||
avgReach: Math.floor(1000 + Math.random() * 5000),
|
||||
performanceScore: Math.floor(50 + Math.random() * 50),
|
||||
trend: ['improving', 'declining', 'stable'][Math.floor(Math.random() * 3)] as 'improving' | 'declining' | 'stable',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time-based analysis (best times to post)
|
||||
*/
|
||||
getTimeAnalysis(userId: string, platform?: string): TimeAnalysis[] {
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const hours = ['6-9 AM', '9-12 PM', '12-3 PM', '3-6 PM', '6-9 PM', '9-12 AM'];
|
||||
|
||||
const analysis: TimeAnalysis[] = [];
|
||||
|
||||
for (const day of days) {
|
||||
for (const hour of hours) {
|
||||
const engagement = 1 + Math.random() * 6;
|
||||
const postCount = Math.floor(Math.random() * 10);
|
||||
|
||||
let recommendation = 'Good time to post';
|
||||
if (engagement > 4) recommendation = 'Excellent time - high engagement expected';
|
||||
else if (engagement < 2) recommendation = 'Avoid posting - low engagement expected';
|
||||
|
||||
analysis.push({
|
||||
dayOfWeek: day,
|
||||
hourSlot: hour,
|
||||
avgEngagement: engagement,
|
||||
postCount,
|
||||
recommendation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement heatmap data
|
||||
*/
|
||||
getEngagementHeatmap(userId: string): number[][] {
|
||||
// 7 days x 24 hours matrix of engagement scores (0-100)
|
||||
return Array.from({ length: 7 }, () =>
|
||||
Array.from({ length: 24 }, () => Math.floor(Math.random() * 100))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comparison data (vs previous period)
|
||||
*/
|
||||
getComparisonData(userId: string, currentPeriod: string): {
|
||||
current: DashboardOverview;
|
||||
previous: DashboardOverview;
|
||||
changes: Record<string, { value: number; percentage: number; trend: 'up' | 'down' | 'stable' }>;
|
||||
} {
|
||||
const current = this.getDashboardOverview(userId, currentPeriod as any);
|
||||
const previous = this.getDashboardOverview(userId, currentPeriod as any);
|
||||
|
||||
const calculate = (curr: number, prev: number) => {
|
||||
const change = curr - prev;
|
||||
const percentage = prev > 0 ? (change / prev) * 100 : 0;
|
||||
const trend: 'up' | 'down' | 'stable' =
|
||||
percentage > 5 ? 'up' : percentage < -5 ? 'down' : 'stable';
|
||||
return { value: change, percentage, trend };
|
||||
};
|
||||
|
||||
return {
|
||||
current,
|
||||
previous,
|
||||
changes: {
|
||||
posts: calculate(current.totalPosts, previous.totalPosts),
|
||||
engagements: calculate(current.totalEngagements, previous.totalEngagements),
|
||||
engagementRate: calculate(current.avgEngagementRate, previous.avgEngagementRate),
|
||||
reach: calculate(current.totalReach, previous.totalReach),
|
||||
followers: calculate(current.followerGrowth, previous.followerGrowth),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard widgets
|
||||
*/
|
||||
getDefaultWidgets(): DashboardWidget[] {
|
||||
return [
|
||||
{
|
||||
id: 'overview',
|
||||
type: 'metric',
|
||||
title: 'Performance Overview',
|
||||
data: null,
|
||||
config: { size: 'large', position: { row: 0, col: 0 } },
|
||||
},
|
||||
{
|
||||
id: 'engagement-trend',
|
||||
type: 'chart',
|
||||
title: 'Engagement Trend',
|
||||
data: null,
|
||||
config: { chartType: 'line', size: 'medium', position: { row: 0, col: 1 } },
|
||||
},
|
||||
{
|
||||
id: 'platform-breakdown',
|
||||
type: 'chart',
|
||||
title: 'Platform Performance',
|
||||
data: null,
|
||||
config: { chartType: 'bar', size: 'medium', position: { row: 1, col: 0 } },
|
||||
},
|
||||
{
|
||||
id: 'content-types',
|
||||
type: 'chart',
|
||||
title: 'Content Type Analysis',
|
||||
data: null,
|
||||
config: { chartType: 'pie', size: 'small', position: { row: 1, col: 1 } },
|
||||
},
|
||||
{
|
||||
id: 'best-times',
|
||||
type: 'heatmap',
|
||||
title: 'Best Times to Post',
|
||||
data: null,
|
||||
config: { size: 'large', position: { row: 2, col: 0 } },
|
||||
},
|
||||
{
|
||||
id: 'top-posts',
|
||||
type: 'table',
|
||||
title: 'Top Performing Posts',
|
||||
data: null,
|
||||
config: { columns: ['title', 'platform', 'engagement', 'reach'], position: { row: 2, col: 1 } },
|
||||
},
|
||||
{
|
||||
id: 'growth-metrics',
|
||||
type: 'comparison',
|
||||
title: 'Growth vs Last Period',
|
||||
data: null,
|
||||
config: { size: 'medium', position: { row: 3, col: 0 } },
|
||||
},
|
||||
{
|
||||
id: 'gold-posts',
|
||||
type: 'table',
|
||||
title: 'Gold Posts',
|
||||
data: null,
|
||||
config: { highlight: true, position: { row: 3, col: 1 } },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/create user dashboard layout
|
||||
*/
|
||||
getDashboardLayout(userId: string): DashboardLayout {
|
||||
let layout = this.userLayouts.get(userId);
|
||||
|
||||
if (!layout) {
|
||||
layout = {
|
||||
userId,
|
||||
widgets: this.getDefaultWidgets(),
|
||||
customization: {
|
||||
theme: 'dark',
|
||||
refreshInterval: 60000,
|
||||
dateRange: 'week',
|
||||
},
|
||||
};
|
||||
this.userLayouts.set(userId, layout);
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update dashboard layout
|
||||
*/
|
||||
updateDashboardLayout(userId: string, updates: Partial<DashboardLayout>): DashboardLayout {
|
||||
const current = this.getDashboardLayout(userId);
|
||||
const updated = { ...current, ...updates };
|
||||
this.userLayouts.set(userId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom widget
|
||||
*/
|
||||
addWidget(userId: string, widget: Omit<DashboardWidget, 'id'>): DashboardWidget {
|
||||
const layout = this.getDashboardLayout(userId);
|
||||
const newWidget: DashboardWidget = {
|
||||
...widget,
|
||||
id: `widget-${Date.now()}`,
|
||||
};
|
||||
layout.widgets.push(newWidget);
|
||||
this.userLayouts.set(userId, layout);
|
||||
return newWidget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove widget
|
||||
*/
|
||||
removeWidget(userId: string, widgetId: string): boolean {
|
||||
const layout = this.getDashboardLayout(userId);
|
||||
const index = layout.widgets.findIndex(w => w.id === widgetId);
|
||||
if (index === -1) return false;
|
||||
|
||||
layout.widgets.splice(index, 1);
|
||||
this.userLayouts.set(userId, layout);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export dashboard data
|
||||
*/
|
||||
exportDashboardData(userId: string, format: 'json' | 'csv'): string {
|
||||
const overview = this.getDashboardOverview(userId, 'month');
|
||||
const platforms = this.getPlatformBreakdown(userId, 'month');
|
||||
const contentTypes = this.getContentTypeAnalysis(userId);
|
||||
|
||||
if (format === 'json') {
|
||||
return JSON.stringify({ overview, platforms, contentTypes }, null, 2);
|
||||
}
|
||||
|
||||
// CSV format
|
||||
let csv = 'Metric,Value\n';
|
||||
csv += `Total Posts,${overview.totalPosts}\n`;
|
||||
csv += `Total Engagements,${overview.totalEngagements}\n`;
|
||||
csv += `Avg Engagement Rate,${overview.avgEngagementRate.toFixed(2)}%\n`;
|
||||
csv += `Total Reach,${overview.totalReach}\n`;
|
||||
csv += `Follower Growth,${overview.followerGrowth}\n`;
|
||||
csv += '\nPlatform,Posts,Engagements,Avg Rate\n';
|
||||
for (const p of platforms) {
|
||||
csv += `${p.platform},${p.posts},${p.engagements},${p.avgEngagementRate.toFixed(2)}%\n`;
|
||||
}
|
||||
return csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insights and recommendations
|
||||
*/
|
||||
getInsights(userId: string): Array<{
|
||||
type: 'success' | 'warning' | 'info' | 'action';
|
||||
title: string;
|
||||
description: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
type: 'success',
|
||||
title: 'Strong Video Performance',
|
||||
description: 'Your video content is outperforming other formats by 45%. Consider increasing video production.',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
type: 'warning',
|
||||
title: 'Engagement Drop on Weekends',
|
||||
description: 'Weekend posts show 30% lower engagement. Consider rescheduling to weekday evenings.',
|
||||
priority: 'medium',
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
title: 'Untapped LinkedIn Potential',
|
||||
description: 'LinkedIn shows high engagement but low posting frequency. Increase LinkedIn content.',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
type: 'info',
|
||||
title: 'Optimal Posting Time Detected',
|
||||
description: 'Best engagement window: Tuesday-Thursday, 6-9 PM in your timezone.',
|
||||
priority: 'medium',
|
||||
},
|
||||
{
|
||||
type: 'success',
|
||||
title: 'Gold Post Streak',
|
||||
description: 'You\'ve had 3 Gold Posts this week! Your viral content formula is working.',
|
||||
priority: 'low',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
482
src/modules/analytics/services/webhook.service.ts
Normal file
482
src/modules/analytics/services/webhook.service.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
// Webhook Service - External API integrations
|
||||
// Path: src/modules/analytics/services/webhook.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface Webhook {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
events: WebhookEvent[];
|
||||
headers?: Record<string, string>;
|
||||
isActive: boolean;
|
||||
retryPolicy: RetryPolicy;
|
||||
lastTriggered?: Date;
|
||||
stats: WebhookStats;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type WebhookEvent =
|
||||
| 'post.published'
|
||||
| 'post.scheduled'
|
||||
| 'engagement.threshold'
|
||||
| 'gold_post.detected'
|
||||
| 'ab_test.completed'
|
||||
| 'automation.triggered'
|
||||
| 'queue.empty'
|
||||
| 'report.generated'
|
||||
| 'error.occurred';
|
||||
|
||||
export interface RetryPolicy {
|
||||
maxRetries: number;
|
||||
backoffMs: number;
|
||||
backoffMultiplier: number;
|
||||
}
|
||||
|
||||
export interface WebhookStats {
|
||||
totalCalls: number;
|
||||
successfulCalls: number;
|
||||
failedCalls: number;
|
||||
lastSuccess?: Date;
|
||||
lastFailure?: Date;
|
||||
avgResponseTime: number;
|
||||
}
|
||||
|
||||
export interface WebhookDelivery {
|
||||
id: string;
|
||||
webhookId: string;
|
||||
event: WebhookEvent;
|
||||
payload: Record<string, any>;
|
||||
status: 'pending' | 'success' | 'failed' | 'retrying';
|
||||
attempts: number;
|
||||
response?: {
|
||||
statusCode: number;
|
||||
body?: string;
|
||||
time: number;
|
||||
};
|
||||
error?: string;
|
||||
createdAt: Date;
|
||||
completedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ExternalApiConfig {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
type: 'zapier' | 'make' | 'n8n' | 'custom' | 'slack' | 'discord' | 'notion';
|
||||
config: Record<string, any>;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WebhookService {
|
||||
private readonly logger = new Logger(WebhookService.name);
|
||||
private webhooks: Map<string, Webhook[]> = new Map();
|
||||
private deliveries: Map<string, WebhookDelivery[]> = new Map();
|
||||
private externalApis: Map<string, ExternalApiConfig[]> = new Map();
|
||||
|
||||
// Event descriptions
|
||||
private readonly eventDescriptions: Record<WebhookEvent, string> = {
|
||||
'post.published': 'Triggered when a post is successfully published',
|
||||
'post.scheduled': 'Triggered when a post is added to the schedule',
|
||||
'engagement.threshold': 'Triggered when engagement exceeds a threshold',
|
||||
'gold_post.detected': 'Triggered when a Gold Post is detected',
|
||||
'ab_test.completed': 'Triggered when an A/B test completes',
|
||||
'automation.triggered': 'Triggered when an automation rule executes',
|
||||
'queue.empty': 'Triggered when the content queue is empty',
|
||||
'report.generated': 'Triggered when an analytics report is ready',
|
||||
'error.occurred': 'Triggered when an error occurs',
|
||||
};
|
||||
|
||||
/**
|
||||
* Create webhook
|
||||
*/
|
||||
createWebhook(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
events: WebhookEvent[];
|
||||
headers?: Record<string, string>;
|
||||
retryPolicy?: Partial<RetryPolicy>;
|
||||
},
|
||||
): Webhook {
|
||||
const webhook: Webhook = {
|
||||
id: `webhook-${Date.now()}`,
|
||||
userId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
secret: input.secret,
|
||||
events: input.events,
|
||||
headers: input.headers,
|
||||
isActive: true,
|
||||
retryPolicy: {
|
||||
maxRetries: input.retryPolicy?.maxRetries ?? 3,
|
||||
backoffMs: input.retryPolicy?.backoffMs ?? 1000,
|
||||
backoffMultiplier: input.retryPolicy?.backoffMultiplier ?? 2,
|
||||
},
|
||||
stats: {
|
||||
totalCalls: 0,
|
||||
successfulCalls: 0,
|
||||
failedCalls: 0,
|
||||
avgResponseTime: 0,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const userWebhooks = this.webhooks.get(userId) || [];
|
||||
userWebhooks.push(webhook);
|
||||
this.webhooks.set(userId, userWebhooks);
|
||||
|
||||
this.logger.log(`Created webhook: ${webhook.id} for user ${userId}`);
|
||||
return webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhooks
|
||||
*/
|
||||
getWebhooks(userId: string): Webhook[] {
|
||||
return this.webhooks.get(userId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook by ID
|
||||
*/
|
||||
getWebhook(userId: string, webhookId: string): Webhook | null {
|
||||
const webhooks = this.webhooks.get(userId) || [];
|
||||
return webhooks.find(w => w.id === webhookId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update webhook
|
||||
*/
|
||||
updateWebhook(
|
||||
userId: string,
|
||||
webhookId: string,
|
||||
updates: Partial<Pick<Webhook, 'name' | 'url' | 'secret' | 'events' | 'headers' | 'isActive' | 'retryPolicy'>>,
|
||||
): Webhook | null {
|
||||
const webhook = this.getWebhook(userId, webhookId);
|
||||
if (!webhook) return null;
|
||||
|
||||
Object.assign(webhook, updates, { updatedAt: new Date() });
|
||||
return webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle webhook
|
||||
*/
|
||||
toggleWebhook(userId: string, webhookId: string): Webhook | null {
|
||||
const webhook = this.getWebhook(userId, webhookId);
|
||||
if (!webhook) return null;
|
||||
|
||||
webhook.isActive = !webhook.isActive;
|
||||
webhook.updatedAt = new Date();
|
||||
return webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete webhook
|
||||
*/
|
||||
deleteWebhook(userId: string, webhookId: string): boolean {
|
||||
const webhooks = this.webhooks.get(userId) || [];
|
||||
const filtered = webhooks.filter(w => w.id !== webhookId);
|
||||
|
||||
if (filtered.length === webhooks.length) return false;
|
||||
|
||||
this.webhooks.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger webhook event
|
||||
*/
|
||||
async triggerEvent(
|
||||
userId: string,
|
||||
event: WebhookEvent,
|
||||
payload: Record<string, any>,
|
||||
): Promise<WebhookDelivery[]> {
|
||||
const webhooks = this.getWebhooks(userId).filter(
|
||||
w => w.isActive && w.events.includes(event)
|
||||
);
|
||||
|
||||
const deliveries: WebhookDelivery[] = [];
|
||||
|
||||
for (const webhook of webhooks) {
|
||||
const delivery = await this.deliverWebhook(webhook, event, payload);
|
||||
deliveries.push(delivery);
|
||||
}
|
||||
|
||||
return deliveries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook deliveries
|
||||
*/
|
||||
getDeliveries(userId: string, webhookId?: string, limit?: number): WebhookDelivery[] {
|
||||
let deliveries = this.deliveries.get(userId) || [];
|
||||
|
||||
if (webhookId) {
|
||||
deliveries = deliveries.filter(d => d.webhookId === webhookId);
|
||||
}
|
||||
|
||||
deliveries = deliveries.sort((a, b) =>
|
||||
b.createdAt.getTime() - a.createdAt.getTime()
|
||||
);
|
||||
|
||||
if (limit) {
|
||||
deliveries = deliveries.slice(0, limit);
|
||||
}
|
||||
|
||||
return deliveries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed delivery
|
||||
*/
|
||||
async retryDelivery(userId: string, deliveryId: string): Promise<WebhookDelivery | null> {
|
||||
const deliveries = this.deliveries.get(userId) || [];
|
||||
const delivery = deliveries.find(d => d.id === deliveryId);
|
||||
|
||||
if (!delivery || delivery.status !== 'failed') return null;
|
||||
|
||||
const webhook = this.getWebhook(userId, delivery.webhookId);
|
||||
if (!webhook) return null;
|
||||
|
||||
delivery.status = 'retrying';
|
||||
delivery.attempts++;
|
||||
|
||||
// Simulate retry
|
||||
return this.executeDelivery(webhook, delivery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available events
|
||||
*/
|
||||
getAvailableEvents(): Array<{ event: WebhookEvent; description: string }> {
|
||||
return Object.entries(this.eventDescriptions).map(([event, description]) => ({
|
||||
event: event as WebhookEvent,
|
||||
description,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test webhook
|
||||
*/
|
||||
async testWebhook(userId: string, webhookId: string): Promise<WebhookDelivery> {
|
||||
const webhook = this.getWebhook(userId, webhookId);
|
||||
if (!webhook) {
|
||||
throw new Error('Webhook not found');
|
||||
}
|
||||
|
||||
const testPayload = {
|
||||
test: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'This is a test webhook delivery',
|
||||
};
|
||||
|
||||
return this.deliverWebhook(webhook, 'post.published', testPayload);
|
||||
}
|
||||
|
||||
// ========== External API Integrations ==========
|
||||
|
||||
/**
|
||||
* Configure external API
|
||||
*/
|
||||
configureExternalApi(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
type: ExternalApiConfig['type'];
|
||||
config: Record<string, any>;
|
||||
},
|
||||
): ExternalApiConfig {
|
||||
const apiConfig: ExternalApiConfig = {
|
||||
id: `api-${Date.now()}`,
|
||||
userId,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: input.config,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const userApis = this.externalApis.get(userId) || [];
|
||||
userApis.push(apiConfig);
|
||||
this.externalApis.set(userId, userApis);
|
||||
|
||||
return apiConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get external APIs
|
||||
*/
|
||||
getExternalApis(userId: string): ExternalApiConfig[] {
|
||||
return this.externalApis.get(userId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get integrations templates
|
||||
*/
|
||||
getIntegrationTemplates(): Array<{
|
||||
type: ExternalApiConfig['type'];
|
||||
name: string;
|
||||
description: string;
|
||||
requiredFields: string[];
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
type: 'zapier',
|
||||
name: 'Zapier',
|
||||
description: 'Connect to 5000+ apps via Zapier',
|
||||
requiredFields: ['webhookUrl'],
|
||||
},
|
||||
{
|
||||
type: 'make',
|
||||
name: 'Make (Integromat)',
|
||||
description: 'Advanced workflow automation',
|
||||
requiredFields: ['webhookUrl', 'scenarioId'],
|
||||
},
|
||||
{
|
||||
type: 'n8n',
|
||||
name: 'n8n',
|
||||
description: 'Self-hosted workflow automation',
|
||||
requiredFields: ['webhookUrl', 'instanceUrl'],
|
||||
},
|
||||
{
|
||||
type: 'slack',
|
||||
name: 'Slack',
|
||||
description: 'Send notifications to Slack',
|
||||
requiredFields: ['webhookUrl', 'channel'],
|
||||
},
|
||||
{
|
||||
type: 'discord',
|
||||
name: 'Discord',
|
||||
description: 'Send notifications to Discord',
|
||||
requiredFields: ['webhookUrl'],
|
||||
},
|
||||
{
|
||||
type: 'notion',
|
||||
name: 'Notion',
|
||||
description: 'Sync data with Notion databases',
|
||||
requiredFields: ['apiKey', 'databaseId'],
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'Custom API',
|
||||
description: 'Connect to any REST API',
|
||||
requiredFields: ['baseUrl', 'authType'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update external API config
|
||||
*/
|
||||
updateExternalApi(
|
||||
userId: string,
|
||||
apiId: string,
|
||||
updates: Partial<Pick<ExternalApiConfig, 'name' | 'config' | 'isActive'>>,
|
||||
): ExternalApiConfig | null {
|
||||
const apis = this.externalApis.get(userId) || [];
|
||||
const api = apis.find(a => a.id === apiId);
|
||||
|
||||
if (!api) return null;
|
||||
|
||||
Object.assign(api, updates);
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete external API
|
||||
*/
|
||||
deleteExternalApi(userId: string, apiId: string): boolean {
|
||||
const apis = this.externalApis.get(userId) || [];
|
||||
const filtered = apis.filter(a => a.id !== apiId);
|
||||
|
||||
if (filtered.length === apis.length) return false;
|
||||
|
||||
this.externalApis.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private async deliverWebhook(
|
||||
webhook: Webhook,
|
||||
event: WebhookEvent,
|
||||
payload: Record<string, any>,
|
||||
): Promise<WebhookDelivery> {
|
||||
const delivery: WebhookDelivery = {
|
||||
id: `delivery-${Date.now()}`,
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
payload,
|
||||
status: 'pending',
|
||||
attempts: 1,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Store delivery
|
||||
const userId = webhook.userId;
|
||||
const deliveries = this.deliveries.get(userId) || [];
|
||||
deliveries.push(delivery);
|
||||
this.deliveries.set(userId, deliveries);
|
||||
|
||||
return this.executeDelivery(webhook, delivery);
|
||||
}
|
||||
|
||||
private async executeDelivery(webhook: Webhook, delivery: WebhookDelivery): Promise<WebhookDelivery> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Simulate HTTP call (in production, use actual HTTP client)
|
||||
await this.simulateHttpCall(webhook.url);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
delivery.status = 'success';
|
||||
delivery.response = {
|
||||
statusCode: 200,
|
||||
body: '{"success": true}',
|
||||
time: responseTime,
|
||||
};
|
||||
delivery.completedAt = new Date();
|
||||
|
||||
// Update webhook stats
|
||||
webhook.stats.totalCalls++;
|
||||
webhook.stats.successfulCalls++;
|
||||
webhook.stats.lastSuccess = new Date();
|
||||
webhook.stats.avgResponseTime =
|
||||
((webhook.stats.avgResponseTime * (webhook.stats.totalCalls - 1)) + responseTime) / webhook.stats.totalCalls;
|
||||
webhook.lastTriggered = new Date();
|
||||
|
||||
this.logger.log(`Webhook delivered: ${webhook.id} - ${delivery.event}`);
|
||||
} catch (error) {
|
||||
delivery.status = 'failed';
|
||||
delivery.error = error.message;
|
||||
delivery.completedAt = new Date();
|
||||
|
||||
webhook.stats.totalCalls++;
|
||||
webhook.stats.failedCalls++;
|
||||
webhook.stats.lastFailure = new Date();
|
||||
|
||||
this.logger.error(`Webhook failed: ${webhook.id} - ${error.message}`);
|
||||
}
|
||||
|
||||
return delivery;
|
||||
}
|
||||
|
||||
private async simulateHttpCall(url: string): Promise<void> {
|
||||
// Simulate network latency
|
||||
await new Promise(resolve => setTimeout(resolve, 50 + Math.random() * 100));
|
||||
|
||||
// Simulate occasional failures (5% chance)
|
||||
if (Math.random() < 0.05) {
|
||||
throw new Error('Connection timeout');
|
||||
}
|
||||
}
|
||||
}
|
||||
84
src/modules/approvals/approvals.controller.ts
Normal file
84
src/modules/approvals/approvals.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Approvals Controller - API endpoints for content approval workflow
|
||||
// Path: src/modules/approvals/approvals.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
Delete,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ApprovalsService } from './approvals.service';
|
||||
import { CurrentUser } from '../../common/decorators';
|
||||
|
||||
@ApiTags('approvals')
|
||||
@ApiBearerAuth()
|
||||
@Controller('approvals')
|
||||
export class ApprovalsController {
|
||||
constructor(private readonly approvalsService: ApprovalsService) { }
|
||||
|
||||
@Post('submit/:contentId')
|
||||
@ApiOperation({ summary: 'Submit content for approval' })
|
||||
async submitForApproval(
|
||||
@Param('contentId', ParseUUIDPipe) contentId: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() body?: { notes?: string },
|
||||
) {
|
||||
return this.approvalsService.submitForApproval(contentId, userId, body);
|
||||
}
|
||||
|
||||
@Get('pending/:workspaceId')
|
||||
@ApiOperation({ summary: 'Get pending approvals for a workspace' })
|
||||
async getPendingApprovals(
|
||||
@Param('workspaceId', ParseUUIDPipe) workspaceId: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.approvalsService.getPendingApprovals(workspaceId, userId);
|
||||
}
|
||||
|
||||
@Get('my-requests')
|
||||
@ApiOperation({ summary: 'Get my approval requests' })
|
||||
async getMyRequests(@CurrentUser('id') userId: string) {
|
||||
return this.approvalsService.getMyApprovalRequests(userId);
|
||||
}
|
||||
|
||||
@Get('history/:contentId')
|
||||
@ApiOperation({ summary: 'Get approval history for content' })
|
||||
async getApprovalHistory(
|
||||
@Param('contentId', ParseUUIDPipe) contentId: string,
|
||||
) {
|
||||
return this.approvalsService.getApprovalHistory(contentId);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@ApiOperation({ summary: 'Approve content' })
|
||||
async approve(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() body?: { feedback?: string },
|
||||
) {
|
||||
return this.approvalsService.approveContent(id, userId, body);
|
||||
}
|
||||
|
||||
@Post(':id/reject')
|
||||
@ApiOperation({ summary: 'Reject content' })
|
||||
async reject(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() body: { feedback: string },
|
||||
) {
|
||||
return this.approvalsService.rejectContent(id, userId, body);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Cancel approval request' })
|
||||
async cancel(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser('id') userId: string,
|
||||
) {
|
||||
return this.approvalsService.cancelApprovalRequest(id, userId);
|
||||
}
|
||||
}
|
||||
13
src/modules/approvals/approvals.module.ts
Normal file
13
src/modules/approvals/approvals.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Approvals Module - Content review and approval workflows
|
||||
// Path: src/modules/approvals/approvals.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ApprovalsService } from './approvals.service';
|
||||
import { ApprovalsController } from './approvals.controller';
|
||||
|
||||
@Module({
|
||||
providers: [ApprovalsService],
|
||||
controllers: [ApprovalsController],
|
||||
exports: [ApprovalsService],
|
||||
})
|
||||
export class ApprovalsModule { }
|
||||
288
src/modules/approvals/approvals.service.ts
Normal file
288
src/modules/approvals/approvals.service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
// Approvals Service - Content review and approval logic
|
||||
// Path: src/modules/approvals/approvals.service.ts
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { ApprovalStatus, ContentStatus, WorkspaceRole } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class ApprovalsService {
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
/**
|
||||
* Submit content for approval
|
||||
*/
|
||||
async submitForApproval(
|
||||
contentId: string,
|
||||
userId: string,
|
||||
data?: { notes?: string },
|
||||
) {
|
||||
const content = await this.prisma.content.findUnique({
|
||||
where: { id: contentId },
|
||||
include: { workspace: true },
|
||||
});
|
||||
|
||||
if (!content) {
|
||||
throw new NotFoundException('CONTENT_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (content.userId !== userId) {
|
||||
throw new ForbiddenException('NOT_CONTENT_OWNER');
|
||||
}
|
||||
|
||||
if (content.status !== 'DRAFT') {
|
||||
throw new BadRequestException('CONTENT_NOT_IN_DRAFT');
|
||||
}
|
||||
|
||||
// Update content status to REVIEW
|
||||
await this.prisma.content.update({
|
||||
where: { id: contentId },
|
||||
data: { status: ContentStatus.REVIEW },
|
||||
});
|
||||
|
||||
// Create approval request
|
||||
const approval = await this.prisma.contentApproval.create({
|
||||
data: {
|
||||
contentId,
|
||||
requestedById: userId,
|
||||
status: ApprovalStatus.PENDING,
|
||||
notes: data?.notes,
|
||||
},
|
||||
include: {
|
||||
content: { select: { title: true, type: true } },
|
||||
requestedBy: { select: { id: true, email: true, firstName: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return approval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending approvals for a workspace
|
||||
*/
|
||||
async getPendingApprovals(workspaceId: string, userId: string) {
|
||||
// Check if user has approval permission
|
||||
const member = await this.prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: { workspaceId, userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (!member || !['OWNER', 'ADMIN', 'EDITOR'].includes(member.role)) {
|
||||
throw new ForbiddenException('NO_APPROVAL_PERMISSION');
|
||||
}
|
||||
|
||||
return this.prisma.contentApproval.findMany({
|
||||
where: {
|
||||
content: { workspaceId },
|
||||
status: ApprovalStatus.PENDING,
|
||||
},
|
||||
include: {
|
||||
content: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
type: true,
|
||||
body: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
requestedBy: {
|
||||
select: { id: true, email: true, firstName: true, lastName: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve content
|
||||
*/
|
||||
async approveContent(
|
||||
approvalId: string,
|
||||
approverId: string,
|
||||
data?: { feedback?: string },
|
||||
) {
|
||||
const approval = await this.prisma.contentApproval.findUnique({
|
||||
where: { id: approvalId },
|
||||
include: { content: { include: { workspace: true } } },
|
||||
});
|
||||
|
||||
if (!approval) {
|
||||
throw new NotFoundException('APPROVAL_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (approval.status !== 'PENDING') {
|
||||
throw new BadRequestException('APPROVAL_ALREADY_PROCESSED');
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (approval.content.workspaceId) {
|
||||
await this.checkApprovalPermission(
|
||||
approval.content.workspaceId,
|
||||
approverId,
|
||||
);
|
||||
}
|
||||
|
||||
// Update approval
|
||||
const [updatedApproval] = await this.prisma.$transaction([
|
||||
this.prisma.contentApproval.update({
|
||||
where: { id: approvalId },
|
||||
data: {
|
||||
status: ApprovalStatus.APPROVED,
|
||||
reviewedById: approverId,
|
||||
reviewedAt: new Date(),
|
||||
feedback: data?.feedback,
|
||||
},
|
||||
}),
|
||||
// Update content status
|
||||
this.prisma.content.update({
|
||||
where: { id: approval.contentId },
|
||||
data: { status: ContentStatus.APPROVED },
|
||||
}),
|
||||
]);
|
||||
|
||||
return updatedApproval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject content
|
||||
*/
|
||||
async rejectContent(
|
||||
approvalId: string,
|
||||
approverId: string,
|
||||
data: { feedback: string },
|
||||
) {
|
||||
if (!data.feedback) {
|
||||
throw new BadRequestException('FEEDBACK_REQUIRED_FOR_REJECTION');
|
||||
}
|
||||
|
||||
const approval = await this.prisma.contentApproval.findUnique({
|
||||
where: { id: approvalId },
|
||||
include: { content: { include: { workspace: true } } },
|
||||
});
|
||||
|
||||
if (!approval) {
|
||||
throw new NotFoundException('APPROVAL_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (approval.status !== 'PENDING') {
|
||||
throw new BadRequestException('APPROVAL_ALREADY_PROCESSED');
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
if (approval.content.workspaceId) {
|
||||
await this.checkApprovalPermission(
|
||||
approval.content.workspaceId,
|
||||
approverId,
|
||||
);
|
||||
}
|
||||
|
||||
// Update approval
|
||||
const [updatedApproval] = await this.prisma.$transaction([
|
||||
this.prisma.contentApproval.update({
|
||||
where: { id: approvalId },
|
||||
data: {
|
||||
status: ApprovalStatus.REJECTED,
|
||||
reviewedById: approverId,
|
||||
reviewedAt: new Date(),
|
||||
feedback: data.feedback,
|
||||
},
|
||||
}),
|
||||
// Revert content to draft
|
||||
this.prisma.content.update({
|
||||
where: { id: approval.contentId },
|
||||
data: { status: ContentStatus.DRAFT },
|
||||
}),
|
||||
]);
|
||||
|
||||
return updatedApproval;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get approval history for content
|
||||
*/
|
||||
async getApprovalHistory(contentId: string) {
|
||||
return this.prisma.contentApproval.findMany({
|
||||
where: { contentId },
|
||||
include: {
|
||||
requestedBy: { select: { id: true, email: true, firstName: true } },
|
||||
reviewedBy: { select: { id: true, email: true, firstName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's pending approval requests
|
||||
*/
|
||||
async getMyApprovalRequests(userId: string) {
|
||||
return this.prisma.contentApproval.findMany({
|
||||
where: { requestedById: userId },
|
||||
include: {
|
||||
content: { select: { id: true, title: true, type: true } },
|
||||
reviewedBy: { select: { id: true, email: true, firstName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel pending approval request
|
||||
*/
|
||||
async cancelApprovalRequest(approvalId: string, userId: string) {
|
||||
const approval = await this.prisma.contentApproval.findUnique({
|
||||
where: { id: approvalId },
|
||||
});
|
||||
|
||||
if (!approval) {
|
||||
throw new NotFoundException('APPROVAL_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (approval.requestedById !== userId) {
|
||||
throw new ForbiddenException('NOT_APPROVAL_OWNER');
|
||||
}
|
||||
|
||||
if (approval.status !== 'PENDING') {
|
||||
throw new BadRequestException('APPROVAL_ALREADY_PROCESSED');
|
||||
}
|
||||
|
||||
// Delete approval and revert content to draft
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.contentApproval.delete({
|
||||
where: { id: approvalId },
|
||||
}),
|
||||
this.prisma.content.update({
|
||||
where: { id: approval.contentId },
|
||||
data: { status: ContentStatus.DRAFT },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { success: true, message: 'Approval request cancelled' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has permission to approve/reject
|
||||
*/
|
||||
private async checkApprovalPermission(
|
||||
workspaceId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
const member = await this.prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: { workspaceId, userId },
|
||||
},
|
||||
});
|
||||
|
||||
const allowedRoles: WorkspaceRole[] = ['OWNER', 'ADMIN'];
|
||||
|
||||
if (!member || !allowedRoles.includes(member.role)) {
|
||||
throw new ForbiddenException('NO_APPROVAL_PERMISSION');
|
||||
}
|
||||
}
|
||||
}
|
||||
4
src/modules/approvals/index.ts
Normal file
4
src/modules/approvals/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Approvals Module Index
|
||||
export * from './approvals.module';
|
||||
export * from './approvals.service';
|
||||
export * from './approvals.controller';
|
||||
191
src/modules/content-generation/content-generation.controller.ts
Normal file
191
src/modules/content-generation/content-generation.controller.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
// Content Generation Controller - API endpoints
|
||||
// Path: src/modules/content-generation/content-generation.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ContentGenerationService } from './content-generation.service';
|
||||
import type { ContentGenerationRequest } from './content-generation.service';
|
||||
import type { Platform } from './services/platform-generator.service';
|
||||
import type { VariationType } from './services/variation.service';
|
||||
import type { BrandVoice } from './services/brand-voice.service';
|
||||
import { Public } from '../../common/decorators';
|
||||
|
||||
@Controller('content-generation')
|
||||
export class ContentGenerationController {
|
||||
constructor(private readonly service: ContentGenerationService) { }
|
||||
|
||||
// ========== FULL GENERATION ==========
|
||||
|
||||
@Public()
|
||||
@Post('generate')
|
||||
generateContent(@Body() body: ContentGenerationRequest) {
|
||||
return this.service.generateContent(body);
|
||||
}
|
||||
|
||||
// ========== NICHES ==========
|
||||
|
||||
@Get('niches')
|
||||
getNiches() {
|
||||
return this.service.getNiches();
|
||||
}
|
||||
|
||||
@Get('niches/:id')
|
||||
analyzeNiche(@Param('id') id: string) {
|
||||
return this.service.analyzeNiche(id);
|
||||
}
|
||||
|
||||
@Post('niches/recommend')
|
||||
recommendNiches(@Body() body: { interests: string[] }) {
|
||||
return this.service.recommendNiches(body.interests);
|
||||
}
|
||||
|
||||
@Get('niches/:id/ideas')
|
||||
getContentIdeas(
|
||||
@Param('id') id: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.service.getContentIdeas(id, count ? parseInt(count, 10) : 10);
|
||||
}
|
||||
|
||||
// ========== RESEARCH ==========
|
||||
|
||||
@Post('research')
|
||||
research(
|
||||
@Body() body: {
|
||||
topic: string;
|
||||
depth?: 'quick' | 'standard' | 'deep';
|
||||
includeStats?: boolean;
|
||||
includeQuotes?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.service.research({
|
||||
topic: body.topic,
|
||||
depth: body.depth || 'standard',
|
||||
includeStats: body.includeStats,
|
||||
includeQuotes: body.includeQuotes,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('research/fact-check')
|
||||
factCheck(@Body() body: { claim: string }) {
|
||||
return this.service.factCheck(body.claim);
|
||||
}
|
||||
|
||||
@Post('research/content')
|
||||
getContentResearch(@Body() body: { topic: string }) {
|
||||
return this.service.getContentResearch(body.topic);
|
||||
}
|
||||
|
||||
// ========== PLATFORMS ==========
|
||||
|
||||
@Get('platforms')
|
||||
getPlatforms() {
|
||||
return this.service.getPlatforms();
|
||||
}
|
||||
|
||||
@Get('platforms/:platform')
|
||||
getPlatformConfig(@Param('platform') platform: Platform) {
|
||||
return this.service.getPlatformConfig(platform);
|
||||
}
|
||||
|
||||
@Post('platforms/:platform/generate')
|
||||
generateForPlatform(
|
||||
@Param('platform') platform: Platform,
|
||||
@Body() body: { topic: string; mainMessage: string },
|
||||
) {
|
||||
return this.service.generateForPlatform(platform, body);
|
||||
}
|
||||
|
||||
@Post('platforms/multi')
|
||||
generateMultiPlatform(
|
||||
@Body() body: { topic: string; mainMessage: string; platforms: Platform[] },
|
||||
) {
|
||||
return this.service.generateMultiPlatform(body);
|
||||
}
|
||||
|
||||
@Post('platforms/adapt')
|
||||
adaptContent(
|
||||
@Body() body: { content: string; fromPlatform: Platform; toPlatform: Platform },
|
||||
) {
|
||||
return this.service.adaptContent(body.content, body.fromPlatform, body.toPlatform);
|
||||
}
|
||||
|
||||
// ========== HASHTAGS ==========
|
||||
|
||||
@Post('hashtags/generate')
|
||||
generateHashtags(
|
||||
@Body() body: { topic: string; platform: string; count?: number },
|
||||
) {
|
||||
return this.service.generateHashtags(body.topic, body.platform);
|
||||
}
|
||||
|
||||
@Get('hashtags/analyze/:hashtag')
|
||||
analyzeHashtag(@Param('hashtag') hashtag: string) {
|
||||
return this.service.analyzeHashtag(hashtag);
|
||||
}
|
||||
|
||||
@Get('hashtags/trending')
|
||||
getTrendingHashtags(@Query('category') category?: string) {
|
||||
return this.service.getTrendingHashtags(category);
|
||||
}
|
||||
|
||||
// ========== BRAND VOICE ==========
|
||||
|
||||
@Get('brand-voice/presets')
|
||||
listBrandVoicePresets() {
|
||||
return this.service.listBrandVoicePresets();
|
||||
}
|
||||
|
||||
@Post('brand-voice')
|
||||
createBrandVoice(@Body() body: Partial<BrandVoice> & { name: string }) {
|
||||
return this.service.createBrandVoice(body);
|
||||
}
|
||||
|
||||
@Get('brand-voice/:id')
|
||||
getBrandVoice(@Param('id') id: string) {
|
||||
return this.service.getBrandVoice(id);
|
||||
}
|
||||
|
||||
@Post('brand-voice/:id/apply')
|
||||
applyBrandVoice(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { content: string },
|
||||
) {
|
||||
return this.service.applyBrandVoice(body.content, id);
|
||||
}
|
||||
|
||||
@Get('brand-voice/:id/prompt')
|
||||
generateVoicePrompt(@Param('id') id: string) {
|
||||
return { prompt: this.service.generateVoicePrompt(id) };
|
||||
}
|
||||
|
||||
// ========== VARIATIONS ==========
|
||||
|
||||
@Post('variations')
|
||||
generateVariations(
|
||||
@Body() body: {
|
||||
content: string;
|
||||
count?: number;
|
||||
type?: VariationType;
|
||||
preserveCore?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.service.generateVariations(body.content, {
|
||||
count: body.count,
|
||||
variationType: body.type,
|
||||
preserveCore: body.preserveCore,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('variations/ab-test')
|
||||
createABTest(@Body() body: { content: string }) {
|
||||
return this.service.createABTest(body.content);
|
||||
}
|
||||
}
|
||||
33
src/modules/content-generation/content-generation.module.ts
Normal file
33
src/modules/content-generation/content-generation.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Content Generation Module - Platform-specific content generators
|
||||
// Path: src/modules/content-generation/content-generation.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../database/prisma.module';
|
||||
import { ContentGenerationService } from './content-generation.service';
|
||||
import { ContentGenerationController } from './content-generation.controller';
|
||||
import { NicheService } from './services/niche.service';
|
||||
import { DeepResearchService } from './services/deep-research.service';
|
||||
import { PlatformGeneratorService } from './services/platform-generator.service';
|
||||
import { HashtagService } from './services/hashtag.service';
|
||||
import { BrandVoiceService } from './services/brand-voice.service';
|
||||
import { VariationService } from './services/variation.service';
|
||||
import { SeoModule } from '../seo/seo.module';
|
||||
import { NeuroMarketingModule } from '../neuro-marketing/neuro-marketing.module';
|
||||
import { GeminiModule } from '../gemini/gemini.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, SeoModule, NeuroMarketingModule, GeminiModule],
|
||||
providers: [
|
||||
ContentGenerationService,
|
||||
NicheService,
|
||||
DeepResearchService,
|
||||
PlatformGeneratorService,
|
||||
HashtagService,
|
||||
BrandVoiceService,
|
||||
VariationService,
|
||||
],
|
||||
controllers: [ContentGenerationController],
|
||||
exports: [ContentGenerationService],
|
||||
})
|
||||
export class ContentGenerationModule { }
|
||||
|
||||
303
src/modules/content-generation/content-generation.service.ts
Normal file
303
src/modules/content-generation/content-generation.service.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
// Content Generation Service - Main orchestration
|
||||
// Path: src/modules/content-generation/content-generation.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { NicheService, Niche, NicheAnalysis } from './services/niche.service';
|
||||
import { DeepResearchService, ResearchResult, ResearchQuery } from './services/deep-research.service';
|
||||
import { PlatformGeneratorService, Platform, GeneratedContent, MultiPlatformContent } from './services/platform-generator.service';
|
||||
import { HashtagService, HashtagSet } from './services/hashtag.service';
|
||||
import { BrandVoiceService, BrandVoice, VoiceApplication } from './services/brand-voice.service';
|
||||
import { VariationService, VariationSet, VariationOptions } from './services/variation.service';
|
||||
import { SeoService, FullSeoAnalysis } from '../seo/seo.service';
|
||||
import { NeuroMarketingService } from '../neuro-marketing/neuro-marketing.service';
|
||||
|
||||
export interface ContentGenerationRequest {
|
||||
topic: string;
|
||||
niche?: string;
|
||||
platforms: Platform[];
|
||||
includeResearch?: boolean;
|
||||
includeHashtags?: boolean;
|
||||
brandVoiceId?: string;
|
||||
variationCount?: number;
|
||||
}
|
||||
|
||||
export interface SeoAnalysisResult {
|
||||
score: number;
|
||||
keywords: string[];
|
||||
suggestions: string[];
|
||||
meta: { title: string; description: string; };
|
||||
}
|
||||
|
||||
export interface NeuroAnalysisResult {
|
||||
score: number;
|
||||
triggersUsed: string[];
|
||||
emotionProfile: string[];
|
||||
improvements: string[];
|
||||
}
|
||||
|
||||
export interface GeneratedContentBundle {
|
||||
id: string;
|
||||
topic: string;
|
||||
niche?: NicheAnalysis;
|
||||
research?: ResearchResult;
|
||||
platforms: GeneratedContent[];
|
||||
variations: VariationSet[];
|
||||
seo?: SeoAnalysisResult;
|
||||
neuro?: NeuroAnalysisResult;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentGenerationService {
|
||||
private readonly logger = new Logger(ContentGenerationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nicheService: NicheService,
|
||||
private readonly researchService: DeepResearchService,
|
||||
private readonly platformService: PlatformGeneratorService,
|
||||
private readonly hashtagService: HashtagService,
|
||||
private readonly brandVoiceService: BrandVoiceService,
|
||||
private readonly variationService: VariationService,
|
||||
private readonly seoService: SeoService,
|
||||
private readonly neuroService: NeuroMarketingService,
|
||||
) { }
|
||||
|
||||
// ========== FULL GENERATION WORKFLOW ==========
|
||||
|
||||
/**
|
||||
* Complete content generation workflow
|
||||
*/
|
||||
async generateContent(request: ContentGenerationRequest): Promise<GeneratedContentBundle> {
|
||||
const {
|
||||
topic,
|
||||
niche,
|
||||
platforms,
|
||||
includeResearch = true,
|
||||
includeHashtags = true,
|
||||
brandVoiceId,
|
||||
variationCount = 3,
|
||||
} = request;
|
||||
|
||||
// Analyze niche if provided
|
||||
let nicheAnalysis: NicheAnalysis | undefined;
|
||||
if (niche) {
|
||||
nicheAnalysis = this.nicheService.analyzeNiche(niche) || undefined;
|
||||
}
|
||||
|
||||
// Perform research if requested
|
||||
let research: ResearchResult | undefined;
|
||||
if (includeResearch) {
|
||||
research = await this.researchService.research({
|
||||
topic,
|
||||
depth: 'standard',
|
||||
includeStats: true,
|
||||
includeQuotes: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate content for each platform using AI
|
||||
const platformContent: GeneratedContent[] = [];
|
||||
for (const platform of platforms) {
|
||||
try {
|
||||
// Use AI generation when available
|
||||
const aiContent = await this.platformService.generateAIContent(
|
||||
topic,
|
||||
research?.summary || `Everything you need to know about ${topic}`,
|
||||
platform,
|
||||
'standard',
|
||||
'tr',
|
||||
);
|
||||
|
||||
const config = this.platformService.getPlatformConfig(platform);
|
||||
let content: GeneratedContent = {
|
||||
platform,
|
||||
format: 'AI Generated',
|
||||
content: aiContent,
|
||||
hashtags: [],
|
||||
mediaRecommendations: [],
|
||||
postingRecommendation: `Best times: ${config.bestPostingTimes.join(', ')}`,
|
||||
characterCount: aiContent.length,
|
||||
isWithinLimit: aiContent.length <= config.maxCharacters,
|
||||
};
|
||||
|
||||
// Apply brand voice if specified
|
||||
if (brandVoiceId) {
|
||||
const voiceApplied = this.brandVoiceService.applyVoice(content.content, brandVoiceId);
|
||||
content.content = voiceApplied.branded;
|
||||
}
|
||||
|
||||
// Add hashtags if requested
|
||||
if (includeHashtags) {
|
||||
const hashtagSet = this.hashtagService.generateHashtags(topic, platform);
|
||||
content.hashtags = hashtagSet.hashtags.map((h) => h.hashtag);
|
||||
}
|
||||
|
||||
platformContent.push(content);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to generate content for platform ${platform}: ${error.message}`);
|
||||
// Continue to next platform
|
||||
}
|
||||
}
|
||||
|
||||
// Generate variations for primary platform
|
||||
const variations: VariationSet[] = [];
|
||||
if (variationCount > 0 && platformContent.length > 0) {
|
||||
const primaryContent = platformContent[0].content;
|
||||
const variationSet = this.variationService.generateVariations(primaryContent, {
|
||||
count: variationCount,
|
||||
variationType: 'complete',
|
||||
});
|
||||
variations.push(variationSet);
|
||||
}
|
||||
|
||||
// SEO Analysis
|
||||
let seoResult: SeoAnalysisResult | undefined;
|
||||
if (platformContent.length > 0) {
|
||||
const primaryContent = platformContent[0].content;
|
||||
const seoScore = this.seoService.quickScore(primaryContent, topic);
|
||||
const lsiKeywords = this.seoService.getLSIKeywords(topic, 10);
|
||||
seoResult = {
|
||||
score: seoScore,
|
||||
keywords: lsiKeywords,
|
||||
suggestions: seoScore < 70 ? [
|
||||
'Add more keyword density',
|
||||
'Include long-tail keywords',
|
||||
'Add meta description',
|
||||
] : ['SEO is optimized'],
|
||||
meta: {
|
||||
title: `${topic} | Content Hunter`,
|
||||
description: research?.summary?.slice(0, 160) || `Learn about ${topic}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Neuro Marketing Analysis
|
||||
let neuroResult: NeuroAnalysisResult | undefined;
|
||||
if (platformContent.length > 0) {
|
||||
const primaryContent = platformContent[0].content;
|
||||
const analysis = this.neuroService.analyze(primaryContent, platforms[0]);
|
||||
neuroResult = {
|
||||
score: analysis.prediction.overallScore,
|
||||
triggersUsed: analysis.triggerAnalysis.used.map(t => t.name),
|
||||
emotionProfile: Object.keys(analysis.prediction.categories).filter(
|
||||
k => analysis.prediction.categories[k as keyof typeof analysis.prediction.categories] > 50
|
||||
),
|
||||
improvements: analysis.prediction.improvements,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: `gen-${Date.now()}`,
|
||||
topic,
|
||||
niche: nicheAnalysis,
|
||||
research,
|
||||
platforms: platformContent,
|
||||
variations,
|
||||
seo: seoResult,
|
||||
neuro: neuroResult,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// ========== NICHE OPERATIONS ==========
|
||||
|
||||
getNiches(): Niche[] {
|
||||
return this.nicheService.getAllNiches();
|
||||
}
|
||||
|
||||
analyzeNiche(nicheId: string): NicheAnalysis | null {
|
||||
return this.nicheService.analyzeNiche(nicheId);
|
||||
}
|
||||
|
||||
recommendNiches(interests: string[]): Niche[] {
|
||||
return this.nicheService.recommendNiches(interests);
|
||||
}
|
||||
|
||||
getContentIdeas(nicheId: string, count?: number): string[] {
|
||||
return this.nicheService.getContentIdeas(nicheId, count);
|
||||
}
|
||||
|
||||
// ========== RESEARCH OPERATIONS ==========
|
||||
|
||||
async research(query: ResearchQuery): Promise<ResearchResult> {
|
||||
return this.researchService.research(query);
|
||||
}
|
||||
|
||||
async factCheck(claim: string) {
|
||||
return this.researchService.factCheck(claim);
|
||||
}
|
||||
|
||||
async getContentResearch(topic: string) {
|
||||
return this.researchService.research({ topic, depth: 'standard', includeStats: true, includeQuotes: true });
|
||||
}
|
||||
|
||||
// ========== PLATFORM OPERATIONS ==========
|
||||
|
||||
getPlatforms() {
|
||||
return this.platformService.getAllPlatforms();
|
||||
}
|
||||
|
||||
getPlatformConfig(platform: Platform) {
|
||||
return this.platformService.getPlatformConfig(platform);
|
||||
}
|
||||
|
||||
generateForPlatform(platform: Platform, input: { topic: string; mainMessage: string }) {
|
||||
return this.platformService.generateForPlatform(platform, input);
|
||||
}
|
||||
|
||||
generateMultiPlatform(input: { topic: string; mainMessage: string; platforms: Platform[] }) {
|
||||
return this.platformService.generateMultiPlatform(input);
|
||||
}
|
||||
|
||||
adaptContent(content: string, from: Platform, to: Platform) {
|
||||
return this.platformService.adaptContent(content, from, to);
|
||||
}
|
||||
|
||||
// ========== HASHTAG OPERATIONS ==========
|
||||
|
||||
generateHashtags(topic: string, platform: string): HashtagSet {
|
||||
return this.hashtagService.generateHashtags(topic, platform);
|
||||
}
|
||||
|
||||
analyzeHashtag(hashtag: string) {
|
||||
return this.hashtagService.analyzeHashtag(hashtag);
|
||||
}
|
||||
|
||||
getTrendingHashtags(category?: string) {
|
||||
return this.hashtagService.getTrendingHashtags(category);
|
||||
}
|
||||
|
||||
// ========== BRAND VOICE OPERATIONS ==========
|
||||
|
||||
createBrandVoice(input: Partial<BrandVoice> & { name: string }): BrandVoice {
|
||||
return this.brandVoiceService.createBrandVoice(input);
|
||||
}
|
||||
|
||||
getBrandVoice(id: string): BrandVoice | null {
|
||||
return this.brandVoiceService.getBrandVoice(id);
|
||||
}
|
||||
|
||||
listBrandVoicePresets() {
|
||||
return this.brandVoiceService.listPresets();
|
||||
}
|
||||
|
||||
applyBrandVoice(content: string, voiceId: string): VoiceApplication {
|
||||
return this.brandVoiceService.applyVoice(content, voiceId);
|
||||
}
|
||||
|
||||
generateVoicePrompt(voiceId: string): string {
|
||||
return this.brandVoiceService.generateVoicePrompt(voiceId);
|
||||
}
|
||||
|
||||
// ========== VARIATION OPERATIONS ==========
|
||||
|
||||
generateVariations(content: string, options?: VariationOptions): VariationSet {
|
||||
return this.variationService.generateVariations(content, options);
|
||||
}
|
||||
|
||||
createABTest(content: string) {
|
||||
return this.variationService.createABTest(content);
|
||||
}
|
||||
}
|
||||
12
src/modules/content-generation/index.ts
Normal file
12
src/modules/content-generation/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Content Generation Module - Index exports
|
||||
// Path: src/modules/content-generation/index.ts
|
||||
|
||||
export * from './content-generation.module';
|
||||
export * from './content-generation.service';
|
||||
export * from './content-generation.controller';
|
||||
export * from './services/niche.service';
|
||||
export * from './services/deep-research.service';
|
||||
export * from './services/platform-generator.service';
|
||||
export * from './services/hashtag.service';
|
||||
export * from './services/brand-voice.service';
|
||||
export * from './services/variation.service';
|
||||
547
src/modules/content-generation/services/brand-voice.service.ts
Normal file
547
src/modules/content-generation/services/brand-voice.service.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
// Brand Voice Service - Brand voice training and application
|
||||
// Path: src/modules/content-generation/services/brand-voice.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface BrandVoice {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
personality: BrandPersonality;
|
||||
toneAttributes: ToneAttribute[];
|
||||
vocabulary: VocabularyRules;
|
||||
examples: BrandExample[];
|
||||
doList: string[];
|
||||
dontList: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface BrandPersonality {
|
||||
primary: PersonalityTrait;
|
||||
secondary: PersonalityTrait;
|
||||
tertiary?: PersonalityTrait;
|
||||
archetypes: string[];
|
||||
}
|
||||
|
||||
export type PersonalityTrait =
|
||||
| 'friendly'
|
||||
| 'professional'
|
||||
| 'playful'
|
||||
| 'authoritative'
|
||||
| 'empathetic'
|
||||
| 'innovative'
|
||||
| 'traditional'
|
||||
| 'bold'
|
||||
| 'calm'
|
||||
| 'energetic';
|
||||
|
||||
export interface ToneAttribute {
|
||||
attribute: string;
|
||||
level: number; // 1-10
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface VocabularyRules {
|
||||
preferredWords: string[];
|
||||
avoidWords: string[];
|
||||
industryTerms: string[];
|
||||
brandSpecificTerms: Record<string, string>; // term -> replacement
|
||||
emojiUsage: 'heavy' | 'moderate' | 'minimal' | 'none';
|
||||
formality: 'formal' | 'semi-formal' | 'casual' | 'very-casual';
|
||||
}
|
||||
|
||||
export interface BrandExample {
|
||||
type: 'good' | 'bad';
|
||||
original?: string;
|
||||
branded: string;
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
export interface VoiceApplication {
|
||||
original: string;
|
||||
branded: string;
|
||||
changes: VoiceChange[];
|
||||
voiceScore: number;
|
||||
}
|
||||
|
||||
export interface VoiceChange {
|
||||
type: 'vocabulary' | 'tone' | 'structure' | 'emoji';
|
||||
before: string;
|
||||
after: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BrandVoiceService {
|
||||
private readonly logger = new Logger(BrandVoiceService.name);
|
||||
|
||||
// In-memory storage for demo
|
||||
private brandVoices: Map<string, BrandVoice> = new Map();
|
||||
|
||||
// Preset brand voice templates
|
||||
private readonly presets: Record<string, Partial<BrandVoice>> = {
|
||||
'startup-founder': {
|
||||
name: 'Startup Founder',
|
||||
description: 'Energetic, innovative, and transparent communication style',
|
||||
personality: {
|
||||
primary: 'bold',
|
||||
secondary: 'innovative',
|
||||
tertiary: 'friendly',
|
||||
archetypes: ['Innovator', 'Maverick', 'Pioneer'],
|
||||
},
|
||||
toneAttributes: [
|
||||
{ attribute: 'Confidence', level: 9, description: 'Speak with conviction' },
|
||||
{ attribute: 'Transparency', level: 8, description: 'Be open about challenges' },
|
||||
{ attribute: 'Excitement', level: 8, description: 'Show passion for the mission' },
|
||||
],
|
||||
vocabulary: {
|
||||
preferredWords: ['building', 'shipping', 'scaling', 'disrupting', 'journey', 'hustle'],
|
||||
avoidWords: ['corporate', 'synergy', 'leverage', 'circle back'],
|
||||
industryTerms: ['MVP', 'product-market fit', 'runway', 'growth hack'],
|
||||
brandSpecificTerms: { 'company': 'our team', 'customers': 'community' },
|
||||
emojiUsage: 'moderate',
|
||||
formality: 'casual',
|
||||
},
|
||||
doList: [
|
||||
'Share behind-the-scenes moments',
|
||||
'Admit mistakes openly',
|
||||
'Celebrate team wins',
|
||||
'Use "we" instead of "I"',
|
||||
],
|
||||
dontList: [
|
||||
'Sound corporate or stiff',
|
||||
'Overpromise',
|
||||
'Ignore feedback',
|
||||
'Use jargon without explanation',
|
||||
],
|
||||
},
|
||||
|
||||
'thought-leader': {
|
||||
name: 'Thought Leader',
|
||||
description: 'Authoritative yet approachable expert positioning',
|
||||
personality: {
|
||||
primary: 'authoritative',
|
||||
secondary: 'empathetic',
|
||||
tertiary: 'calm',
|
||||
archetypes: ['Sage', 'Teacher', 'Guide'],
|
||||
},
|
||||
toneAttributes: [
|
||||
{ attribute: 'Authority', level: 9, description: 'Speak from experience' },
|
||||
{ attribute: 'Wisdom', level: 8, description: 'Share insights, not just information' },
|
||||
{ attribute: 'Humility', level: 7, description: 'Credit sources, acknowledge limits' },
|
||||
],
|
||||
vocabulary: {
|
||||
preferredWords: ['insight', 'perspective', 'framework', 'principle', 'observation'],
|
||||
avoidWords: ['obviously', 'simply', 'just', 'everyone knows'],
|
||||
industryTerms: [],
|
||||
brandSpecificTerms: {},
|
||||
emojiUsage: 'minimal',
|
||||
formality: 'semi-formal',
|
||||
},
|
||||
doList: [
|
||||
'Share original frameworks',
|
||||
'Reference data and research',
|
||||
'Tell stories with lessons',
|
||||
'Ask thought-provoking questions',
|
||||
],
|
||||
dontList: [
|
||||
'Be preachy or condescending',
|
||||
'Claim to have all answers',
|
||||
'Dismiss other perspectives',
|
||||
'Overuse "I"',
|
||||
],
|
||||
},
|
||||
|
||||
'friendly-expert': {
|
||||
name: 'Friendly Expert',
|
||||
description: 'Warm, helpful, and knowledgeable without being intimidating',
|
||||
personality: {
|
||||
primary: 'friendly',
|
||||
secondary: 'professional',
|
||||
tertiary: 'empathetic',
|
||||
archetypes: ['Guide', 'Helper', 'Friend'],
|
||||
},
|
||||
toneAttributes: [
|
||||
{ attribute: 'Warmth', level: 9, description: 'Make people feel welcome' },
|
||||
{ attribute: 'Helpfulness', level: 9, description: 'Always provide value' },
|
||||
{ attribute: 'Clarity', level: 8, description: 'Explain simply' },
|
||||
],
|
||||
vocabulary: {
|
||||
preferredWords: ['let\'s', 'together', 'you', 'try', 'discover', 'learn'],
|
||||
avoidWords: ['actually', 'basically', 'obviously', 'you should'],
|
||||
industryTerms: [],
|
||||
brandSpecificTerms: {},
|
||||
emojiUsage: 'moderate',
|
||||
formality: 'casual',
|
||||
},
|
||||
doList: [
|
||||
'Use "you" frequently',
|
||||
'Acknowledge struggles',
|
||||
'Celebrate wins, even small ones',
|
||||
'End with encouragement',
|
||||
],
|
||||
dontList: [
|
||||
'Talk down to audience',
|
||||
'Use complex jargon',
|
||||
'Be negative or discouraging',
|
||||
'Ignore questions',
|
||||
],
|
||||
},
|
||||
|
||||
'bold-provocateur': {
|
||||
name: 'Bold Provocateur',
|
||||
description: 'Challenging conventional wisdom with strong opinions',
|
||||
personality: {
|
||||
primary: 'bold',
|
||||
secondary: 'energetic',
|
||||
tertiary: 'innovative',
|
||||
archetypes: ['Rebel', 'Provocateur', 'Truth-teller'],
|
||||
},
|
||||
toneAttributes: [
|
||||
{ attribute: 'Boldness', level: 10, description: 'Don\'t hold back' },
|
||||
{ attribute: 'Controversy', level: 7, description: 'Challenge status quo' },
|
||||
{ attribute: 'Conviction', level: 9, description: 'Stand by your views' },
|
||||
],
|
||||
vocabulary: {
|
||||
preferredWords: ['truth', 'reality', 'myth', 'wake up', 'stop', 'wrong'],
|
||||
avoidWords: ['maybe', 'perhaps', 'I think', 'in my humble opinion'],
|
||||
industryTerms: [],
|
||||
brandSpecificTerms: {},
|
||||
emojiUsage: 'minimal',
|
||||
formality: 'casual',
|
||||
},
|
||||
doList: [
|
||||
'Take strong positions',
|
||||
'Back claims with evidence',
|
||||
'Name problems directly',
|
||||
'Offer real solutions',
|
||||
],
|
||||
dontList: [
|
||||
'Be mean-spirited',
|
||||
'Attack individuals',
|
||||
'Claim controversy for its own sake',
|
||||
'Refuse to engage with disagreement',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a custom brand voice
|
||||
*/
|
||||
createBrandVoice(input: Partial<BrandVoice> & { name: string }): BrandVoice {
|
||||
const brandVoice: BrandVoice = {
|
||||
id: `voice-${Date.now()}`,
|
||||
name: input.name,
|
||||
description: input.description || '',
|
||||
personality: input.personality || {
|
||||
primary: 'friendly',
|
||||
secondary: 'professional',
|
||||
archetypes: [],
|
||||
},
|
||||
toneAttributes: input.toneAttributes || [],
|
||||
vocabulary: input.vocabulary || {
|
||||
preferredWords: [],
|
||||
avoidWords: [],
|
||||
industryTerms: [],
|
||||
brandSpecificTerms: {},
|
||||
emojiUsage: 'moderate',
|
||||
formality: 'semi-formal',
|
||||
},
|
||||
examples: input.examples || [],
|
||||
doList: input.doList || [],
|
||||
dontList: input.dontList || [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.brandVoices.set(brandVoice.id, brandVoice);
|
||||
return brandVoice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get brand voice by ID
|
||||
*/
|
||||
getBrandVoice(id: string): BrandVoice | null {
|
||||
return this.brandVoices.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get preset brand voice
|
||||
*/
|
||||
getPreset(presetName: string): Partial<BrandVoice> | null {
|
||||
return this.presets[presetName] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all presets
|
||||
*/
|
||||
listPresets(): { name: string; description: string }[] {
|
||||
return Object.entries(this.presets).map(([key, preset]) => ({
|
||||
name: key,
|
||||
description: preset.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply brand voice to content
|
||||
*/
|
||||
applyVoice(content: string, voiceId: string): VoiceApplication {
|
||||
const voice = this.brandVoices.get(voiceId);
|
||||
if (!voice) {
|
||||
return {
|
||||
original: content,
|
||||
branded: content,
|
||||
changes: [],
|
||||
voiceScore: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let branded = content;
|
||||
const changes: VoiceChange[] = [];
|
||||
|
||||
// Apply vocabulary substitutions
|
||||
for (const [term, replacement] of Object.entries(voice.vocabulary.brandSpecificTerms)) {
|
||||
if (branded.toLowerCase().includes(term.toLowerCase())) {
|
||||
const regex = new RegExp(term, 'gi');
|
||||
branded = branded.replace(regex, replacement);
|
||||
changes.push({
|
||||
type: 'vocabulary',
|
||||
before: term,
|
||||
after: replacement,
|
||||
reason: 'Brand-specific terminology',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove avoided words
|
||||
for (const avoidWord of voice.vocabulary.avoidWords) {
|
||||
if (branded.toLowerCase().includes(avoidWord.toLowerCase())) {
|
||||
const regex = new RegExp(`\\b${avoidWord}\\b`, 'gi');
|
||||
branded = branded.replace(regex, '');
|
||||
changes.push({
|
||||
type: 'vocabulary',
|
||||
before: avoidWord,
|
||||
after: '[removed]',
|
||||
reason: 'Avoid word per brand guidelines',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust formality
|
||||
branded = this.adjustFormality(branded, voice.vocabulary.formality, changes);
|
||||
|
||||
// Handle emoji usage
|
||||
branded = this.adjustEmojis(branded, voice.vocabulary.emojiUsage, changes);
|
||||
|
||||
// Calculate voice score
|
||||
const voiceScore = this.calculateVoiceScore(branded, voice);
|
||||
|
||||
return {
|
||||
original: content,
|
||||
branded: branded.replace(/\s+/g, ' ').trim(),
|
||||
changes,
|
||||
voiceScore,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze content against brand voice
|
||||
*/
|
||||
analyzeAgainstVoice(content: string, voiceId: string): {
|
||||
overallScore: number;
|
||||
vocabularyScore: number;
|
||||
toneScore: number;
|
||||
suggestions: string[];
|
||||
} {
|
||||
const voice = this.brandVoices.get(voiceId);
|
||||
if (!voice) {
|
||||
return { overallScore: 0, vocabularyScore: 0, toneScore: 0, suggestions: [] };
|
||||
}
|
||||
|
||||
const vocabularyScore = this.calculateVocabularyScore(content, voice);
|
||||
const toneScore = this.calculateToneScore(content, voice);
|
||||
const overallScore = Math.round((vocabularyScore + toneScore) / 2);
|
||||
|
||||
const suggestions = this.generateSuggestions(content, voice);
|
||||
|
||||
return {
|
||||
overallScore,
|
||||
vocabularyScore,
|
||||
toneScore,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI prompt for brand voice
|
||||
*/
|
||||
generateVoicePrompt(voiceId: string): string {
|
||||
const voice = this.brandVoices.get(voiceId);
|
||||
if (!voice) return '';
|
||||
|
||||
return `
|
||||
Write in the following brand voice:
|
||||
|
||||
PERSONALITY: ${voice.personality.primary}, ${voice.personality.secondary}
|
||||
ARCHETYPES: ${voice.personality.archetypes.join(', ')}
|
||||
FORMALITY: ${voice.vocabulary.formality}
|
||||
EMOJI USAGE: ${voice.vocabulary.emojiUsage}
|
||||
|
||||
TONE ATTRIBUTES:
|
||||
${voice.toneAttributes.map((t) => `- ${t.attribute} (${t.level}/10): ${t.description}`).join('\n')}
|
||||
|
||||
VOCABULARY DO'S:
|
||||
${voice.vocabulary.preferredWords.join(', ')}
|
||||
|
||||
VOCABULARY DON'TS:
|
||||
${voice.vocabulary.avoidWords.join(', ')}
|
||||
|
||||
DO:
|
||||
${voice.doList.map((d) => `- ${d}`).join('\n')}
|
||||
|
||||
DON'T:
|
||||
${voice.dontList.map((d) => `- ${d}`).join('\n')}
|
||||
`.trim();
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private adjustFormality(
|
||||
content: string,
|
||||
formality: string,
|
||||
changes: VoiceChange[],
|
||||
): string {
|
||||
let adjusted = content;
|
||||
|
||||
if (formality === 'casual' || formality === 'very-casual') {
|
||||
// Add contractions
|
||||
const contractions: Record<string, string> = {
|
||||
'do not': "don't",
|
||||
'cannot': "can't",
|
||||
'will not': "won't",
|
||||
'is not': "isn't",
|
||||
'are not': "aren't",
|
||||
'I am': "I'm",
|
||||
'you are': "you're",
|
||||
'we are': "we're",
|
||||
'it is': "it's",
|
||||
};
|
||||
|
||||
for (const [formal, informal] of Object.entries(contractions)) {
|
||||
const regex = new RegExp(formal, 'gi');
|
||||
if (adjusted.match(regex)) {
|
||||
adjusted = adjusted.replace(regex, informal);
|
||||
changes.push({
|
||||
type: 'tone',
|
||||
before: formal,
|
||||
after: informal,
|
||||
reason: 'Casual tone adjustment',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return adjusted;
|
||||
}
|
||||
|
||||
private adjustEmojis(
|
||||
content: string,
|
||||
emojiUsage: string,
|
||||
changes: VoiceChange[],
|
||||
): string {
|
||||
if (emojiUsage === 'none') {
|
||||
const withoutEmojis = content.replace(/[\u{1F300}-\u{1F9FF}]/gu, '');
|
||||
if (withoutEmojis !== content) {
|
||||
changes.push({
|
||||
type: 'emoji',
|
||||
before: 'emojis present',
|
||||
after: 'emojis removed',
|
||||
reason: 'Brand voice prohibits emojis',
|
||||
});
|
||||
}
|
||||
return withoutEmojis;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private calculateVoiceScore(content: string, voice: BrandVoice): number {
|
||||
let score = 60;
|
||||
|
||||
// Check preferred words
|
||||
const hasPreferred = voice.vocabulary.preferredWords.some((w) =>
|
||||
content.toLowerCase().includes(w.toLowerCase())
|
||||
);
|
||||
if (hasPreferred) score += 15;
|
||||
|
||||
// Check avoided words
|
||||
const hasAvoided = voice.vocabulary.avoidWords.some((w) =>
|
||||
content.toLowerCase().includes(w.toLowerCase())
|
||||
);
|
||||
if (hasAvoided) score -= 15;
|
||||
|
||||
return Math.min(100, Math.max(0, score));
|
||||
}
|
||||
|
||||
private calculateVocabularyScore(content: string, voice: BrandVoice): number {
|
||||
let score = 50;
|
||||
const contentLower = content.toLowerCase();
|
||||
|
||||
// Preferred words bonus
|
||||
voice.vocabulary.preferredWords.forEach((word) => {
|
||||
if (contentLower.includes(word.toLowerCase())) score += 5;
|
||||
});
|
||||
|
||||
// Avoided words penalty
|
||||
voice.vocabulary.avoidWords.forEach((word) => {
|
||||
if (contentLower.includes(word.toLowerCase())) score -= 10;
|
||||
});
|
||||
|
||||
return Math.min(100, Math.max(0, score));
|
||||
}
|
||||
|
||||
private calculateToneScore(content: string, voice: BrandVoice): number {
|
||||
let score = 50;
|
||||
|
||||
// Simple heuristics for tone
|
||||
const hasExclamation = content.includes('!');
|
||||
const hasQuestion = content.includes('?');
|
||||
const wordCount = content.split(/\s+/).length;
|
||||
const avgWordLength = content.replace(/\s+/g, '').length / wordCount;
|
||||
|
||||
// Adjust based on personality
|
||||
if (voice.personality.primary === 'energetic' && hasExclamation) score += 10;
|
||||
if (voice.personality.primary === 'calm' && !hasExclamation) score += 10;
|
||||
if (voice.personality.secondary === 'empathetic' && hasQuestion) score += 10;
|
||||
|
||||
return Math.min(100, Math.max(0, score));
|
||||
}
|
||||
|
||||
private generateSuggestions(content: string, voice: BrandVoice): string[] {
|
||||
const suggestions: string[] = [];
|
||||
const contentLower = content.toLowerCase();
|
||||
|
||||
// Check for avoided words
|
||||
voice.vocabulary.avoidWords.forEach((word) => {
|
||||
if (contentLower.includes(word.toLowerCase())) {
|
||||
suggestions.push(`Consider removing or replacing "${word}"`);
|
||||
}
|
||||
});
|
||||
|
||||
// Suggest preferred words if missing
|
||||
const hasAnyPreferred = voice.vocabulary.preferredWords.some((w) =>
|
||||
contentLower.includes(w.toLowerCase())
|
||||
);
|
||||
if (!hasAnyPreferred && voice.vocabulary.preferredWords.length > 0) {
|
||||
suggestions.push(`Try incorporating brand words like: ${voice.vocabulary.preferredWords.slice(0, 3).join(', ')}`);
|
||||
}
|
||||
|
||||
// General suggestions from do's list
|
||||
if (voice.doList.length > 0 && suggestions.length < 3) {
|
||||
suggestions.push(`Remember: ${voice.doList[0]}`);
|
||||
}
|
||||
|
||||
return suggestions.slice(0, 5);
|
||||
}
|
||||
}
|
||||
301
src/modules/content-generation/services/deep-research.service.ts
Normal file
301
src/modules/content-generation/services/deep-research.service.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
// Deep Research Service - Real implementation using Gemini AI
|
||||
// Path: src/modules/content-generation/services/deep-research.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { GeminiService } from '../../gemini/gemini.service';
|
||||
|
||||
export interface ResearchQuery {
|
||||
topic: string;
|
||||
depth: 'quick' | 'standard' | 'deep';
|
||||
sources?: SourceType[];
|
||||
maxSources?: number;
|
||||
includeStats?: boolean;
|
||||
includeQuotes?: boolean;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export type SourceType =
|
||||
| 'academic'
|
||||
| 'news'
|
||||
| 'industry'
|
||||
| 'social'
|
||||
| 'government'
|
||||
| 'blog';
|
||||
|
||||
export interface ResearchResult {
|
||||
query: string;
|
||||
summary: string;
|
||||
keyFindings: KeyFinding[];
|
||||
statistics: Statistic[];
|
||||
quotes: Quote[];
|
||||
sources: Source[];
|
||||
relatedTopics: string[];
|
||||
contentAngles: string[];
|
||||
generatedAt: Date;
|
||||
}
|
||||
|
||||
export interface KeyFinding {
|
||||
finding: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export interface Statistic {
|
||||
value: string;
|
||||
context: string;
|
||||
sourceId: string;
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export interface Quote {
|
||||
text: string;
|
||||
author: string;
|
||||
role?: string;
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
id: string;
|
||||
type: SourceType;
|
||||
title: string;
|
||||
url: string;
|
||||
author?: string;
|
||||
publishedDate?: string;
|
||||
credibilityScore: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DeepResearchService {
|
||||
private readonly logger = new Logger(DeepResearchService.name);
|
||||
|
||||
constructor(private readonly gemini: GeminiService) { }
|
||||
|
||||
/**
|
||||
* Perform deep research on a topic using Gemini AI
|
||||
*/
|
||||
async research(query: ResearchQuery): Promise<ResearchResult> {
|
||||
const { topic, depth, includeStats = true, includeQuotes = true, language = 'tr' } = query;
|
||||
|
||||
this.logger.log(`Performing ${depth} research on: ${topic}`);
|
||||
|
||||
if (!this.gemini.isAvailable()) {
|
||||
this.logger.warn('Gemini not available, returning basic research');
|
||||
return this.getFallbackResearch(topic);
|
||||
}
|
||||
|
||||
try {
|
||||
const researchPrompt = this.buildResearchPrompt(topic, depth, includeStats, includeQuotes, language);
|
||||
|
||||
const schema = `{
|
||||
"summary": "string - comprehensive summary of the topic (2-3 paragraphs)",
|
||||
"keyFindings": [{ "finding": "string", "confidence": "high|medium|low" }],
|
||||
"statistics": [{ "value": "string", "context": "string", "year": number }],
|
||||
"quotes": [{ "text": "string", "author": "string", "role": "string" }],
|
||||
"relatedTopics": ["string"],
|
||||
"contentAngles": ["string - unique content angle ideas"],
|
||||
"sources": [{ "title": "string", "url": "string", "type": "news|industry|blog|academic" }]
|
||||
}`;
|
||||
|
||||
const response = await this.gemini.generateJSON<any>(researchPrompt, schema, {
|
||||
temperature: 0.7,
|
||||
maxTokens: 4000,
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
|
||||
return {
|
||||
query: topic,
|
||||
summary: data.summary || `Research summary for ${topic}`,
|
||||
keyFindings: (data.keyFindings || []).map((f: any, i: number) => ({
|
||||
finding: f.finding,
|
||||
confidence: f.confidence || 'medium',
|
||||
sourceId: `src-${i}`,
|
||||
})),
|
||||
statistics: (data.statistics || []).map((s: any, i: number) => ({
|
||||
value: s.value,
|
||||
context: s.context,
|
||||
sourceId: `src-${i}`,
|
||||
year: s.year,
|
||||
})),
|
||||
quotes: (data.quotes || []).map((q: any, i: number) => ({
|
||||
text: q.text,
|
||||
author: q.author,
|
||||
role: q.role,
|
||||
sourceId: `src-${i}`,
|
||||
})),
|
||||
sources: (data.sources || []).map((s: any, i: number) => ({
|
||||
id: `src-${i}`,
|
||||
type: s.type || 'news',
|
||||
title: s.title,
|
||||
url: s.url || `https://google.com/search?q=${encodeURIComponent(topic)}`,
|
||||
credibilityScore: 80,
|
||||
})),
|
||||
relatedTopics: data.relatedTopics || [],
|
||||
contentAngles: data.contentAngles || [],
|
||||
generatedAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Research failed: ${error.message}`);
|
||||
return this.getFallbackResearch(topic);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the research prompt based on depth
|
||||
*/
|
||||
private buildResearchPrompt(
|
||||
topic: string,
|
||||
depth: string,
|
||||
includeStats: boolean,
|
||||
includeQuotes: boolean,
|
||||
language: string
|
||||
): string {
|
||||
const depthInstructions = {
|
||||
quick: 'Provide a brief overview with 3 key findings.',
|
||||
standard: 'Provide detailed research with 5-7 key findings, statistics, and expert quotes.',
|
||||
deep: 'Provide comprehensive research with 10+ key findings, extensive statistics, multiple expert quotes, and diverse content angles.',
|
||||
};
|
||||
|
||||
return `You are a professional research analyst. Research the following topic thoroughly and provide accurate, up-to-date information.
|
||||
|
||||
TOPIC: ${topic}
|
||||
|
||||
DEPTH: ${depthInstructions[depth] || depthInstructions.standard}
|
||||
|
||||
REQUIREMENTS:
|
||||
- Provide a comprehensive summary (2-3 paragraphs)
|
||||
- List key findings with confidence levels
|
||||
${includeStats ? '- Include relevant statistics with context and year' : ''}
|
||||
${includeQuotes ? '- Include quotes from industry experts or thought leaders' : ''}
|
||||
- Suggest related topics for further exploration
|
||||
- Provide unique content angles for creating engaging content
|
||||
- List credible sources (real URLs when possible)
|
||||
|
||||
LANGUAGE: Respond in ${language === 'tr' ? 'Turkish' : 'English'}
|
||||
|
||||
Be factual, avoid speculation, and cite sources where possible.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback when Gemini is not available
|
||||
*/
|
||||
private getFallbackResearch(topic: string): ResearchResult {
|
||||
return {
|
||||
query: topic,
|
||||
summary: `Research on "${topic}" requires AI service. Please ensure Gemini API is configured.`,
|
||||
keyFindings: [{
|
||||
finding: 'AI research service is currently unavailable',
|
||||
confidence: 'low',
|
||||
sourceId: 'fallback',
|
||||
}],
|
||||
statistics: [],
|
||||
quotes: [],
|
||||
sources: [{
|
||||
id: 'fallback',
|
||||
type: 'news',
|
||||
title: `Google Search: ${topic}`,
|
||||
url: `https://google.com/search?q=${encodeURIComponent(topic)}`,
|
||||
credibilityScore: 50,
|
||||
}],
|
||||
relatedTopics: [],
|
||||
contentAngles: [],
|
||||
generatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick fact check using Gemini
|
||||
*/
|
||||
async factCheck(claim: string): Promise<{
|
||||
claim: string;
|
||||
isAccurate: boolean;
|
||||
confidence: number;
|
||||
explanation: string;
|
||||
corrections?: string[];
|
||||
}> {
|
||||
if (!this.gemini.isAvailable()) {
|
||||
return {
|
||||
claim,
|
||||
isAccurate: false,
|
||||
confidence: 0,
|
||||
explanation: 'Fact-checking service unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
const prompt = `Fact check this claim: "${claim}"
|
||||
|
||||
Respond with:
|
||||
1. Is it accurate? (true/false)
|
||||
2. Confidence level (0-100)
|
||||
3. Brief explanation
|
||||
4. Any corrections needed`;
|
||||
|
||||
const schema = `{
|
||||
"isAccurate": boolean,
|
||||
"confidence": number,
|
||||
"explanation": "string",
|
||||
"corrections": ["string"]
|
||||
}`;
|
||||
|
||||
try {
|
||||
const response = await this.gemini.generateJSON<any>(prompt, schema);
|
||||
return {
|
||||
claim,
|
||||
isAccurate: response.data.isAccurate,
|
||||
confidence: response.data.confidence,
|
||||
explanation: response.data.explanation,
|
||||
corrections: response.data.corrections,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Fact check failed: ${error.message}`);
|
||||
return {
|
||||
claim,
|
||||
isAccurate: false,
|
||||
confidence: 0,
|
||||
explanation: 'Unable to verify claim',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze competitors/sources for a topic
|
||||
*/
|
||||
async analyzeCompetitors(topic: string, niche?: string): Promise<{
|
||||
topCreators: { name: string; platform: string; approach: string }[];
|
||||
contentGaps: string[];
|
||||
opportunities: string[];
|
||||
}> {
|
||||
if (!this.gemini.isAvailable()) {
|
||||
return {
|
||||
topCreators: [],
|
||||
contentGaps: ['Enable Gemini for competitor analysis'],
|
||||
opportunities: [],
|
||||
};
|
||||
}
|
||||
|
||||
const prompt = `Analyze the content landscape for "${topic}"${niche ? ` in the ${niche} niche` : ''}.
|
||||
|
||||
Identify:
|
||||
1. Top content creators covering this topic (with their platform and approach)
|
||||
2. Content gaps that are underserved
|
||||
3. Opportunities for unique content`;
|
||||
|
||||
const schema = `{
|
||||
"topCreators": [{ "name": "string", "platform": "string", "approach": "string" }],
|
||||
"contentGaps": ["string"],
|
||||
"opportunities": ["string"]
|
||||
}`;
|
||||
|
||||
try {
|
||||
const response = await this.gemini.generateJSON<any>(prompt, schema);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
this.logger.error(`Competitor analysis failed: ${error.message}`);
|
||||
return {
|
||||
topCreators: [],
|
||||
contentGaps: [],
|
||||
opportunities: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
276
src/modules/content-generation/services/hashtag.service.ts
Normal file
276
src/modules/content-generation/services/hashtag.service.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
// Hashtag Service - Intelligent hashtag generation and management
|
||||
// Path: src/modules/content-generation/services/hashtag.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface HashtagSuggestion {
|
||||
hashtag: string;
|
||||
type: HashtagType;
|
||||
popularity: 'low' | 'medium' | 'high' | 'trending';
|
||||
competition: 'low' | 'medium' | 'high';
|
||||
reachPotential: number; // 1-100
|
||||
recommended: boolean;
|
||||
}
|
||||
|
||||
export type HashtagType =
|
||||
| 'niche'
|
||||
| 'trending'
|
||||
| 'community'
|
||||
| 'branded'
|
||||
| 'location'
|
||||
| 'broad'
|
||||
| 'long_tail';
|
||||
|
||||
export interface HashtagSet {
|
||||
topic: string;
|
||||
platform: string;
|
||||
hashtags: HashtagSuggestion[];
|
||||
strategy: string;
|
||||
recommendedCount: number;
|
||||
}
|
||||
|
||||
export interface HashtagAnalysis {
|
||||
hashtag: string;
|
||||
totalPosts: number;
|
||||
avgEngagement: number;
|
||||
topRelated: string[];
|
||||
bestTimeToUse: string;
|
||||
overused: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HashtagService {
|
||||
private readonly logger = new Logger(HashtagService.name);
|
||||
|
||||
// Platform-specific hashtag limits
|
||||
private readonly platformLimits: Record<string, number> = {
|
||||
twitter: 3,
|
||||
instagram: 30,
|
||||
linkedin: 5,
|
||||
tiktok: 5,
|
||||
facebook: 3,
|
||||
youtube: 15,
|
||||
threads: 0,
|
||||
};
|
||||
|
||||
// Popular hashtag database (mock)
|
||||
private readonly hashtagDatabase: Record<string, { posts: number; engagement: number }> = {
|
||||
'contentcreator': { posts: 15000000, engagement: 2.5 },
|
||||
'entrepreneur': { posts: 25000000, engagement: 2.1 },
|
||||
'marketing': { posts: 20000000, engagement: 1.8 },
|
||||
'business': { posts: 30000000, engagement: 1.5 },
|
||||
'motivation': { posts: 35000000, engagement: 3.2 },
|
||||
'productivity': { posts: 8000000, engagement: 2.8 },
|
||||
'ai': { posts: 5000000, engagement: 4.5 },
|
||||
'growthhacking': { posts: 2000000, engagement: 3.1 },
|
||||
'personalbranding': { posts: 3000000, engagement: 3.4 },
|
||||
'socialmedia': { posts: 12000000, engagement: 2.0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate hashtags for a topic
|
||||
*/
|
||||
generateHashtags(
|
||||
topic: string,
|
||||
platform: string,
|
||||
options?: {
|
||||
count?: number;
|
||||
includeNiche?: boolean;
|
||||
includeTrending?: boolean;
|
||||
includeBranded?: boolean;
|
||||
},
|
||||
): HashtagSet {
|
||||
const maxCount = this.platformLimits[platform] || 10;
|
||||
const count = Math.min(options?.count || maxCount, maxCount);
|
||||
|
||||
const hashtags: HashtagSuggestion[] = [];
|
||||
const topicWords = topic.toLowerCase().split(/\s+/);
|
||||
|
||||
// Generate niche hashtags (specific to topic)
|
||||
if (options?.includeNiche !== false) {
|
||||
const nicheHashtags = this.generateNicheHashtags(topicWords, Math.ceil(count * 0.4));
|
||||
hashtags.push(...nicheHashtags);
|
||||
}
|
||||
|
||||
// Generate trending/popular hashtags
|
||||
if (options?.includeTrending !== false) {
|
||||
const trendingHashtags = this.generateTrendingHashtags(topicWords, Math.ceil(count * 0.3));
|
||||
hashtags.push(...trendingHashtags);
|
||||
}
|
||||
|
||||
// Generate broad/community hashtags
|
||||
const communityHashtags = this.generateCommunityHashtags(topicWords, Math.ceil(count * 0.3));
|
||||
hashtags.push(...communityHashtags);
|
||||
|
||||
// Sort by reach potential and take top N
|
||||
const sortedHashtags = hashtags
|
||||
.sort((a, b) => b.reachPotential - a.reachPotential)
|
||||
.slice(0, count);
|
||||
|
||||
return {
|
||||
topic,
|
||||
platform,
|
||||
hashtags: sortedHashtags,
|
||||
strategy: this.generateStrategy(platform, sortedHashtags),
|
||||
recommendedCount: Math.min(count, maxCount),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a hashtag
|
||||
*/
|
||||
analyzeHashtag(hashtag: string): HashtagAnalysis {
|
||||
const cleanHashtag = hashtag.replace('#', '').toLowerCase();
|
||||
const data = this.hashtagDatabase[cleanHashtag] || { posts: 100000, engagement: 2.0 };
|
||||
|
||||
return {
|
||||
hashtag: `#${cleanHashtag}`,
|
||||
totalPosts: data.posts,
|
||||
avgEngagement: data.engagement,
|
||||
topRelated: this.findRelatedHashtags(cleanHashtag),
|
||||
bestTimeToUse: this.getBestTimeToUse(data.engagement),
|
||||
overused: data.posts > 20000000,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find related hashtags
|
||||
*/
|
||||
findRelatedHashtags(hashtag: string, count: number = 5): string[] {
|
||||
// Mock related hashtags based on common patterns
|
||||
const related: string[] = [];
|
||||
const base = hashtag.replace('#', '').toLowerCase();
|
||||
|
||||
// Add common variations
|
||||
related.push(`#${base}tips`);
|
||||
related.push(`#${base}life`);
|
||||
related.push(`#${base}community`);
|
||||
related.push(`#${base}daily`);
|
||||
related.push(`#${base}goals`);
|
||||
|
||||
return related.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check hashtag performance
|
||||
*/
|
||||
checkPerformance(hashtags: string[]): {
|
||||
hashtag: string;
|
||||
score: number;
|
||||
recommendation: string;
|
||||
}[] {
|
||||
return hashtags.map((hashtag) => {
|
||||
const analysis = this.analyzeHashtag(hashtag);
|
||||
const score = this.calculateHashtagScore(analysis);
|
||||
|
||||
let recommendation = '';
|
||||
if (score >= 80) recommendation = 'Excellent choice';
|
||||
else if (score >= 60) recommendation = 'Good hashtag';
|
||||
else if (score >= 40) recommendation = 'Consider alternatives';
|
||||
else recommendation = 'Not recommended';
|
||||
|
||||
return { hashtag, score, recommendation };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate optimal hashtag strategy
|
||||
*/
|
||||
generateStrategy(platform: string, hashtags: HashtagSuggestion[]): string {
|
||||
const strategies: Record<string, string> = {
|
||||
instagram: `Use ${this.platformLimits.instagram} hashtags: Mix 10 niche, 10 medium, 10 broad. Place in first comment for cleaner look.`,
|
||||
twitter: `Use 1-3 relevant hashtags. Focus on trending topics for visibility.`,
|
||||
linkedin: `Use 3-5 professional hashtags. Industry-specific performs best.`,
|
||||
tiktok: `Use 3-5 hashtags including trending sounds/challenges. Niche > broad.`,
|
||||
youtube: `Use 3-5 hashtags in description. Include 1 branded hashtag.`,
|
||||
facebook: `Minimal hashtags (1-2). Focus on groups and direct engagement.`,
|
||||
threads: `Hashtags have limited impact. Focus on content quality.`,
|
||||
};
|
||||
|
||||
return strategies[platform] || 'Use relevant hashtags based on your content.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trending hashtags
|
||||
*/
|
||||
getTrendingHashtags(
|
||||
category?: string,
|
||||
count: number = 10,
|
||||
): { hashtag: string; growth: number; posts24h: number }[] {
|
||||
// Mock trending hashtags
|
||||
const trending = [
|
||||
{ hashtag: '#AI', growth: 150, posts24h: 50000 },
|
||||
{ hashtag: '#ChatGPT', growth: 120, posts24h: 45000 },
|
||||
{ hashtag: '#ContentCreation', growth: 80, posts24h: 30000 },
|
||||
{ hashtag: '#RemoteWork', growth: 60, posts24h: 25000 },
|
||||
{ hashtag: '#PersonalBranding', growth: 55, posts24h: 20000 },
|
||||
{ hashtag: '#Productivity', growth: 45, posts24h: 18000 },
|
||||
{ hashtag: '#SideHustle', growth: 40, posts24h: 15000 },
|
||||
{ hashtag: '#GrowthMindset', growth: 35, posts24h: 12000 },
|
||||
{ hashtag: '#Entrepreneurship', growth: 30, posts24h: 10000 },
|
||||
{ hashtag: '#DigitalMarketing', growth: 25, posts24h: 8000 },
|
||||
];
|
||||
|
||||
return trending.slice(0, count);
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private generateNicheHashtags(words: string[], count: number): HashtagSuggestion[] {
|
||||
return words.slice(0, count).map((word) => ({
|
||||
hashtag: `#${word}`,
|
||||
type: 'niche' as HashtagType,
|
||||
popularity: 'medium' as const,
|
||||
competition: 'low' as const,
|
||||
reachPotential: 70 + Math.random() * 20,
|
||||
recommended: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private generateTrendingHashtags(words: string[], count: number): HashtagSuggestion[] {
|
||||
const trending = ['tips', 'howto', '2024', 'hacks', 'growth'];
|
||||
return trending.slice(0, count).map((suffix) => ({
|
||||
hashtag: `#${words[0]}${suffix}`,
|
||||
type: 'trending' as HashtagType,
|
||||
popularity: 'high' as const,
|
||||
competition: 'high' as const,
|
||||
reachPotential: 60 + Math.random() * 30,
|
||||
recommended: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private generateCommunityHashtags(words: string[], count: number): HashtagSuggestion[] {
|
||||
const community = ['community', 'life', 'motivation', 'success', 'goals'];
|
||||
return community.slice(0, count).map((suffix) => ({
|
||||
hashtag: `#${words[0]}${suffix}`,
|
||||
type: 'community' as HashtagType,
|
||||
popularity: 'high' as const,
|
||||
competition: 'medium' as const,
|
||||
reachPotential: 50 + Math.random() * 25,
|
||||
recommended: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private getBestTimeToUse(engagement: number): string {
|
||||
if (engagement > 3) return 'Peak hours (9AM-11AM, 6PM-9PM)';
|
||||
if (engagement > 2) return 'Business hours (9AM-5PM)';
|
||||
return 'Anytime';
|
||||
}
|
||||
|
||||
private calculateHashtagScore(analysis: HashtagAnalysis): number {
|
||||
let score = 50;
|
||||
|
||||
// Engagement factor
|
||||
score += analysis.avgEngagement * 10;
|
||||
|
||||
// Overused penalty
|
||||
if (analysis.overused) score -= 20;
|
||||
|
||||
// Sweet spot for posts (not too few, not too many)
|
||||
if (analysis.totalPosts >= 100000 && analysis.totalPosts <= 10000000) {
|
||||
score += 15;
|
||||
}
|
||||
|
||||
return Math.min(100, Math.max(0, Math.round(score)));
|
||||
}
|
||||
}
|
||||
330
src/modules/content-generation/services/niche.service.ts
Normal file
330
src/modules/content-generation/services/niche.service.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
// Niche Service - Niche selection and management
|
||||
// Path: src/modules/content-generation/services/niche.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface Niche {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
category: NicheCategory;
|
||||
description: string;
|
||||
targetAudience: string[];
|
||||
painPoints: string[];
|
||||
keywords: string[];
|
||||
contentAngles: string[];
|
||||
competitors: string[];
|
||||
monetization: string[];
|
||||
difficulty: 'beginner' | 'intermediate' | 'advanced';
|
||||
growthPotential: number; // 1-100
|
||||
engagement: {
|
||||
avgLikes: number;
|
||||
avgComments: number;
|
||||
avgShares: number;
|
||||
};
|
||||
}
|
||||
|
||||
export type NicheCategory =
|
||||
| 'business'
|
||||
| 'finance'
|
||||
| 'health'
|
||||
| 'tech'
|
||||
| 'lifestyle'
|
||||
| 'education'
|
||||
| 'entertainment'
|
||||
| 'creative'
|
||||
| 'personal_development'
|
||||
| 'relationships'
|
||||
| 'career'
|
||||
| 'parenting';
|
||||
|
||||
export interface NicheAnalysis {
|
||||
niche: Niche;
|
||||
saturation: 'low' | 'medium' | 'high';
|
||||
competition: number; // 1-100
|
||||
opportunity: number; // 1-100
|
||||
trendingTopics: string[];
|
||||
contentGaps: string[];
|
||||
recommendedPlatforms: string[];
|
||||
monetizationPotential: number; // 1-100
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NicheService {
|
||||
private readonly logger = new Logger(NicheService.name);
|
||||
|
||||
// Popular niches database
|
||||
private readonly niches: Niche[] = [
|
||||
{
|
||||
id: 'personal-finance',
|
||||
name: 'Personal Finance',
|
||||
slug: 'personal-finance',
|
||||
category: 'finance',
|
||||
description: 'Money management, investing, budgeting, and financial freedom',
|
||||
targetAudience: ['millennials', 'young professionals', 'families', 'retirees'],
|
||||
painPoints: ['debt', 'saving money', 'investing confusion', 'retirement anxiety'],
|
||||
keywords: ['budgeting', 'investing', 'passive income', 'financial freedom', 'side hustle'],
|
||||
contentAngles: ['beginner guides', 'investment strategies', 'debt payoff stories', 'money mistakes'],
|
||||
competitors: ['The Financial Diet', 'Graham Stephan', 'Dave Ramsey'],
|
||||
monetization: ['affiliate marketing', 'courses', 'coaching', 'sponsored content'],
|
||||
difficulty: 'intermediate',
|
||||
growthPotential: 85,
|
||||
engagement: { avgLikes: 500, avgComments: 50, avgShares: 100 },
|
||||
},
|
||||
{
|
||||
id: 'productivity',
|
||||
name: 'Productivity & Time Management',
|
||||
slug: 'productivity',
|
||||
category: 'personal_development',
|
||||
description: 'Work efficiency, habits, systems, and getting more done',
|
||||
targetAudience: ['entrepreneurs', 'remote workers', 'students', 'executives'],
|
||||
painPoints: ['procrastination', 'overwhelm', 'work-life balance', 'focus issues'],
|
||||
keywords: ['productivity tips', 'time management', 'habits', 'morning routine', 'deep work'],
|
||||
contentAngles: ['system breakdowns', 'tool reviews', 'habit building', 'workflow optimization'],
|
||||
competitors: ['Ali Abdaal', 'Thomas Frank', 'Cal Newport'],
|
||||
monetization: ['digital products', 'consulting', 'affiliate marketing', 'memberships'],
|
||||
difficulty: 'intermediate',
|
||||
growthPotential: 80,
|
||||
engagement: { avgLikes: 800, avgComments: 80, avgShares: 200 },
|
||||
},
|
||||
{
|
||||
id: 'ai-tech',
|
||||
name: 'AI & Technology',
|
||||
slug: 'ai-tech',
|
||||
category: 'tech',
|
||||
description: 'Artificial intelligence, automation, and emerging technology',
|
||||
targetAudience: ['tech enthusiasts', 'developers', 'entrepreneurs', 'business owners'],
|
||||
painPoints: ['keeping up with change', 'implementation', 'job automation fears', 'tool overload'],
|
||||
keywords: ['AI tools', 'ChatGPT', 'automation', 'machine learning', 'future of work'],
|
||||
contentAngles: ['tool tutorials', 'trend analysis', 'use cases', 'predictions'],
|
||||
competitors: ['Matt Wolfe', 'Linus Tech Tips', 'Fireship'],
|
||||
monetization: ['sponsorships', 'affiliate marketing', 'consulting', 'SaaS products'],
|
||||
difficulty: 'advanced',
|
||||
growthPotential: 95,
|
||||
engagement: { avgLikes: 1200, avgComments: 150, avgShares: 400 },
|
||||
},
|
||||
{
|
||||
id: 'content-creation',
|
||||
name: 'Content Creation & Social Media',
|
||||
slug: 'content-creation',
|
||||
category: 'creative',
|
||||
description: 'Growing on social media, creating content, and building an audience',
|
||||
targetAudience: ['aspiring creators', 'small businesses', 'influencers', 'marketers'],
|
||||
painPoints: ['algorithm changes', 'consistency', 'monetization', 'growth plateaus'],
|
||||
keywords: ['grow on Instagram', 'content strategy', 'viral content', 'engagement tips'],
|
||||
contentAngles: ['platform strategies', 'case studies', 'tool recommendations', 'growth hacks'],
|
||||
competitors: ['Vanessa Lau', 'Jade Darmawangsa', 'Roberto Blake'],
|
||||
monetization: ['courses', 'coaching', 'agency services', 'brand deals'],
|
||||
difficulty: 'intermediate',
|
||||
growthPotential: 75,
|
||||
engagement: { avgLikes: 600, avgComments: 100, avgShares: 150 },
|
||||
},
|
||||
{
|
||||
id: 'mental-health',
|
||||
name: 'Mental Health & Wellness',
|
||||
slug: 'mental-health',
|
||||
category: 'health',
|
||||
description: 'Mental wellness, anxiety, stress management, and self-care',
|
||||
targetAudience: ['young adults', 'stressed professionals', 'students', 'parents'],
|
||||
painPoints: ['anxiety', 'burnout', 'depression', 'overwhelm', 'self-doubt'],
|
||||
keywords: ['mental health tips', 'anxiety relief', 'self-care', 'therapy', 'mindfulness'],
|
||||
contentAngles: ['personal stories', 'coping strategies', 'professional insights', 'resources'],
|
||||
competitors: ['Therapy in a Nutshell', 'Dr. Julie Smith', 'The Holistic Psychologist'],
|
||||
monetization: ['books', 'courses', 'therapy referrals', 'speaking'],
|
||||
difficulty: 'advanced',
|
||||
growthPotential: 90,
|
||||
engagement: { avgLikes: 2000, avgComments: 300, avgShares: 500 },
|
||||
},
|
||||
{
|
||||
id: 'entrepreneurship',
|
||||
name: 'Entrepreneurship & Startups',
|
||||
slug: 'entrepreneurship',
|
||||
category: 'business',
|
||||
description: 'Starting and growing businesses, startup culture, and business strategy',
|
||||
targetAudience: ['founders', 'aspiring entrepreneurs', 'small business owners', 'investors'],
|
||||
painPoints: ['funding', 'scaling', 'finding customers', 'team building', 'failure fear'],
|
||||
keywords: ['startup tips', 'business ideas', 'entrepreneurship', 'funding', 'growth strategies'],
|
||||
contentAngles: ['founder stories', 'business frameworks', 'failure lessons', 'growth strategies'],
|
||||
competitors: ['GaryVee', 'Alex Hormozi', 'My First Million'],
|
||||
monetization: ['coaching', 'events', 'investments', 'courses'],
|
||||
difficulty: 'advanced',
|
||||
growthPotential: 85,
|
||||
engagement: { avgLikes: 1500, avgComments: 200, avgShares: 350 },
|
||||
},
|
||||
{
|
||||
id: 'fitness',
|
||||
name: 'Fitness & Exercise',
|
||||
slug: 'fitness',
|
||||
category: 'health',
|
||||
description: 'Workouts, gym routines, home fitness, and physical health',
|
||||
targetAudience: ['beginners', 'fitness enthusiasts', 'athletes', 'busy professionals'],
|
||||
painPoints: ['motivation', 'time constraints', 'plateau', 'injuries', 'gym intimidation'],
|
||||
keywords: ['workout routine', 'home workout', 'weight loss', 'muscle building', 'fitness tips'],
|
||||
contentAngles: ['workout demos', 'transformation stories', 'nutrition tips', 'myth busting'],
|
||||
competitors: ['Jeff Nippard', 'Athlean-X', 'Whitney Simmons'],
|
||||
monetization: ['programs', 'supplements', 'apparel', 'coaching'],
|
||||
difficulty: 'beginner',
|
||||
growthPotential: 70,
|
||||
engagement: { avgLikes: 3000, avgComments: 200, avgShares: 400 },
|
||||
},
|
||||
{
|
||||
id: 'parenting',
|
||||
name: 'Parenting & Family',
|
||||
slug: 'parenting',
|
||||
category: 'parenting',
|
||||
description: 'Raising children, family life, and parenting strategies',
|
||||
targetAudience: ['new parents', 'expecting parents', 'parents of teens', 'grandparents'],
|
||||
painPoints: ['sleep deprivation', 'discipline', 'education', 'work-life balance', 'screen time'],
|
||||
keywords: ['parenting tips', 'baby care', 'toddler', 'teen parenting', 'family activities'],
|
||||
contentAngles: ['age-specific tips', 'product reviews', 'real-life stories', 'expert advice'],
|
||||
competitors: ['Janet Lansbury', 'Dr. Becky', 'Big Little Feelings'],
|
||||
monetization: ['affiliate marketing', 'books', 'courses', 'brand partnerships'],
|
||||
difficulty: 'beginner',
|
||||
growthPotential: 75,
|
||||
engagement: { avgLikes: 1000, avgComments: 250, avgShares: 300 },
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Get all niches
|
||||
*/
|
||||
getAllNiches(): Niche[] {
|
||||
return this.niches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get niche by ID or slug
|
||||
*/
|
||||
getNiche(idOrSlug: string): Niche | null {
|
||||
return this.niches.find((n) => n.id === idOrSlug || n.slug === idOrSlug) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get niches by category
|
||||
*/
|
||||
getNichesByCategory(category: NicheCategory): Niche[] {
|
||||
return this.niches.filter((n) => n.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze a niche
|
||||
*/
|
||||
analyzeNiche(nicheId: string): NicheAnalysis | null {
|
||||
const niche = this.getNiche(nicheId);
|
||||
if (!niche) return null;
|
||||
|
||||
const saturation = niche.growthPotential < 60 ? 'high' : niche.growthPotential < 80 ? 'medium' : 'low';
|
||||
const competition = 100 - niche.growthPotential + Math.random() * 20;
|
||||
|
||||
return {
|
||||
niche,
|
||||
saturation,
|
||||
competition: Math.min(100, Math.round(competition)),
|
||||
opportunity: niche.growthPotential,
|
||||
trendingTopics: this.getTrendingTopics(niche),
|
||||
contentGaps: this.findContentGaps(niche),
|
||||
recommendedPlatforms: this.recommendPlatforms(niche),
|
||||
monetizationPotential: this.calculateMonetizationPotential(niche),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommend niches based on interests
|
||||
*/
|
||||
recommendNiches(interests: string[]): Niche[] {
|
||||
const scored = this.niches.map((niche) => {
|
||||
let score = 0;
|
||||
const nicheText = [
|
||||
niche.name,
|
||||
niche.description,
|
||||
...niche.keywords,
|
||||
...niche.contentAngles,
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
for (const interest of interests) {
|
||||
if (nicheText.includes(interest.toLowerCase())) {
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
score += niche.growthPotential / 10;
|
||||
|
||||
return { niche, score };
|
||||
});
|
||||
|
||||
return scored
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, 5)
|
||||
.map((s) => s.niche);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content ideas for a niche
|
||||
*/
|
||||
getContentIdeas(nicheId: string, count: number = 10): string[] {
|
||||
const niche = this.getNiche(nicheId);
|
||||
if (!niche) return [];
|
||||
|
||||
const ideas: string[] = [];
|
||||
const templates = [
|
||||
`${count} things nobody tells you about {keyword}`,
|
||||
`How to {keyword} without {pain_point}`,
|
||||
`Why most people fail at {keyword}`,
|
||||
`The complete guide to {keyword} for beginners`,
|
||||
`{keyword} mistakes I made (so you don't have to)`,
|
||||
`How I {keyword} in {time_period}`,
|
||||
`Stop doing this if you want to {keyword}`,
|
||||
`The truth about {keyword} that experts won't tell you`,
|
||||
`{keyword} tips that actually work in 2024`,
|
||||
`My {keyword} system that changed everything`,
|
||||
];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const template = templates[i % templates.length];
|
||||
const keyword = niche.keywords[i % niche.keywords.length];
|
||||
const painPoint = niche.painPoints[i % niche.painPoints.length];
|
||||
|
||||
let idea = template
|
||||
.replace('{keyword}', keyword)
|
||||
.replace('{pain_point}', painPoint)
|
||||
.replace('{time_period}', '30 days')
|
||||
.replace('{count}', String(Math.floor(Math.random() * 7 + 3)));
|
||||
|
||||
ideas.push(idea);
|
||||
}
|
||||
|
||||
return ideas;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private getTrendingTopics(niche: Niche): string[] {
|
||||
// Mock trending topics
|
||||
return niche.keywords.slice(0, 3).map((k) => `${k} 2024`);
|
||||
}
|
||||
|
||||
private findContentGaps(niche: Niche): string[] {
|
||||
return [
|
||||
`Advanced ${niche.keywords[0]} strategies`,
|
||||
`${niche.name} for complete beginners`,
|
||||
`Common ${niche.keywords[1]} mistakes`,
|
||||
`${niche.name} case studies`,
|
||||
];
|
||||
}
|
||||
|
||||
private recommendPlatforms(niche: Niche): string[] {
|
||||
const platforms: string[] = [];
|
||||
|
||||
if (niche.engagement.avgLikes > 1000) platforms.push('instagram', 'tiktok');
|
||||
if (niche.category === 'business' || niche.category === 'career') platforms.push('linkedin');
|
||||
if (niche.engagement.avgShares > 200) platforms.push('twitter');
|
||||
if (niche.difficulty === 'advanced') platforms.push('youtube');
|
||||
|
||||
return platforms.length > 0 ? platforms : ['instagram', 'twitter'];
|
||||
}
|
||||
|
||||
private calculateMonetizationPotential(niche: Niche): number {
|
||||
const monetizationScore = niche.monetization.length * 15;
|
||||
const difficultyBonus = niche.difficulty === 'advanced' ? 20 : niche.difficulty === 'intermediate' ? 10 : 0;
|
||||
return Math.min(100, monetizationScore + difficultyBonus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
// Platform Generator Service - Platform-specific content generation with AI
|
||||
// Path: src/modules/content-generation/services/platform-generator.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { GeminiService } from '../../gemini/gemini.service';
|
||||
|
||||
export type Platform =
|
||||
| 'twitter'
|
||||
| 'instagram'
|
||||
| 'linkedin'
|
||||
| 'facebook'
|
||||
| 'tiktok'
|
||||
| 'youtube'
|
||||
| 'threads'
|
||||
| 'medium';
|
||||
|
||||
export interface PlatformConfig {
|
||||
platform: Platform;
|
||||
name: string;
|
||||
icon: string;
|
||||
maxCharacters: number;
|
||||
maxHashtags: number;
|
||||
supportsMedia: boolean;
|
||||
mediaTypes: string[];
|
||||
bestPostingTimes: string[];
|
||||
contentFormats: ContentFormat[];
|
||||
tone: string;
|
||||
features: string[];
|
||||
}
|
||||
|
||||
export interface ContentFormat {
|
||||
name: string;
|
||||
description: string;
|
||||
template: string;
|
||||
example: string;
|
||||
}
|
||||
|
||||
export interface GeneratedContent {
|
||||
platform: Platform;
|
||||
format: string;
|
||||
content: string;
|
||||
hashtags: string[];
|
||||
mediaRecommendations: string[];
|
||||
postingRecommendation: string;
|
||||
characterCount: number;
|
||||
isWithinLimit: boolean;
|
||||
variations?: string[];
|
||||
}
|
||||
|
||||
export interface MultiPlatformContent {
|
||||
original: {
|
||||
topic: string;
|
||||
mainMessage: string;
|
||||
};
|
||||
platforms: GeneratedContent[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlatformGeneratorService {
|
||||
private readonly logger = new Logger(PlatformGeneratorService.name);
|
||||
|
||||
constructor(private readonly gemini: GeminiService) { }
|
||||
|
||||
// Platform configurations
|
||||
private readonly platforms: Record<Platform, PlatformConfig> = {
|
||||
twitter: {
|
||||
platform: 'twitter',
|
||||
name: 'Twitter/X',
|
||||
icon: '𝕏',
|
||||
maxCharacters: 280,
|
||||
maxHashtags: 3,
|
||||
supportsMedia: true,
|
||||
mediaTypes: ['image', 'video', 'gif', 'poll'],
|
||||
bestPostingTimes: ['9:00 AM', '12:00 PM', '5:00 PM'],
|
||||
contentFormats: [
|
||||
{
|
||||
name: 'Single Tweet',
|
||||
description: 'Concise, impactful single post',
|
||||
template: '[Hook]\n\n[Main point]\n\n[CTA]',
|
||||
example: 'The best content creators don\'t just post.\n\nThey build systems.\n\nHere\'s how ↓',
|
||||
},
|
||||
{
|
||||
name: 'Thread',
|
||||
description: 'Long-form content in connected tweets',
|
||||
template: '🧵 [Hook - create curiosity]\n\n1/ [First point]\n2/ [Second point]\n...\n\n[Summary + CTA]',
|
||||
example: '🧵 I studied 100 viral threads.\n\nHere\'s what they all have in common:',
|
||||
},
|
||||
{
|
||||
name: 'Quote Tweet',
|
||||
description: 'Commentary on existing content',
|
||||
template: '[Your perspective]\n\n[Quote/retweet]',
|
||||
example: 'This is exactly why I always say:\n\nConsistency beats talent every time.',
|
||||
},
|
||||
],
|
||||
tone: 'conversational, witty, direct',
|
||||
features: ['retweets', 'quote tweets', 'polls', 'spaces'],
|
||||
},
|
||||
|
||||
instagram: {
|
||||
platform: 'instagram',
|
||||
name: 'Instagram',
|
||||
icon: '📸',
|
||||
maxCharacters: 2200,
|
||||
maxHashtags: 30,
|
||||
supportsMedia: true,
|
||||
mediaTypes: ['image', 'carousel', 'reel', 'story'],
|
||||
bestPostingTimes: ['11:00 AM', '2:00 PM', '7:00 PM'],
|
||||
contentFormats: [
|
||||
{
|
||||
name: 'Carousel',
|
||||
description: 'Educational multi-slide content',
|
||||
template: 'Slide 1: [Hook headline]\nSlide 2-9: [Value points]\nSlide 10: [CTA]\n\nCaption: [Hook] + [Context] + [CTA] + [Hashtags]',
|
||||
example: 'Slide 1: 7 Things I Wish I Knew Before Starting\nSlide 2: 1. Your first content will suck...',
|
||||
},
|
||||
{
|
||||
name: 'Reel',
|
||||
description: 'Short-form video content',
|
||||
template: '[Hook - 3 sec] → [Content - 15-30 sec] → [CTA/Loop - 3 sec]',
|
||||
example: 'Hook: "Stop doing this if you want to grow"\nContent: Show the mistake and fix\nCTA: "Follow for more tips"',
|
||||
},
|
||||
{
|
||||
name: 'Single Post',
|
||||
description: 'Static image with caption',
|
||||
template: '[Visual hook]\n\nCaption:\n[Hook line]\n\n[Value/Story]\n\n[CTA]\n\n[Hashtags]',
|
||||
example: 'The difference between struggle and success:\n\nIt\'s not about working harder.\nIt\'s about working smarter.\n\nSave this for later 💾',
|
||||
},
|
||||
],
|
||||
tone: 'visual, aspirational, authentic',
|
||||
features: ['stories', 'reels', 'guides', 'collabs', 'live'],
|
||||
},
|
||||
|
||||
linkedin: {
|
||||
platform: 'linkedin',
|
||||
name: 'LinkedIn',
|
||||
icon: '💼',
|
||||
maxCharacters: 3000,
|
||||
maxHashtags: 5,
|
||||
supportsMedia: true,
|
||||
mediaTypes: ['image', 'video', 'document', 'poll'],
|
||||
bestPostingTimes: ['7:00 AM', '12:00 PM', '5:00 PM'],
|
||||
contentFormats: [
|
||||
{
|
||||
name: 'Story Post',
|
||||
description: 'Personal story with lesson',
|
||||
template: '[Hook - short, powerful]\n\n↓\n\n[Story with struggle]\n[Turning point]\n[Result]\n\n[Key lessons]\n\n[Question for engagement]',
|
||||
example: 'I got fired 6 months ago.\n\n↓\n\nBest thing that ever happened to me.\n\nHere\'s why...',
|
||||
},
|
||||
{
|
||||
name: 'Listicle',
|
||||
description: 'Numbered tips or insights',
|
||||
template: '[Hook]\n\n1. [Point 1]\n2. [Point 2]\n3. [Point 3]\n...\n\n[Summary]\n[CTA]',
|
||||
example: '5 things successful leaders do differently:\n\n1. They listen more than they talk...',
|
||||
},
|
||||
{
|
||||
name: 'Contrarian Take',
|
||||
description: 'Against-the-grain perspective',
|
||||
template: '[Unpopular opinion]\n\n[Your reasoning]\n\n[Evidence/experience]\n\n[Conclusion]\n\n[Discussion prompt]',
|
||||
example: 'Controversial take:\n\nHustle culture is killing creativity.\n\nHere\'s what I mean...',
|
||||
},
|
||||
],
|
||||
tone: 'professional, thoughtful, authentic',
|
||||
features: ['articles', 'newsletters', 'polls', 'events'],
|
||||
},
|
||||
|
||||
facebook: {
|
||||
platform: 'facebook',
|
||||
name: 'Facebook',
|
||||
icon: '📘',
|
||||
maxCharacters: 63206,
|
||||
maxHashtags: 3,
|
||||
supportsMedia: true,
|
||||
mediaTypes: ['image', 'video', 'live', 'story', 'reel'],
|
||||
bestPostingTimes: ['1:00 PM', '4:00 PM', '8:00 PM'],
|
||||
contentFormats: [
|
||||
{
|
||||
name: 'Community Post',
|
||||
description: 'Engagement-focused discussion',
|
||||
template: '[Question or discussion starter]\n\n[Context]\n\n[Call for opinions]',
|
||||
example: 'Quick question for parents:\n\nHow do you handle screen time with your kids?\n\nI\'m curious what works for you 👇',
|
||||
},
|
||||
{
|
||||
name: 'Story Share',
|
||||
description: 'Personal or customer story',
|
||||
template: '[Hook]\n\n[Background]\n[Challenge]\n[Solution/Outcome]\n\n[Takeaway]',
|
||||
example: 'This message made my day ❤️\n\nA customer just sent me this...',
|
||||
},
|
||||
],
|
||||
tone: 'friendly, conversational, community-focused',
|
||||
features: ['groups', 'events', 'marketplace', 'reels'],
|
||||
},
|
||||
|
||||
tiktok: {
|
||||
platform: 'tiktok',
|
||||
name: 'TikTok',
|
||||
icon: '🎵',
|
||||
maxCharacters: 2200,
|
||||
maxHashtags: 5,
|
||||
supportsMedia: true,
|
||||
mediaTypes: ['video', 'live', 'story'],
|
||||
bestPostingTimes: ['7:00 AM', '12:00 PM', '7:00 PM'],
|
||||
contentFormats: [
|
||||
{
|
||||
name: 'Tutorial',
|
||||
description: 'How-to video content',
|
||||
template: '[Hook - 1-3 sec] → [Steps - fast paced] → [Result/CTA]',
|
||||
example: 'POV: You finally learn this trick\n*shows quick tutorial*\nFollow for more!',
|
||||
},
|
||||
{
|
||||
name: 'Storytime',
|
||||
description: 'Narrative content',
|
||||
template: '[Hook that creates curiosity] → [Story with pacing] → [Payoff]',
|
||||
example: 'So this happened at work today...\n*tells story with dramatic pauses*',
|
||||
},
|
||||
{
|
||||
name: 'Trend',
|
||||
description: 'Trend participation',
|
||||
template: '[Trending sound/format] + [Your niche twist]',
|
||||
example: '*Uses trending sound but applies it to your industry*',
|
||||
},
|
||||
],
|
||||
tone: 'casual, entertaining, authentic',
|
||||
features: ['duets', 'stitches', 'effects', 'live'],
|
||||
},
|
||||
|
||||
youtube: {
|
||||
platform: 'youtube',
|
||||
name: 'YouTube',
|
||||
icon: '▶️',
|
||||
maxCharacters: 5000,
|
||||
maxHashtags: 15,
|
||||
supportsMedia: true,
|
||||
mediaTypes: ['video', 'short', 'live', 'community'],
|
||||
bestPostingTimes: ['2:00 PM', '4:00 PM', '6:00 PM'],
|
||||
contentFormats: [
|
||||
{
|
||||
name: 'Long-form Video',
|
||||
description: 'In-depth content (8-20 min)',
|
||||
template: 'Hook (0-30s) → Intro (30s-1m) → Content (main body) → CTA (last 30s)',
|
||||
example: 'Title: How I Grew From 0 to 100K Subscribers\nIntro: "In this video, I\'ll show you exactly..."',
|
||||
},
|
||||
{
|
||||
name: 'Short',
|
||||
description: 'Vertical short-form (< 60s)',
|
||||
template: '[Hook - 1s] → [Value - 50s] → [CTA - 9s]',
|
||||
example: '*Quick tip or fact* → "Subscribe for more!"',
|
||||
},
|
||||
],
|
||||
tone: 'educational, entertaining, personality-driven',
|
||||
features: ['community posts', 'playlists', 'premieres', 'cards'],
|
||||
},
|
||||
|
||||
threads: {
|
||||
platform: 'threads',
|
||||
name: 'Threads',
|
||||
icon: '🧵',
|
||||
maxCharacters: 500,
|
||||
maxHashtags: 0,
|
||||
supportsMedia: true,
|
||||
mediaTypes: ['image', 'video', 'carousel'],
|
||||
bestPostingTimes: ['9:00 AM', '1:00 PM', '6:00 PM'],
|
||||
contentFormats: [
|
||||
{
|
||||
name: 'Hot Take',
|
||||
description: 'Opinion or observation',
|
||||
template: '[Strong opinion]\n\n[Brief explanation]',
|
||||
example: 'Hot take: Most productivity advice is just procrastination in disguise.',
|
||||
},
|
||||
{
|
||||
name: 'Conversation Starter',
|
||||
description: 'Discussion prompt',
|
||||
template: '[Relatable observation or question]\n\n[Optional context]',
|
||||
example: 'What\'s one thing you wish you learned earlier in your career?',
|
||||
},
|
||||
],
|
||||
tone: 'casual, conversational, text-first',
|
||||
features: ['reposts', 'quotes', 'follows from Instagram'],
|
||||
},
|
||||
|
||||
medium: {
|
||||
platform: 'medium', // Note: You might need to add 'medium' to Platform type definition if strict
|
||||
name: 'Medium',
|
||||
icon: '📝',
|
||||
maxCharacters: 20000,
|
||||
maxHashtags: 5,
|
||||
supportsMedia: true,
|
||||
mediaTypes: ['image', 'embed'],
|
||||
bestPostingTimes: ['8:00 AM', '10:00 AM'],
|
||||
contentFormats: [
|
||||
{
|
||||
name: 'Blog Post',
|
||||
description: 'Long-form article',
|
||||
template: '# [Title]\n\n## Introduction\n[Hook]\n\n## Main Point 1\n[Content]\n\n## Main Point 2\n[Content]\n\n## Conclusion\n[Summary + CTA]',
|
||||
example: '# The Future of AI\n\n## Introduction\nAI is changing everything...',
|
||||
}
|
||||
],
|
||||
tone: 'professional, informative, storytelling',
|
||||
features: ['publications', 'newsletters'],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get platform configuration
|
||||
*/
|
||||
getPlatformConfig(platform: Platform): PlatformConfig {
|
||||
return this.platforms[platform];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all platform configurations
|
||||
*/
|
||||
getAllPlatforms(): PlatformConfig[] {
|
||||
return Object.values(this.platforms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content for a specific platform
|
||||
*/
|
||||
generateForPlatform(
|
||||
platform: Platform,
|
||||
input: {
|
||||
topic: string;
|
||||
mainMessage: string;
|
||||
format?: string;
|
||||
includeHashtags?: boolean;
|
||||
},
|
||||
): GeneratedContent {
|
||||
const config = this.platforms[platform];
|
||||
const format = input.format || config.contentFormats[0].name;
|
||||
const formatConfig = config.contentFormats.find((f) => f.name === format) || config.contentFormats[0];
|
||||
|
||||
// Generate content based on template
|
||||
const content = this.generateContent(input.topic, input.mainMessage, formatConfig, config);
|
||||
const hashtags = input.includeHashtags !== false ? this.generateHashtags(input.topic, config.maxHashtags) : [];
|
||||
|
||||
return {
|
||||
platform,
|
||||
format: formatConfig.name,
|
||||
content,
|
||||
hashtags,
|
||||
mediaRecommendations: this.getMediaRecommendations(platform, formatConfig.name),
|
||||
postingRecommendation: `Best times: ${config.bestPostingTimes.join(', ')}`,
|
||||
characterCount: content.length,
|
||||
isWithinLimit: content.length <= config.maxCharacters,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content for multiple platforms
|
||||
*/
|
||||
generateMultiPlatform(input: {
|
||||
topic: string;
|
||||
mainMessage: string;
|
||||
platforms: Platform[];
|
||||
}): MultiPlatformContent {
|
||||
const platforms = input.platforms.map((p) =>
|
||||
this.generateForPlatform(p, {
|
||||
topic: input.topic,
|
||||
mainMessage: input.mainMessage,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
original: {
|
||||
topic: input.topic,
|
||||
mainMessage: input.mainMessage,
|
||||
},
|
||||
platforms,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt content from one platform to another
|
||||
*/
|
||||
adaptContent(
|
||||
content: string,
|
||||
fromPlatform: Platform,
|
||||
toPlatform: Platform,
|
||||
): GeneratedContent {
|
||||
const toConfig = this.platforms[toPlatform];
|
||||
let adapted = content;
|
||||
|
||||
// Shorten if needed
|
||||
if (adapted.length > toConfig.maxCharacters) {
|
||||
adapted = adapted.substring(0, toConfig.maxCharacters - 3) + '...';
|
||||
}
|
||||
|
||||
// Adjust tone/style based on platform
|
||||
adapted = this.adjustTone(adapted, toPlatform);
|
||||
|
||||
return {
|
||||
platform: toPlatform,
|
||||
format: 'adapted',
|
||||
content: adapted,
|
||||
hashtags: [],
|
||||
mediaRecommendations: this.getMediaRecommendations(toPlatform, 'adapted'),
|
||||
postingRecommendation: `Best times: ${toConfig.bestPostingTimes.join(', ')}`,
|
||||
characterCount: adapted.length,
|
||||
isWithinLimit: adapted.length <= toConfig.maxCharacters,
|
||||
};
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private generateContent(
|
||||
topic: string,
|
||||
mainMessage: string,
|
||||
format: ContentFormat,
|
||||
config: PlatformConfig,
|
||||
): string {
|
||||
// Fallback template-based content (used when AI is not available)
|
||||
return this.generateTemplateContent(topic, mainMessage, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI-powered content using Gemini
|
||||
*/
|
||||
async generateAIContent(
|
||||
topic: string,
|
||||
mainMessage: string,
|
||||
platform: Platform,
|
||||
format: string,
|
||||
language: string = 'tr',
|
||||
): Promise<string> {
|
||||
const config = this.platforms[platform];
|
||||
|
||||
if (!this.gemini.isAvailable()) {
|
||||
this.logger.warn('Gemini not available, using template fallback');
|
||||
return this.generateTemplateContent(topic, mainMessage, config);
|
||||
}
|
||||
|
||||
const prompt = `Sen profesyonel bir sosyal medya içerik uzmanısın.
|
||||
|
||||
PLATFORM: ${config.name}
|
||||
KONU: ${topic}
|
||||
ANA MESAJ: ${mainMessage}
|
||||
FORMAT: ${format}
|
||||
KARAKTER LİMİTİ: ${config.maxCharacters}
|
||||
MAX HASHTAG: ${config.maxHashtags}
|
||||
TON: ${config.tone}
|
||||
|
||||
Bu platform için özgün, ilgi çekici ve viral potansiyeli yüksek bir içerik oluştur.
|
||||
|
||||
KURALLAR:
|
||||
1. Karakter limitine uy
|
||||
2. Platformun tonuna uygun yaz
|
||||
3. Hook (dikkat çeken giriş) ile başla
|
||||
4. CTA (harekete geçirici) ile bitir
|
||||
5. Emoji kullan ama aşırıya kaçma
|
||||
6. ${language === 'tr' ? 'Türkçe' : 'İngilizce'} yaz
|
||||
|
||||
SADECE içeriği yaz, açıklama veya başlık ekleme.`;
|
||||
|
||||
try {
|
||||
const response = await this.gemini.generateText(prompt, {
|
||||
temperature: 0.8,
|
||||
maxTokens: 1000,
|
||||
});
|
||||
return response.text;
|
||||
} catch (error) {
|
||||
this.logger.error(`AI content generation failed: ${error.message}`);
|
||||
return this.generateTemplateContent(topic, mainMessage, config);
|
||||
}
|
||||
}
|
||||
|
||||
private generateTemplateContent(
|
||||
topic: string,
|
||||
mainMessage: string,
|
||||
config: PlatformConfig,
|
||||
): string {
|
||||
let content = '';
|
||||
|
||||
switch (config.platform) {
|
||||
case 'twitter':
|
||||
content = `${mainMessage}\n\nLearn more about ${topic} 👇`;
|
||||
break;
|
||||
case 'instagram':
|
||||
content = `${mainMessage}\n\n·\n·\n·\n\n💡 Save this for later!\n\nWant more ${topic} tips? Follow @yourhandle`;
|
||||
break;
|
||||
case 'linkedin':
|
||||
content = `${mainMessage}\n\n↓\n\nHere's what I've learned about ${topic}:\n\n1. Start small\n2. Be consistent\n3. Learn from feedback\n\nWhat's your experience with ${topic}?\n\nShare in the comments 👇`;
|
||||
break;
|
||||
case 'tiktok':
|
||||
content = `POV: You finally understand ${topic}\n\n${mainMessage}\n\nFollow for more tips! 🎯`;
|
||||
break;
|
||||
case 'youtube':
|
||||
content = `📺 ${topic.toUpperCase()}\n\n${mainMessage}\n\n⏱️ Timestamps:\n0:00 Intro\n1:00 Main content\n\n🔔 Subscribe for more!`;
|
||||
break;
|
||||
case 'threads':
|
||||
content = `${mainMessage}`;
|
||||
break;
|
||||
default:
|
||||
content = mainMessage;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private generateHashtags(topic: string, maxCount: number): string[] {
|
||||
const words = topic.toLowerCase().split(' ').filter((w) => w.length > 3);
|
||||
const hashtags = words.slice(0, maxCount).map((w) => `#${w}`);
|
||||
return hashtags;
|
||||
}
|
||||
|
||||
private getMediaRecommendations(platform: Platform, format: string): string[] {
|
||||
const recommendations: Record<Platform, string[]> = {
|
||||
twitter: ['Add an image for 2x engagement', 'Consider a poll for interaction'],
|
||||
instagram: ['Use high-quality visuals', 'Add text overlays for accessibility', 'Use trending audio for Reels'],
|
||||
linkedin: ['Add a relevant image', 'Consider a carousel document', 'Include data visualizations'],
|
||||
facebook: ['Include a video if possible', 'Use native video over links'],
|
||||
tiktok: ['Use trending sounds', 'Add captions for accessibility', 'Film vertically'],
|
||||
youtube: ['Create an attention-grabbing thumbnail', 'Add end screens', 'Include cards for related videos'],
|
||||
threads: ['Keep it text-focused', 'Add one image max'],
|
||||
medium: ['Use high-quality header image', 'Break text with images/embeds', 'Use proper H1/H2 tags'],
|
||||
};
|
||||
|
||||
return recommendations[platform] || [];
|
||||
}
|
||||
|
||||
private adjustTone(content: string, platform: Platform): string {
|
||||
// Simple tone adjustments
|
||||
switch (platform) {
|
||||
case 'linkedin':
|
||||
return content.replace(/lol|haha|😂/gi, '');
|
||||
case 'tiktok':
|
||||
return content.replace(/Furthermore|Moreover|Additionally/gi, 'Also');
|
||||
default:
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
399
src/modules/content-generation/services/variation.service.ts
Normal file
399
src/modules/content-generation/services/variation.service.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
// Variation Service - Generate content variations
|
||||
// Path: src/modules/content-generation/services/variation.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface VariationOptions {
|
||||
count?: number;
|
||||
variationType?: VariationType;
|
||||
preserveCore?: boolean;
|
||||
targetLength?: 'shorter' | 'same' | 'longer';
|
||||
toneShift?: string;
|
||||
}
|
||||
|
||||
export type VariationType =
|
||||
| 'hook' // Different hooks/openings
|
||||
| 'angle' // Different perspective/angle
|
||||
| 'tone' // Same content, different tone
|
||||
| 'format' // Different structure
|
||||
| 'length' // Shorter/longer versions
|
||||
| 'platform' // Platform-adapted versions
|
||||
| 'complete'; // Full rewrites
|
||||
|
||||
export interface ContentVariation {
|
||||
id: string;
|
||||
type: VariationType;
|
||||
content: string;
|
||||
changes: string[];
|
||||
similarity: number; // 0-100 (0 = completely different)
|
||||
}
|
||||
|
||||
export interface VariationSet {
|
||||
original: string;
|
||||
variations: ContentVariation[];
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class VariationService {
|
||||
private readonly logger = new Logger(VariationService.name);
|
||||
|
||||
// Hook variations
|
||||
private readonly hookTemplates = {
|
||||
question: (topic: string) => `Ever wondered why ${topic}?`,
|
||||
bold: (topic: string) => `Here's the truth about ${topic}:`,
|
||||
story: (topic: string) => `Last year, I learned something about ${topic} that changed everything.`,
|
||||
statistic: (topic: string) => `73% of people get ${topic} wrong. Here's what I discovered:`,
|
||||
contrarian: (topic: string) => `Everything you've been told about ${topic} is wrong.`,
|
||||
pain: (topic: string) => `Struggling with ${topic}? You're not alone.`,
|
||||
promise: (topic: string) => `What if you could master ${topic} in just 30 days?`,
|
||||
curiosity: (topic: string) => `The ${topic} secret nobody talks about:`,
|
||||
};
|
||||
|
||||
// Tone modifiers
|
||||
private readonly toneModifiers: Record<string, (text: string) => string> = {
|
||||
professional: (text) => text.replace(/!/g, '.').replace(/lol|haha|😂/gi, ''),
|
||||
casual: (text) => text.replace(/Furthermore,/g, 'Also,').replace(/However,/g, 'But'),
|
||||
enthusiastic: (text) => text.replace(/\./g, '!').replace(/good/gi, 'amazing'),
|
||||
empathetic: (text) => `I understand how challenging this can be. ${text}`,
|
||||
urgent: (text) => `Don't wait. ${text} Time is running out.`,
|
||||
humorous: (text) => text + ' (And yes, I learned this the hard way 😅)',
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate variations of content
|
||||
*/
|
||||
generateVariations(
|
||||
content: string,
|
||||
options: VariationOptions = {},
|
||||
): VariationSet {
|
||||
const { count = 3, variationType = 'complete', preserveCore = true } = options;
|
||||
const variations: ContentVariation[] = [];
|
||||
|
||||
switch (variationType) {
|
||||
case 'hook':
|
||||
variations.push(...this.generateHookVariations(content, count));
|
||||
break;
|
||||
case 'angle':
|
||||
variations.push(...this.generateAngleVariations(content, count));
|
||||
break;
|
||||
case 'tone':
|
||||
variations.push(...this.generateToneVariations(content, count, options.toneShift));
|
||||
break;
|
||||
case 'format':
|
||||
variations.push(...this.generateFormatVariations(content, count));
|
||||
break;
|
||||
case 'length':
|
||||
variations.push(...this.generateLengthVariations(content, options.targetLength || 'same'));
|
||||
break;
|
||||
case 'complete':
|
||||
default:
|
||||
variations.push(...this.generateCompleteVariations(content, count, preserveCore));
|
||||
}
|
||||
|
||||
return {
|
||||
original: content,
|
||||
variations: variations.slice(0, count),
|
||||
recommendation: this.getRecommendation(variations),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A/B test variations
|
||||
*/
|
||||
createABTest(content: string): {
|
||||
original: string;
|
||||
variationA: ContentVariation;
|
||||
variationB: ContentVariation;
|
||||
testingTips: string[];
|
||||
} {
|
||||
const hookVar = this.generateHookVariations(content, 1)[0];
|
||||
const formatVar = this.generateFormatVariations(content, 1)[0];
|
||||
|
||||
return {
|
||||
original: content,
|
||||
variationA: hookVar,
|
||||
variationB: formatVar,
|
||||
testingTips: [
|
||||
'Test at the same time of day',
|
||||
'Use similar audience targeting',
|
||||
'Run for at least 24-48 hours',
|
||||
'Look at engagement rate, not just likes',
|
||||
'Track save rate as quality indicator',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hook-only variations
|
||||
*/
|
||||
private generateHookVariations(content: string, count: number): ContentVariation[] {
|
||||
const topic = this.extractTopic(content);
|
||||
const hookTypes = Object.keys(this.hookTemplates);
|
||||
const variations: ContentVariation[] = [];
|
||||
|
||||
for (let i = 0; i < Math.min(count, hookTypes.length); i++) {
|
||||
const hookType = hookTypes[i] as keyof typeof this.hookTemplates;
|
||||
const newHook = this.hookTemplates[hookType](topic);
|
||||
|
||||
// Replace first line with new hook
|
||||
const lines = content.split('\n');
|
||||
const newContent = [newHook, ...lines.slice(1)].join('\n');
|
||||
|
||||
variations.push({
|
||||
id: `hook-${i + 1}`,
|
||||
type: 'hook',
|
||||
content: newContent,
|
||||
changes: [`Changed hook to ${hookType} style`],
|
||||
similarity: 85,
|
||||
});
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate angle variations
|
||||
*/
|
||||
private generateAngleVariations(content: string, count: number): ContentVariation[] {
|
||||
const angles = [
|
||||
{ name: 'how-to', prefix: 'Here\'s how to', focus: 'actionable steps' },
|
||||
{ name: 'why', prefix: 'This is why', focus: 'reasoning and benefits' },
|
||||
{ name: 'what-if', prefix: 'What if you could', focus: 'possibilities' },
|
||||
{ name: 'mistake', prefix: 'The biggest mistake people make with', focus: 'what not to do' },
|
||||
{ name: 'story', prefix: 'When I first started with', focus: 'personal experience' },
|
||||
];
|
||||
|
||||
const topic = this.extractTopic(content);
|
||||
const variations: ContentVariation[] = [];
|
||||
|
||||
for (let i = 0; i < Math.min(count, angles.length); i++) {
|
||||
const angle = angles[i];
|
||||
variations.push({
|
||||
id: `angle-${i + 1}`,
|
||||
type: 'angle',
|
||||
content: `${angle.prefix} ${topic}:\n\n${this.keepCore(content)}\n\nFocusing on: ${angle.focus}`,
|
||||
changes: [`Shifted to ${angle.name} angle`, `Focus: ${angle.focus}`],
|
||||
similarity: 70,
|
||||
});
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate tone variations
|
||||
*/
|
||||
private generateToneVariations(
|
||||
content: string,
|
||||
count: number,
|
||||
specificTone?: string,
|
||||
): ContentVariation[] {
|
||||
const tones = specificTone
|
||||
? [specificTone]
|
||||
: Object.keys(this.toneModifiers).slice(0, count);
|
||||
|
||||
return tones.map((tone, i) => {
|
||||
const modifier = this.toneModifiers[tone];
|
||||
const modified = modifier ? modifier(content) : content;
|
||||
|
||||
return {
|
||||
id: `tone-${i + 1}`,
|
||||
type: 'tone',
|
||||
content: modified,
|
||||
changes: [`Applied ${tone} tone`],
|
||||
similarity: 90,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate format variations
|
||||
*/
|
||||
private generateFormatVariations(content: string, count: number): ContentVariation[] {
|
||||
const variations: ContentVariation[] = [];
|
||||
const points = this.extractPoints(content);
|
||||
|
||||
// Listicle format
|
||||
if (count >= 1) {
|
||||
variations.push({
|
||||
id: 'format-list',
|
||||
type: 'format',
|
||||
content: this.toListFormat(points),
|
||||
changes: ['Converted to numbered list'],
|
||||
similarity: 80,
|
||||
});
|
||||
}
|
||||
|
||||
// Bullet format
|
||||
if (count >= 2) {
|
||||
variations.push({
|
||||
id: 'format-bullets',
|
||||
type: 'format',
|
||||
content: this.toBulletFormat(points),
|
||||
changes: ['Converted to bullet points'],
|
||||
similarity: 80,
|
||||
});
|
||||
}
|
||||
|
||||
// Thread format
|
||||
if (count >= 3) {
|
||||
variations.push({
|
||||
id: 'format-thread',
|
||||
type: 'format',
|
||||
content: this.toThreadFormat(points),
|
||||
changes: ['Converted to thread format'],
|
||||
similarity: 75,
|
||||
});
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate length variations
|
||||
*/
|
||||
private generateLengthVariations(
|
||||
content: string,
|
||||
target: 'shorter' | 'same' | 'longer',
|
||||
): ContentVariation[] {
|
||||
const variations: ContentVariation[] = [];
|
||||
|
||||
if (target === 'shorter' || target === 'same') {
|
||||
variations.push({
|
||||
id: 'length-short',
|
||||
type: 'length',
|
||||
content: this.shorten(content),
|
||||
changes: ['Condensed to key points'],
|
||||
similarity: 75,
|
||||
});
|
||||
}
|
||||
|
||||
if (target === 'longer' || target === 'same') {
|
||||
variations.push({
|
||||
id: 'length-long',
|
||||
type: 'length',
|
||||
content: this.lengthen(content),
|
||||
changes: ['Expanded with more detail'],
|
||||
similarity: 70,
|
||||
});
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate complete rewrites
|
||||
*/
|
||||
private generateCompleteVariations(
|
||||
content: string,
|
||||
count: number,
|
||||
preserveCore: boolean,
|
||||
): ContentVariation[] {
|
||||
const variations: ContentVariation[] = [];
|
||||
const topic = this.extractTopic(content);
|
||||
const core = this.keepCore(content);
|
||||
|
||||
// Variation 1: Different angle + hook
|
||||
variations.push({
|
||||
id: 'complete-1',
|
||||
type: 'complete',
|
||||
content: `The truth about ${topic}:\n\n${preserveCore ? core : this.rewriteCore(core)}\n\nSave this for later.`,
|
||||
changes: ['New hook', 'New CTA', preserveCore ? 'Preserved core' : 'Rewrote core'],
|
||||
similarity: preserveCore ? 60 : 40,
|
||||
});
|
||||
|
||||
// Variation 2: Story-based
|
||||
if (count >= 2) {
|
||||
variations.push({
|
||||
id: 'complete-2',
|
||||
type: 'complete',
|
||||
content: `I wish someone told me this about ${topic} earlier:\n\n${preserveCore ? core : this.rewriteCore(core)}\n\nShare if this helped.`,
|
||||
changes: ['Story-based approach', 'Personal angle'],
|
||||
similarity: preserveCore ? 55 : 35,
|
||||
});
|
||||
}
|
||||
|
||||
// Variation 3: Direct/Bold
|
||||
if (count >= 3) {
|
||||
variations.push({
|
||||
id: 'complete-3',
|
||||
type: 'complete',
|
||||
content: `Stop what you're doing.\n\nThis ${topic} insight is too important:\n\n${preserveCore ? core : this.rewriteCore(core)}\n\nYou're welcome.`,
|
||||
changes: ['Bold/direct style', 'Urgency added'],
|
||||
similarity: preserveCore ? 50 : 30,
|
||||
});
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
private extractTopic(content: string): string {
|
||||
const firstLine = content.split('\n')[0];
|
||||
// Simple topic extraction from first line
|
||||
return firstLine.replace(/[#:?!.]/g, '').trim().substring(0, 50);
|
||||
}
|
||||
|
||||
private extractPoints(content: string): string[] {
|
||||
const lines = content.split('\n').filter((line) => line.trim().length > 0);
|
||||
return lines.slice(1, -1); // Remove first (hook) and last (CTA)
|
||||
}
|
||||
|
||||
private keepCore(content: string): string {
|
||||
const lines = content.split('\n').filter((line) => line.trim().length > 0);
|
||||
return lines.slice(1, -1).join('\n'); // Middle content
|
||||
}
|
||||
|
||||
private rewriteCore(core: string): string {
|
||||
// Simple word substitutions for demo
|
||||
return core
|
||||
.replace(/important/gi, 'crucial')
|
||||
.replace(/great/gi, 'excellent')
|
||||
.replace(/good/gi, 'solid')
|
||||
.replace(/need to/gi, 'must')
|
||||
.replace(/should/gi, 'need to');
|
||||
}
|
||||
|
||||
private toListFormat(points: string[]): string {
|
||||
return points.map((p, i) => `${i + 1}. ${p.replace(/^[-•*]\s*/, '')}`).join('\n');
|
||||
}
|
||||
|
||||
private toBulletFormat(points: string[]): string {
|
||||
return points.map((p) => `• ${p.replace(/^[-•*\d.]\s*/, '')}`).join('\n');
|
||||
}
|
||||
|
||||
private toThreadFormat(points: string[]): string {
|
||||
return points.map((p, i) => `${i + 1}/ ${p.replace(/^[-•*\d.]\s*/, '')}`).join('\n\n');
|
||||
}
|
||||
|
||||
private shorten(content: string): string {
|
||||
const lines = content.split('\n').filter((l) => l.trim());
|
||||
// Keep first, last, and every other middle line
|
||||
const shortened = [
|
||||
lines[0],
|
||||
...lines.slice(1, -1).filter((_, i) => i % 2 === 0),
|
||||
lines[lines.length - 1],
|
||||
];
|
||||
return shortened.join('\n\n');
|
||||
}
|
||||
|
||||
private lengthen(content: string): string {
|
||||
return content + '\n\n' +
|
||||
'📌 Key takeaway: Apply these insights consistently for best results.\n\n' +
|
||||
'💡 Pro tip: Start small and build momentum over time.\n\n' +
|
||||
'🎯 Action item: Pick one thing from this post and implement it today.';
|
||||
}
|
||||
|
||||
private getRecommendation(variations: ContentVariation[]): string {
|
||||
if (variations.length === 0) return 'No variations generated';
|
||||
|
||||
const highVariety = variations.filter((v) => v.similarity < 60);
|
||||
if (highVariety.length > 0) {
|
||||
return `Variation ${highVariety[0].id} offers the most differentiation (${100 - highVariety[0].similarity}% unique)`;
|
||||
}
|
||||
|
||||
return `All variations maintain strong similarity to original. Consider testing ${variations[0].id}.`;
|
||||
}
|
||||
}
|
||||
256
src/modules/content/content.controller.ts
Normal file
256
src/modules/content/content.controller.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
// Content Controller - API endpoints for content management
|
||||
// Path: src/modules/content/content.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
ParseUUIDPipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
|
||||
import { ContentService } from './content.service';
|
||||
import { MasterContentService } from './services/master-content.service';
|
||||
import type { CreateMasterContentDto } from './services/master-content.service';
|
||||
import { BuildingBlocksService } from './services/building-blocks.service';
|
||||
import { WritingStylesService } from './services/writing-styles.service';
|
||||
import type { WritingStyleConfig } from './services/writing-styles.service';
|
||||
import { ContentVariationsService } from './services/content-variations.service';
|
||||
import type { VariationConfig } from './services/content-variations.service';
|
||||
import { PlatformAdaptersService } from './services/platform-adapters.service';
|
||||
import { CurrentUser } from '../../common/decorators';
|
||||
import { SocialPlatform, ContentStatus, MasterContentType } from '@prisma/client';
|
||||
|
||||
@ApiTags('content')
|
||||
@ApiBearerAuth()
|
||||
@Controller('content')
|
||||
export class ContentController {
|
||||
constructor(
|
||||
private readonly contentService: ContentService,
|
||||
private readonly masterContentService: MasterContentService,
|
||||
private readonly buildingBlocksService: BuildingBlocksService,
|
||||
private readonly writingStylesService: WritingStylesService,
|
||||
private readonly variationsService: ContentVariationsService,
|
||||
private readonly platformsService: PlatformAdaptersService,
|
||||
) { }
|
||||
|
||||
// ==================== Master Content ====================
|
||||
|
||||
@Post('master')
|
||||
@ApiOperation({ summary: 'Create master content' })
|
||||
async createMaster(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: CreateMasterContentDto,
|
||||
) {
|
||||
return this.masterContentService.create(userId, dto);
|
||||
}
|
||||
|
||||
@Get('master')
|
||||
@ApiOperation({ summary: 'Get user master contents' })
|
||||
@ApiQuery({ name: 'type', required: false, enum: MasterContentType })
|
||||
async getMasterContents(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('type') type?: MasterContentType,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('offset') offset?: number,
|
||||
) {
|
||||
return this.masterContentService.getByUser(userId, {
|
||||
type,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('master/:id')
|
||||
@ApiOperation({ summary: 'Get master content by ID' })
|
||||
async getMasterById(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.masterContentService.getById(id);
|
||||
}
|
||||
|
||||
@Post('master/:id/repurpose')
|
||||
@ApiOperation({ summary: 'Repurpose master content to multiple platforms' })
|
||||
async repurposeMaster(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('platforms') platforms: string[],
|
||||
) {
|
||||
return this.masterContentService.repurpose(id, platforms);
|
||||
}
|
||||
|
||||
// ==================== Platform Content ====================
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create platform-specific content' })
|
||||
async createContent(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() dto: { masterContentId: string; platform: SocialPlatform; body?: string; scheduledAt?: string },
|
||||
) {
|
||||
return this.contentService.createFromMaster(userId, {
|
||||
...dto,
|
||||
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get user contents' })
|
||||
@ApiQuery({ name: 'platform', required: false, enum: SocialPlatform })
|
||||
@ApiQuery({ name: 'status', required: false, enum: ContentStatus })
|
||||
async getContents(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('platform') platform?: SocialPlatform,
|
||||
@Query('status') status?: ContentStatus,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('offset') offset?: number,
|
||||
) {
|
||||
return this.contentService.getByUser(userId, {
|
||||
platform,
|
||||
status,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('calendar')
|
||||
@ApiOperation({ summary: 'Get content calendar' })
|
||||
async getCalendar(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('start') start: string,
|
||||
@Query('end') end: string,
|
||||
) {
|
||||
return this.contentService.getCalendar(userId, new Date(start), new Date(end));
|
||||
}
|
||||
|
||||
@Get('analytics')
|
||||
@ApiOperation({ summary: 'Get content analytics' })
|
||||
async getAnalytics(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('period') period: 'week' | 'month' | 'year' = 'month',
|
||||
) {
|
||||
return this.contentService.getAnalytics(userId, period);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get content by ID' })
|
||||
async getById(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contentService.getById(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update content' })
|
||||
async updateContent(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: { body?: string; status?: ContentStatus; scheduledAt?: string },
|
||||
) {
|
||||
return this.contentService.update(id, {
|
||||
...dto,
|
||||
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/publish')
|
||||
@ApiOperation({ summary: 'Publish content' })
|
||||
async publishContent(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('publishedUrl') publishedUrl?: string,
|
||||
) {
|
||||
return this.contentService.publish(id, publishedUrl);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete content' })
|
||||
async deleteContent(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.contentService.delete(id);
|
||||
}
|
||||
|
||||
// ==================== Variations ====================
|
||||
|
||||
@Post(':id/variations')
|
||||
@ApiOperation({ summary: 'Generate content variations' })
|
||||
async generateVariations(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() config: VariationConfig,
|
||||
) {
|
||||
return this.variationsService.generateVariations(id, config);
|
||||
}
|
||||
|
||||
@Get(':id/variations/metrics')
|
||||
@ApiOperation({ summary: 'Get variation performance metrics' })
|
||||
async getVariationMetrics(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.variationsService.getPerformanceMetrics(id);
|
||||
}
|
||||
|
||||
@Post(':id/variations/winner')
|
||||
@ApiOperation({ summary: 'Select winning variation' })
|
||||
async selectWinner(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body('metric') metric: 'ctr' | 'engagementRate' | 'conversionRate',
|
||||
) {
|
||||
return this.variationsService.selectWinner(id, metric);
|
||||
}
|
||||
|
||||
// ==================== Writing Styles ====================
|
||||
|
||||
@Get('styles')
|
||||
@ApiOperation({ summary: 'Get all writing styles' })
|
||||
async getWritingStyles(@CurrentUser('id') userId: string) {
|
||||
return this.writingStylesService.getAll(userId);
|
||||
}
|
||||
|
||||
@Post('styles')
|
||||
@ApiOperation({ summary: 'Create custom writing style' })
|
||||
async createWritingStyle(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() config: WritingStyleConfig,
|
||||
) {
|
||||
return this.writingStylesService.create(userId, config);
|
||||
}
|
||||
|
||||
@Put('styles/:id/default')
|
||||
@ApiOperation({ summary: 'Set default writing style' })
|
||||
async setDefaultStyle(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') styleId: string,
|
||||
) {
|
||||
return this.writingStylesService.setDefault(userId, styleId);
|
||||
}
|
||||
|
||||
// ==================== Building Blocks ====================
|
||||
|
||||
@Get('blocks/:type')
|
||||
@ApiOperation({ summary: 'Get building blocks by type' })
|
||||
async getBuildingBlocks(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('type') type: string,
|
||||
@Query('limit') limit?: number,
|
||||
) {
|
||||
return this.buildingBlocksService.getByType(userId, type, limit ? Number(limit) : undefined);
|
||||
}
|
||||
|
||||
// ==================== Platforms ====================
|
||||
|
||||
@Get('platforms/config')
|
||||
@ApiOperation({ summary: 'Get all platform configurations' })
|
||||
async getPlatformConfigs() {
|
||||
return this.platformsService.getAllConfigs();
|
||||
}
|
||||
|
||||
@Get('platforms/:platform/config')
|
||||
@ApiOperation({ summary: 'Get specific platform config' })
|
||||
async getPlatformConfig(@Param('platform') platform: SocialPlatform) {
|
||||
return this.platformsService.getConfig(platform);
|
||||
}
|
||||
|
||||
@Post('platforms/adapt')
|
||||
@ApiOperation({ summary: 'Adapt content for platform' })
|
||||
async adaptContent(
|
||||
@Body() dto: { content: string; sourcePlatform: SocialPlatform; targetPlatform: SocialPlatform },
|
||||
) {
|
||||
return {
|
||||
adapted: this.platformsService.adapt(dto.content, dto.sourcePlatform, dto.targetPlatform),
|
||||
validation: this.platformsService.validate(dto.content, dto.targetPlatform),
|
||||
};
|
||||
}
|
||||
}
|
||||
25
src/modules/content/content.module.ts
Normal file
25
src/modules/content/content.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Master Content Module - Content generation pipeline
|
||||
// Path: src/modules/content/content.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ContentService } from './content.service';
|
||||
import { ContentController } from './content.controller';
|
||||
import { MasterContentService } from './services/master-content.service';
|
||||
import { BuildingBlocksService } from './services/building-blocks.service';
|
||||
import { WritingStylesService } from './services/writing-styles.service';
|
||||
import { ContentVariationsService } from './services/content-variations.service';
|
||||
import { PlatformAdaptersService } from './services/platform-adapters.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
ContentService,
|
||||
MasterContentService,
|
||||
BuildingBlocksService,
|
||||
WritingStylesService,
|
||||
ContentVariationsService,
|
||||
PlatformAdaptersService,
|
||||
],
|
||||
controllers: [ContentController],
|
||||
exports: [ContentService, MasterContentService],
|
||||
})
|
||||
export class ContentModule { }
|
||||
225
src/modules/content/content.service.ts
Normal file
225
src/modules/content/content.service.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// Content Service - Main orchestrator for content operations
|
||||
// Path: src/modules/content/content.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { MasterContentService } from './services/master-content.service';
|
||||
import { BuildingBlocksService } from './services/building-blocks.service';
|
||||
import { WritingStylesService } from './services/writing-styles.service';
|
||||
import { ContentVariationsService } from './services/content-variations.service';
|
||||
import { PlatformAdaptersService } from './services/platform-adapters.service';
|
||||
import { SocialPlatform, ContentStatus } from '@prisma/client';
|
||||
|
||||
export interface CreateContentDto {
|
||||
masterContentId: string;
|
||||
platform: SocialPlatform;
|
||||
body?: string;
|
||||
scheduledAt?: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentService {
|
||||
private readonly logger = new Logger(ContentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly masterContent: MasterContentService,
|
||||
private readonly buildingBlocks: BuildingBlocksService,
|
||||
private readonly writingStyles: WritingStylesService,
|
||||
private readonly variations: ContentVariationsService,
|
||||
private readonly platforms: PlatformAdaptersService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Create platform-specific content from master content
|
||||
*/
|
||||
async createFromMaster(userId: string, dto: CreateContentDto) {
|
||||
const master = await this.masterContent.getById(dto.masterContentId);
|
||||
if (!master) {
|
||||
throw new Error('Master content not found');
|
||||
}
|
||||
|
||||
// Get platform config
|
||||
const platformConfig = this.platforms.getConfig(dto.platform);
|
||||
|
||||
// Adapt content for platform
|
||||
let body = dto.body || master.body || '';
|
||||
body = this.platforms.format(body, dto.platform);
|
||||
|
||||
// Validate content
|
||||
const validation = this.platforms.validate(body, dto.platform);
|
||||
if (!validation.valid) {
|
||||
this.logger.warn(`Content validation issues: ${validation.issues.join(', ')}`);
|
||||
}
|
||||
|
||||
// Create content record
|
||||
const content = await this.prisma.content.create({
|
||||
data: {
|
||||
userId,
|
||||
masterContentId: dto.masterContentId,
|
||||
type: dto.platform as any,
|
||||
body,
|
||||
status: dto.scheduledAt ? ContentStatus.SCHEDULED : ContentStatus.DRAFT,
|
||||
scheduledAt: dto.scheduledAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content,
|
||||
platformConfig,
|
||||
validation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's content with filters
|
||||
*/
|
||||
async getByUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
platform?: SocialPlatform;
|
||||
status?: ContentStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
) {
|
||||
return this.prisma.content.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(options?.platform && { platform: options.platform }),
|
||||
...(options?.status && { status: options.status }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: options?.limit || 20,
|
||||
skip: options?.offset || 0,
|
||||
include: {
|
||||
masterContent: { select: { id: true, title: true, type: true } },
|
||||
variants: { select: { id: true, name: true, isWinner: true } },
|
||||
_count: { select: { variants: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content by ID
|
||||
*/
|
||||
async getById(id: string) {
|
||||
return this.prisma.content.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
masterContent: true,
|
||||
variants: true,
|
||||
approvals: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content
|
||||
*/
|
||||
async update(id: string, data: { body?: string; status?: ContentStatus; scheduledAt?: Date }) {
|
||||
return this.prisma.content.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish content (mark as published)
|
||||
*/
|
||||
async publish(id: string, publishedUrl?: string) {
|
||||
return this.prisma.content.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: ContentStatus.PUBLISHED,
|
||||
publishedAt: new Date(),
|
||||
publishedUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete content
|
||||
*/
|
||||
async delete(id: string) {
|
||||
return this.prisma.content.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content calendar for user
|
||||
*/
|
||||
async getCalendar(userId: string, startDate: Date, endDate: Date) {
|
||||
return this.prisma.content.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
{ scheduledAt: { gte: startDate, lte: endDate } },
|
||||
{ publishedAt: { gte: startDate, lte: endDate } },
|
||||
],
|
||||
},
|
||||
orderBy: [{ scheduledAt: 'asc' }, { publishedAt: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
scheduledAt: true,
|
||||
publishedAt: true,
|
||||
masterContent: { select: { title: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content analytics
|
||||
*/
|
||||
async getAnalytics(userId: string, period: 'week' | 'month' | 'year') {
|
||||
const now = new Date();
|
||||
let startDate: Date;
|
||||
|
||||
switch (period) {
|
||||
case 'week':
|
||||
startDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'month':
|
||||
startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'year':
|
||||
startDate = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
}
|
||||
|
||||
const contents = await this.prisma.content.findMany({
|
||||
where: {
|
||||
userId,
|
||||
publishedAt: { gte: startDate },
|
||||
},
|
||||
include: {
|
||||
variants: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Aggregate by platform
|
||||
const byPlatform = contents.reduce(
|
||||
(acc, content) => {
|
||||
if (!acc[content.type]) {
|
||||
acc[content.type] = { count: 0, totalEngagement: 0 };
|
||||
}
|
||||
acc[content.type].count++;
|
||||
acc[content.type].totalEngagement += content.variants.reduce(
|
||||
(sum, v) => sum + (v.engagements || 0),
|
||||
0,
|
||||
);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { count: number; totalEngagement: number }>,
|
||||
);
|
||||
|
||||
return {
|
||||
totalPublished: contents.length,
|
||||
byPlatform,
|
||||
period,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
src/modules/content/index.ts
Normal file
9
src/modules/content/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Content Module Index
|
||||
export * from './content.module';
|
||||
export * from './content.service';
|
||||
export * from './content.controller';
|
||||
export * from './services/master-content.service';
|
||||
export * from './services/building-blocks.service';
|
||||
export * from './services/writing-styles.service';
|
||||
export * from './services/content-variations.service';
|
||||
export * from './services/platform-adapters.service';
|
||||
193
src/modules/content/services/building-blocks.service.ts
Normal file
193
src/modules/content/services/building-blocks.service.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// Building Blocks Service - Extract reusable content elements
|
||||
// Path: src/modules/content/services/building-blocks.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
|
||||
export interface BuildingBlocks {
|
||||
hooks: string[];
|
||||
painPoints: string[];
|
||||
paradoxes: string[];
|
||||
quotes: string[];
|
||||
statistics: string[];
|
||||
callToActions: string[];
|
||||
metaphors: string[];
|
||||
stories: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BuildingBlocksService {
|
||||
private readonly logger = new Logger(BuildingBlocksService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
/**
|
||||
* Extract building blocks from content/research
|
||||
*/
|
||||
async extract(title: string, researchNotes?: string): Promise<BuildingBlocks> {
|
||||
this.logger.log(`Extracting building blocks for: ${title}`);
|
||||
|
||||
// AI-powered extraction will be added here
|
||||
// For now, generate template blocks
|
||||
return {
|
||||
hooks: this.generateHooks(title),
|
||||
painPoints: this.generatePainPoints(title),
|
||||
paradoxes: this.generateParadoxes(title),
|
||||
quotes: [],
|
||||
statistics: [],
|
||||
callToActions: this.generateCTAs(title),
|
||||
metaphors: [],
|
||||
stories: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate platform-specific content from building blocks
|
||||
*/
|
||||
async generatePlatformContent(
|
||||
masterBody: string,
|
||||
blocks: BuildingBlocks,
|
||||
platform: string,
|
||||
): Promise<string[]> {
|
||||
const platformConfig = this.getPlatformConfig(platform);
|
||||
const variations: string[] = [];
|
||||
|
||||
// Generate variations based on platform constraints
|
||||
for (let i = 0; i < platformConfig.variationCount; i++) {
|
||||
const hook = blocks.hooks[i % blocks.hooks.length];
|
||||
const cta = blocks.callToActions[i % blocks.callToActions.length];
|
||||
|
||||
let content = '';
|
||||
if (platform === 'TWITTER') {
|
||||
content = this.generateTwitterContent(hook, masterBody, cta, platformConfig.maxLength);
|
||||
} else if (platform === 'LINKEDIN') {
|
||||
content = this.generateLinkedInContent(hook, masterBody, cta, platformConfig.maxLength);
|
||||
} else if (platform === 'INSTAGRAM') {
|
||||
content = this.generateInstagramContent(hook, masterBody, cta, platformConfig.maxLength);
|
||||
} else {
|
||||
content = this.generateGenericContent(hook, masterBody, cta, platformConfig.maxLength);
|
||||
}
|
||||
|
||||
variations.push(content);
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save building blocks for reuse
|
||||
*/
|
||||
async save(userId: string, masterContentId: string, blocks: BuildingBlocks) {
|
||||
return this.prisma.buildingBlock.createMany({
|
||||
data: [
|
||||
...blocks.hooks.map((text) => ({
|
||||
userId,
|
||||
masterContentId,
|
||||
type: 'HOOK' as const,
|
||||
content: text,
|
||||
})),
|
||||
...blocks.painPoints.map((text) => ({
|
||||
userId,
|
||||
masterContentId,
|
||||
type: 'PAIN_POINT' as const,
|
||||
content: text,
|
||||
})),
|
||||
...blocks.callToActions.map((text) => ({
|
||||
userId,
|
||||
masterContentId,
|
||||
type: 'CTA' as const,
|
||||
content: text,
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved building blocks by type
|
||||
*/
|
||||
async getByType(userId: string, type: string, limit = 20) {
|
||||
return this.prisma.buildingBlock.findMany({
|
||||
where: { masterContent: { userId }, type: type as any },
|
||||
orderBy: { usageCount: 'desc' },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
// Hook Templates
|
||||
private generateHooks(title: string): string[] {
|
||||
const templates = [
|
||||
`The biggest mistake people make with ${title} is...`,
|
||||
`What if everything you knew about ${title} was wrong?`,
|
||||
`I spent 5 years studying ${title}. Here's what I learned:`,
|
||||
`${title} doesn't work the way you think it does.`,
|
||||
`The hidden truth about ${title} that nobody talks about:`,
|
||||
`Why 95% of people fail at ${title} (and how to be in the 5%):`,
|
||||
`Stop doing this one thing with ${title}. It's costing you.`,
|
||||
`I was wrong about ${title}. Here's my updated take:`,
|
||||
];
|
||||
return templates;
|
||||
}
|
||||
|
||||
// Pain Point Templates
|
||||
private generatePainPoints(title: string): string[] {
|
||||
return [
|
||||
`Struggling to get results with ${title}?`,
|
||||
`Feeling overwhelmed by all the ${title} advice out there?`,
|
||||
`Tired of spending hours on ${title} with nothing to show for it?`,
|
||||
`Frustrated that ${title} seems to work for everyone but you?`,
|
||||
`Not sure where to start with ${title}?`,
|
||||
];
|
||||
}
|
||||
|
||||
// Paradox Templates
|
||||
private generateParadoxes(title: string): string[] {
|
||||
return [
|
||||
`The more you focus on ${title}, the less progress you make.`,
|
||||
`To master ${title}, you must first unlearn everything you know.`,
|
||||
`The best ${title} strategy is having no strategy at all.`,
|
||||
`Less effort in ${title} often leads to better results.`,
|
||||
];
|
||||
}
|
||||
|
||||
// CTA Templates
|
||||
private generateCTAs(title: string): string[] {
|
||||
return [
|
||||
`Want to learn more about ${title}? Link in bio.`,
|
||||
`Save this post for later. You'll need it.`,
|
||||
`Drop a 🔥 if this changed your perspective on ${title}.`,
|
||||
`Follow for more insights on ${title}.`,
|
||||
`Share this with someone who needs to hear it.`,
|
||||
`Comment "INFO" and I'll send you my complete ${title} guide.`,
|
||||
];
|
||||
}
|
||||
|
||||
// Platform configurations
|
||||
private getPlatformConfig(platform: string) {
|
||||
const configs: Record<string, { maxLength: number; variationCount: number }> = {
|
||||
TWITTER: { maxLength: 280, variationCount: 5 },
|
||||
LINKEDIN: { maxLength: 3000, variationCount: 3 },
|
||||
INSTAGRAM: { maxLength: 2200, variationCount: 3 },
|
||||
FACEBOOK: { maxLength: 63206, variationCount: 2 },
|
||||
TIKTOK: { maxLength: 300, variationCount: 3 },
|
||||
};
|
||||
return configs[platform] || { maxLength: 1000, variationCount: 2 };
|
||||
}
|
||||
|
||||
// Platform-specific generators
|
||||
private generateTwitterContent(hook: string, body: string, cta: string, maxLength: number): string {
|
||||
const content = `${hook}\n\n${body.substring(0, 150)}...\n\n${cta}`;
|
||||
return content.substring(0, maxLength);
|
||||
}
|
||||
|
||||
private generateLinkedInContent(hook: string, body: string, cta: string, maxLength: number): string {
|
||||
return `${hook}\n\n${body.substring(0, 2500)}\n\n---\n${cta}`;
|
||||
}
|
||||
|
||||
private generateInstagramContent(hook: string, body: string, cta: string, maxLength: number): string {
|
||||
return `${hook}\n\n${body.substring(0, 1800)}\n\n.\n.\n.\n${cta}`;
|
||||
}
|
||||
|
||||
private generateGenericContent(hook: string, body: string, cta: string, maxLength: number): string {
|
||||
return `${hook}\n\n${body}\n\n${cta}`.substring(0, maxLength);
|
||||
}
|
||||
}
|
||||
168
src/modules/content/services/content-variations.service.ts
Normal file
168
src/modules/content/services/content-variations.service.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
// Content Variations Service - Generate multiple content variations
|
||||
// Path: src/modules/content/services/content-variations.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { ContentVariant } from '@prisma/client';
|
||||
|
||||
export interface VariationConfig {
|
||||
count: number;
|
||||
strategy: 'ab_test' | 'audience_segment' | 'platform_specific' | 'time_based';
|
||||
variations: VariationSpec[];
|
||||
}
|
||||
|
||||
export interface VariationSpec {
|
||||
name: string;
|
||||
hook?: string;
|
||||
tone?: string;
|
||||
length?: 'shorter' | 'same' | 'longer';
|
||||
cta?: string;
|
||||
targetAudience?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentVariationsService {
|
||||
private readonly logger = new Logger(ContentVariationsService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
/**
|
||||
* Generate content variations for A/B testing
|
||||
*/
|
||||
async generateVariations(
|
||||
contentId: string,
|
||||
config: VariationConfig,
|
||||
): Promise<any[]> {
|
||||
const content = await this.prisma.content.findUnique({
|
||||
where: { id: contentId },
|
||||
});
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Content not found');
|
||||
}
|
||||
|
||||
const variations: ContentVariant[] = [];
|
||||
|
||||
for (const spec of config.variations) {
|
||||
const variation = await this.createVariation(content, spec);
|
||||
variations.push(variation);
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single variation
|
||||
*/
|
||||
private async createVariation(
|
||||
originalContent: any,
|
||||
spec: VariationSpec,
|
||||
) {
|
||||
let variedBody = originalContent.body || '';
|
||||
|
||||
// Apply hook variation
|
||||
if (spec.hook) {
|
||||
variedBody = this.replaceHook(variedBody, spec.hook);
|
||||
}
|
||||
|
||||
// Apply length variation
|
||||
if (spec.length === 'shorter') {
|
||||
variedBody = this.shortenContent(variedBody, 0.7);
|
||||
} else if (spec.length === 'longer') {
|
||||
variedBody = this.expandContent(variedBody, 1.3);
|
||||
}
|
||||
|
||||
// Apply CTA variation
|
||||
if (spec.cta) {
|
||||
variedBody = this.replaceCTA(variedBody, spec.cta);
|
||||
}
|
||||
|
||||
// Save variation
|
||||
return this.prisma.contentVariant.create({
|
||||
data: {
|
||||
contentId: originalContent.id,
|
||||
name: spec.name,
|
||||
text: variedBody,
|
||||
platform: originalContent.platform,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variation performance metrics
|
||||
*/
|
||||
async getPerformanceMetrics(contentId: string) {
|
||||
const variants = await this.prisma.contentVariant.findMany({
|
||||
where: { contentId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
impressions: true,
|
||||
clicks: true,
|
||||
engagements: true,
|
||||
shares: true,
|
||||
conversions: true,
|
||||
},
|
||||
});
|
||||
|
||||
return variants.map((v) => ({
|
||||
...v,
|
||||
ctr: v.impressions > 0 ? (v.clicks / v.impressions) * 100 : 0,
|
||||
engagementRate: v.impressions > 0 ? (v.engagements / v.impressions) * 100 : 0,
|
||||
conversionRate: v.clicks > 0 ? (v.conversions / v.clicks) * 100 : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Select best performing variation
|
||||
*/
|
||||
async selectWinner(contentId: string, metric: 'ctr' | 'engagementRate' | 'conversionRate') {
|
||||
const metrics = await this.getPerformanceMetrics(contentId);
|
||||
|
||||
if (metrics.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Sort by selected metric
|
||||
metrics.sort((a, b) => b[metric] - a[metric]);
|
||||
|
||||
const winner = metrics[0];
|
||||
|
||||
// Mark as winner
|
||||
await this.prisma.contentVariant.update({
|
||||
where: { id: winner.id },
|
||||
data: { isWinner: true },
|
||||
});
|
||||
|
||||
return winner;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
private replaceHook(content: string, newHook: string): string {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > 0) {
|
||||
lines[0] = newHook;
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private shortenContent(content: string, ratio: number): string {
|
||||
const words = content.split(/\s+/);
|
||||
const targetLength = Math.floor(words.length * ratio);
|
||||
return words.slice(0, targetLength).join(' ') + '...';
|
||||
}
|
||||
|
||||
private expandContent(content: string, ratio: number): string {
|
||||
// In real implementation, this would use AI to expand
|
||||
return content + '\n\n[Expanded content would be added here]';
|
||||
}
|
||||
|
||||
private replaceCTA(content: string, newCTA: string): string {
|
||||
const lines = content.split('\n');
|
||||
if (lines.length > 0) {
|
||||
lines[lines.length - 1] = newCTA;
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
268
src/modules/content/services/master-content.service.ts
Normal file
268
src/modules/content/services/master-content.service.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// Master Content Service - Hub for long-form content creation
|
||||
// Path: src/modules/content/services/master-content.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { BuildingBlocksService } from './building-blocks.service';
|
||||
import { WritingStylesService } from './writing-styles.service';
|
||||
import { MasterContentType, ContentLanguage } from '@prisma/client';
|
||||
|
||||
export interface CreateMasterContentDto {
|
||||
title: string;
|
||||
type: MasterContentType;
|
||||
nicheId?: string;
|
||||
trendId?: string;
|
||||
researchNotes?: string;
|
||||
targetAudience?: string;
|
||||
writingStyleId?: string;
|
||||
targetLanguage?: ContentLanguage;
|
||||
}
|
||||
|
||||
export interface GeneratedMasterContent {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
outline: string[];
|
||||
buildingBlocks: {
|
||||
hooks: string[];
|
||||
painPoints: string[];
|
||||
paradoxes: string[];
|
||||
quotes: string[];
|
||||
statistics: string[];
|
||||
};
|
||||
metadata: {
|
||||
wordCount: number;
|
||||
readingTime: number;
|
||||
seoScore?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MasterContentService {
|
||||
private readonly logger = new Logger(MasterContentService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly buildingBlocks: BuildingBlocksService,
|
||||
private readonly writingStyles: WritingStylesService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Create master content (Blog, Newsletter, Podcast Script, Video Script)
|
||||
*/
|
||||
async create(
|
||||
userId: string,
|
||||
data: CreateMasterContentDto,
|
||||
): Promise<GeneratedMasterContent> {
|
||||
this.logger.log(`Creating ${data.type} master content: ${data.title}`);
|
||||
|
||||
// Get writing style
|
||||
const style = data.writingStyleId
|
||||
? await this.writingStyles.getById(data.writingStyleId)
|
||||
: await this.writingStyles.getDefault(userId);
|
||||
|
||||
// Generate outline based on content type
|
||||
const outline = this.generateOutline(data.type, data.title);
|
||||
|
||||
// Create master content record
|
||||
const masterContent = await this.prisma.masterContent.create({
|
||||
data: {
|
||||
userId,
|
||||
nicheId: data.nicheId,
|
||||
trendId: data.trendId,
|
||||
title: data.title,
|
||||
type: data.type,
|
||||
researchNotes: data.researchNotes,
|
||||
targetAudience: data.targetAudience,
|
||||
writingStyleId: style?.id,
|
||||
outline,
|
||||
status: 'DRAFT',
|
||||
body: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Extract building blocks
|
||||
const blocks = await this.buildingBlocks.extract(data.title, data.researchNotes);
|
||||
|
||||
// Generate content body (placeholder - will integrate with AI)
|
||||
const body = this.generateContentBody(data.type, outline, blocks, style);
|
||||
|
||||
// Update with generated content
|
||||
await this.prisma.masterContent.update({
|
||||
where: { id: masterContent.id },
|
||||
data: {
|
||||
body,
|
||||
hooks: blocks.hooks,
|
||||
painPoints: blocks.painPoints,
|
||||
paradoxes: blocks.paradoxes,
|
||||
quotes: blocks.quotes,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: masterContent.id,
|
||||
title: data.title,
|
||||
body,
|
||||
outline,
|
||||
buildingBlocks: blocks,
|
||||
metadata: {
|
||||
wordCount: body.split(/\s+/).length,
|
||||
readingTime: Math.ceil(body.split(/\s+/).length / 200),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get master content by ID
|
||||
*/
|
||||
async getById(id: string) {
|
||||
return this.prisma.masterContent.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
niche: true,
|
||||
trend: true,
|
||||
writingStyle: true,
|
||||
contents: true,
|
||||
buildingBlocks: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's master contents
|
||||
*/
|
||||
async getByUser(
|
||||
userId: string,
|
||||
options?: {
|
||||
type?: MasterContentType;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
},
|
||||
) {
|
||||
return this.prisma.masterContent.findMany({
|
||||
where: {
|
||||
userId,
|
||||
...(options?.type && { type: options.type }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: options?.limit || 20,
|
||||
skip: options?.offset || 0,
|
||||
include: {
|
||||
niche: { select: { id: true, name: true } },
|
||||
_count: { select: { contents: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Repurpose master content into multiple pieces
|
||||
*/
|
||||
async repurpose(masterContentId: string, platforms: string[]) {
|
||||
const masterContent = await this.getById(masterContentId);
|
||||
if (!masterContent) {
|
||||
throw new Error('Master content not found');
|
||||
}
|
||||
|
||||
const blocks = {
|
||||
hooks: masterContent.buildingBlocks.filter(b => b.type === 'HOOK').map(b => b.content),
|
||||
painPoints: masterContent.buildingBlocks.filter(b => b.type === 'PAIN_POINT').map(b => b.content),
|
||||
paradoxes: masterContent.buildingBlocks.filter(b => b.type === 'PARADOX').map(b => b.content),
|
||||
quotes: masterContent.buildingBlocks.filter(b => b.type === 'QUOTE').map(b => b.content),
|
||||
statistics: masterContent.buildingBlocks.filter(b => b.type === 'STATISTIC').map(b => b.content),
|
||||
callToActions: masterContent.buildingBlocks.filter(b => b.type === 'CTA').map(b => b.content),
|
||||
metaphors: masterContent.buildingBlocks.filter(b => b.type === 'METAPHOR').map(b => b.content),
|
||||
stories: masterContent.buildingBlocks.filter(b => b.type === 'STORY').map(b => b.content),
|
||||
};
|
||||
|
||||
// Generate content for each platform
|
||||
const results: { platform: string; variations: string[] }[] = [];
|
||||
for (const platform of platforms) {
|
||||
const variations = await this.buildingBlocks.generatePlatformContent(
|
||||
masterContent.body || '',
|
||||
blocks,
|
||||
platform,
|
||||
);
|
||||
results.push({ platform, variations });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate outline based on content type
|
||||
*/
|
||||
private generateOutline(type: MasterContentType, title: string): string[] {
|
||||
const outlines: Record<MasterContentType, string[]> = {
|
||||
BLOG: [
|
||||
'Introduction - Hook & Problem Statement',
|
||||
'Background - Context & Why It Matters',
|
||||
'Main Point 1 - Key Insight',
|
||||
'Main Point 2 - Supporting Evidence',
|
||||
'Main Point 3 - Practical Application',
|
||||
'Objections & Counterarguments',
|
||||
'Conclusion - Call to Action',
|
||||
],
|
||||
NEWSLETTER: [
|
||||
'Attention-Grabbing Opening',
|
||||
'This Week\'s Key Insight',
|
||||
'Quick Win / Actionable Tip',
|
||||
'Deep Dive Section',
|
||||
'Resource of the Week',
|
||||
'Community Highlight',
|
||||
'What\'s Coming Next',
|
||||
],
|
||||
PODCAST_SCRIPT: [
|
||||
'Cold Open / Teaser',
|
||||
'Intro & Welcome',
|
||||
'Main Topic Introduction',
|
||||
'Segment 1 - Story/Context',
|
||||
'Segment 2 - Key Insights',
|
||||
'Segment 3 - Practical Takeaways',
|
||||
'Sponsor Break (if applicable)',
|
||||
'Listener Q&A / Community',
|
||||
'Outro & CTA',
|
||||
],
|
||||
VIDEO_SCRIPT: [
|
||||
'Hook (First 5 seconds)',
|
||||
'Promise / What They\'ll Learn',
|
||||
'Credibility Moment',
|
||||
'Main Content Section 1',
|
||||
'Main Content Section 2',
|
||||
'Main Content Section 3',
|
||||
'Recap & Summary',
|
||||
'Call to Action',
|
||||
'End Screen Prompt',
|
||||
],
|
||||
THREAD: [
|
||||
'Hook Tweet (Pattern Interrupt)',
|
||||
'Context / Problem',
|
||||
'Key Insight 1',
|
||||
'Key Insight 2',
|
||||
'Key Insight 3',
|
||||
'Proof / Example',
|
||||
'Summary',
|
||||
'CTA',
|
||||
],
|
||||
};
|
||||
|
||||
return outlines[type] || outlines.BLOG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content body (placeholder for AI integration)
|
||||
*/
|
||||
private generateContentBody(
|
||||
type: MasterContentType,
|
||||
outline: string[],
|
||||
blocks: any,
|
||||
style: any,
|
||||
): string {
|
||||
// This will be replaced with actual AI generation
|
||||
const sections = outline.map((section, index) => {
|
||||
const hook = (blocks.hooks && blocks.hooks.length > 0) ? blocks.hooks[index % blocks.hooks.length] : '';
|
||||
return `## ${section}\n\n${hook}\n\n[Content for ${section} will be generated here]`;
|
||||
});
|
||||
|
||||
return sections.join('\n\n---\n\n');
|
||||
}
|
||||
}
|
||||
288
src/modules/content/services/platform-adapters.service.ts
Normal file
288
src/modules/content/services/platform-adapters.service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
// Platform Adapters Service - Transform content for different platforms
|
||||
// Path: src/modules/content/services/platform-adapters.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { SocialPlatform } from '@prisma/client';
|
||||
|
||||
export interface PlatformConfig {
|
||||
name: string;
|
||||
maxLength: number;
|
||||
supportsMedia: boolean;
|
||||
supportsLinks: boolean;
|
||||
supportsHashtags: boolean;
|
||||
supportsMentions: boolean;
|
||||
supportsEmoji: boolean;
|
||||
mediaFormats?: string[];
|
||||
optimalLength?: number;
|
||||
bestPostingTimes?: string[];
|
||||
engagementTips?: string[];
|
||||
}
|
||||
|
||||
export const PLATFORM_CONFIGS: Record<SocialPlatform, PlatformConfig> = {
|
||||
TWITTER: {
|
||||
name: 'X (Twitter)',
|
||||
maxLength: 280,
|
||||
optimalLength: 240,
|
||||
supportsMedia: true,
|
||||
supportsLinks: true,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video', 'gif'],
|
||||
bestPostingTimes: ['9:00', '12:00', '17:00', '21:00'],
|
||||
engagementTips: [
|
||||
'Use threads for longer content',
|
||||
'Ask questions to boost engagement',
|
||||
'Quote tweet with commentary',
|
||||
],
|
||||
},
|
||||
LINKEDIN: {
|
||||
name: 'LinkedIn',
|
||||
maxLength: 3000,
|
||||
optimalLength: 1300,
|
||||
supportsMedia: true,
|
||||
supportsLinks: true,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video', 'document', 'carousel'],
|
||||
bestPostingTimes: ['7:30', '12:00', '17:00'],
|
||||
engagementTips: [
|
||||
'Hook in first line (before "see more")',
|
||||
'Use line breaks for readability',
|
||||
'End with a question',
|
||||
],
|
||||
},
|
||||
INSTAGRAM: {
|
||||
name: 'Instagram',
|
||||
maxLength: 2200,
|
||||
optimalLength: 1500,
|
||||
supportsMedia: true,
|
||||
supportsLinks: false,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video', 'reels', 'carousel', 'stories'],
|
||||
bestPostingTimes: ['6:00', '11:00', '19:00'],
|
||||
engagementTips: [
|
||||
'Use up to 30 hashtags',
|
||||
'Put hashtags in first comment',
|
||||
'Include CTA in caption',
|
||||
],
|
||||
},
|
||||
FACEBOOK: {
|
||||
name: 'Facebook',
|
||||
maxLength: 63206,
|
||||
optimalLength: 250,
|
||||
supportsMedia: true,
|
||||
supportsLinks: true,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video', 'reels', 'stories'],
|
||||
bestPostingTimes: ['9:00', '13:00', '19:00'],
|
||||
engagementTips: [
|
||||
'Shorter posts perform better',
|
||||
'Native video preferred',
|
||||
'Use Facebook Live for engagement',
|
||||
],
|
||||
},
|
||||
TIKTOK: {
|
||||
name: 'TikTok',
|
||||
maxLength: 300,
|
||||
optimalLength: 150,
|
||||
supportsMedia: true,
|
||||
supportsLinks: false,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['video'],
|
||||
bestPostingTimes: ['7:00', '10:00', '19:00', '23:00'],
|
||||
engagementTips: [
|
||||
'Hook in first 3 seconds',
|
||||
'Use trending sounds',
|
||||
'Keep videos under 60 seconds',
|
||||
],
|
||||
},
|
||||
YOUTUBE: {
|
||||
name: 'YouTube',
|
||||
maxLength: 5000,
|
||||
optimalLength: 500,
|
||||
supportsMedia: true,
|
||||
supportsLinks: true,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: false,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['video', 'shorts'],
|
||||
bestPostingTimes: ['12:00', '15:00', '19:00'],
|
||||
engagementTips: [
|
||||
'Use timestamps in description',
|
||||
'Include relevant keywords',
|
||||
'Add cards and end screens',
|
||||
],
|
||||
},
|
||||
PINTEREST: {
|
||||
name: 'Pinterest',
|
||||
maxLength: 500,
|
||||
optimalLength: 300,
|
||||
supportsMedia: true,
|
||||
supportsLinks: true,
|
||||
supportsHashtags: true,
|
||||
supportsMentions: false,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video', 'idea_pin'],
|
||||
bestPostingTimes: ['20:00', '21:00', '22:00'],
|
||||
engagementTips: [
|
||||
'Vertical images perform best',
|
||||
'Use keyword-rich descriptions',
|
||||
'Create multiple pins per post',
|
||||
],
|
||||
},
|
||||
THREADS: {
|
||||
name: 'Threads',
|
||||
maxLength: 500,
|
||||
optimalLength: 280,
|
||||
supportsMedia: true,
|
||||
supportsLinks: false,
|
||||
supportsHashtags: false,
|
||||
supportsMentions: true,
|
||||
supportsEmoji: true,
|
||||
mediaFormats: ['image', 'video'],
|
||||
bestPostingTimes: ['12:00', '17:00', '21:00'],
|
||||
engagementTips: [
|
||||
'Conversational tone works best',
|
||||
'Reply to trending topics',
|
||||
'Cross-post from Instagram',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PlatformAdaptersService {
|
||||
private readonly logger = new Logger(PlatformAdaptersService.name);
|
||||
|
||||
/**
|
||||
* Get platform configuration
|
||||
*/
|
||||
getConfig(platform: SocialPlatform): PlatformConfig {
|
||||
return PLATFORM_CONFIGS[platform];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all platform configs
|
||||
*/
|
||||
getAllConfigs(): Record<SocialPlatform, PlatformConfig> {
|
||||
return PLATFORM_CONFIGS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapt content for specific platform
|
||||
*/
|
||||
adapt(content: string, sourcePlatform: SocialPlatform, targetPlatform: SocialPlatform): string {
|
||||
const sourceConfig = PLATFORM_CONFIGS[sourcePlatform];
|
||||
const targetConfig = PLATFORM_CONFIGS[targetPlatform];
|
||||
|
||||
let adapted = content;
|
||||
|
||||
// Handle length constraints
|
||||
if (adapted.length > targetConfig.maxLength) {
|
||||
adapted = this.truncate(adapted, targetConfig.maxLength);
|
||||
}
|
||||
|
||||
// Handle link support
|
||||
if (!targetConfig.supportsLinks && sourceConfig.supportsLinks) {
|
||||
adapted = this.removeLinks(adapted);
|
||||
}
|
||||
|
||||
// Handle hashtag support
|
||||
if (!targetConfig.supportsHashtags && sourceConfig.supportsHashtags) {
|
||||
adapted = this.removeHashtags(adapted);
|
||||
}
|
||||
|
||||
return adapted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format content for platform
|
||||
*/
|
||||
format(content: string, platform: SocialPlatform): string {
|
||||
const config = PLATFORM_CONFIGS[platform];
|
||||
|
||||
switch (platform) {
|
||||
case 'TWITTER':
|
||||
return this.formatForTwitter(content, config);
|
||||
case 'LINKEDIN':
|
||||
return this.formatForLinkedIn(content, config);
|
||||
case 'INSTAGRAM':
|
||||
return this.formatForInstagram(content, config);
|
||||
default:
|
||||
return this.truncate(content, config.maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content is valid for platform
|
||||
*/
|
||||
validate(content: string, platform: SocialPlatform): { valid: boolean; issues: string[] } {
|
||||
const config = PLATFORM_CONFIGS[platform];
|
||||
const issues: string[] = [];
|
||||
|
||||
if (content.length > config.maxLength) {
|
||||
issues.push(`Content exceeds ${config.maxLength} character limit`);
|
||||
}
|
||||
|
||||
if (!config.supportsLinks && this.containsLinks(content)) {
|
||||
issues.push('Platform does not support links in captions');
|
||||
}
|
||||
|
||||
return { valid: issues.length === 0, issues };
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
private truncate(content: string, maxLength: number): string {
|
||||
if (content.length <= maxLength) return content;
|
||||
return content.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
|
||||
private removeLinks(content: string): string {
|
||||
return content.replace(/https?:\/\/[^\s]+/g, '[link in bio]');
|
||||
}
|
||||
|
||||
private removeHashtags(content: string): string {
|
||||
return content.replace(/#\w+/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
private containsLinks(content: string): boolean {
|
||||
return /https?:\/\/[^\s]+/.test(content);
|
||||
}
|
||||
|
||||
private formatForTwitter(content: string, config: PlatformConfig): string {
|
||||
let formatted = content;
|
||||
|
||||
// Ensure hashtags at end
|
||||
const hashtags = formatted.match(/#\w+/g) || [];
|
||||
formatted = formatted.replace(/#\w+/g, '').trim();
|
||||
formatted = `${formatted}\n\n${hashtags.slice(0, 3).join(' ')}`;
|
||||
|
||||
return this.truncate(formatted, config.maxLength);
|
||||
}
|
||||
|
||||
private formatForLinkedIn(content: string, config: PlatformConfig): string {
|
||||
// Add line breaks for readability
|
||||
const lines = content.split(/(?<=[.!?])\s+/);
|
||||
return lines.slice(0, 20).join('\n\n');
|
||||
}
|
||||
|
||||
private formatForInstagram(content: string, config: PlatformConfig): string {
|
||||
let formatted = content;
|
||||
|
||||
// Add dots before hashtags (common Instagram style)
|
||||
if (formatted.includes('#')) {
|
||||
const mainContent = formatted.split('#')[0].trim();
|
||||
const hashtags = formatted.match(/#\w+/g) || [];
|
||||
formatted = `${mainContent}\n.\n.\n.\n${hashtags.join(' ')}`;
|
||||
}
|
||||
|
||||
return formatted;
|
||||
}
|
||||
}
|
||||
424
src/modules/content/services/writing-styles.service.ts
Normal file
424
src/modules/content/services/writing-styles.service.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
// Writing Styles Service - Extended with 15+ personality tones
|
||||
// Path: src/modules/content/services/writing-styles.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { ContentLanguage } from '@prisma/client';
|
||||
|
||||
export interface WritingStyleConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
tone: WritingTone;
|
||||
voice: 'first_person' | 'second_person' | 'third_person';
|
||||
vocabulary: 'simple' | 'intermediate' | 'advanced' | 'technical';
|
||||
sentenceLength: 'short' | 'medium' | 'long' | 'varied';
|
||||
emojiUsage: 'none' | 'minimal' | 'moderate' | 'heavy';
|
||||
hashtagStyle: 'none' | 'minimal' | 'topic_based' | 'trending';
|
||||
structurePreference: 'paragraphs' | 'bullets' | 'numbered' | 'mixed';
|
||||
engagementStyle: 'educational' | 'storytelling' | 'data_driven' | 'conversational' | 'provocative';
|
||||
signatureElements?: string[];
|
||||
avoidPhrases?: string[];
|
||||
preferredPhrases?: string[];
|
||||
language?: ContentLanguage;
|
||||
}
|
||||
|
||||
// 15+ Writing Tones
|
||||
export type WritingTone =
|
||||
| 'storyteller' // Narrative-driven, emotionally engaging
|
||||
| 'narrator' // Documentary style, observational
|
||||
| 'sarcastic' // Witty, ironic, sharp humor
|
||||
| 'inspirational' // Motivational, uplifting
|
||||
| 'professional' // Business-like, polished
|
||||
| 'casual' // Relaxed, conversational
|
||||
| 'friendly' // Warm, approachable
|
||||
| 'authoritative' // Expert, commanding
|
||||
| 'playful' // Fun, light-hearted
|
||||
| 'provocative' // Controversial, challenging
|
||||
| 'empathetic' // Understanding, supportive
|
||||
| 'analytical' // Data-focused, logical
|
||||
| 'humorous' // Comedy-driven, entertaining
|
||||
| 'minimalist' // Concise, direct
|
||||
| 'dramatic' // Intense, emotional
|
||||
| 'educational'; // Teaching, informative
|
||||
|
||||
export const WRITING_TONES: Record<WritingTone, { emoji: string; description: string; promptHint: string }> = {
|
||||
storyteller: {
|
||||
emoji: '📖',
|
||||
description: 'Narrative-driven, weaves stories to make points memorable',
|
||||
promptHint: 'Write like a master storyteller, using narrative techniques, character arcs, and emotional hooks',
|
||||
},
|
||||
narrator: {
|
||||
emoji: '🎙️',
|
||||
description: 'Documentary-style, observational and descriptive',
|
||||
promptHint: 'Write like a documentary narrator, observing and describing with clarity and depth',
|
||||
},
|
||||
sarcastic: {
|
||||
emoji: '😏',
|
||||
description: 'Witty, ironic, with sharp humor',
|
||||
promptHint: 'Write with sarcastic wit, using irony and clever observations that make readers think',
|
||||
},
|
||||
inspirational: {
|
||||
emoji: '✨',
|
||||
description: 'Motivational and uplifting content',
|
||||
promptHint: 'Write to inspire and motivate, using powerful language that uplifts the reader',
|
||||
},
|
||||
professional: {
|
||||
emoji: '💼',
|
||||
description: 'Business-like, polished and authoritative',
|
||||
promptHint: 'Write in a professional, business-appropriate tone with credibility and expertise',
|
||||
},
|
||||
casual: {
|
||||
emoji: '😊',
|
||||
description: 'Relaxed and conversational',
|
||||
promptHint: 'Write casually, like talking to a friend over coffee',
|
||||
},
|
||||
friendly: {
|
||||
emoji: '🤝',
|
||||
description: 'Warm, approachable and welcoming',
|
||||
promptHint: 'Write in a warm, friendly manner that makes readers feel comfortable and welcomed',
|
||||
},
|
||||
authoritative: {
|
||||
emoji: '👔',
|
||||
description: 'Expert voice with commanding presence',
|
||||
promptHint: 'Write with authority and expertise, establishing credibility and trust',
|
||||
},
|
||||
playful: {
|
||||
emoji: '🎉',
|
||||
description: 'Fun, light-hearted and entertaining',
|
||||
promptHint: 'Write playfully with humor, wordplay, and a light touch',
|
||||
},
|
||||
provocative: {
|
||||
emoji: '🔥',
|
||||
description: 'Controversial, thought-provoking',
|
||||
promptHint: 'Write to challenge assumptions and provoke thought, with bold statements',
|
||||
},
|
||||
empathetic: {
|
||||
emoji: '💙',
|
||||
description: 'Understanding and supportive',
|
||||
promptHint: 'Write with empathy, acknowledging struggles and offering understanding',
|
||||
},
|
||||
analytical: {
|
||||
emoji: '📊',
|
||||
description: 'Data-focused and logical',
|
||||
promptHint: 'Write analytically, using data, logic, and structured arguments',
|
||||
},
|
||||
humorous: {
|
||||
emoji: '😂',
|
||||
description: 'Comedy-driven, entertaining',
|
||||
promptHint: 'Write with humor, jokes, and entertainment value',
|
||||
},
|
||||
minimalist: {
|
||||
emoji: '🎯',
|
||||
description: 'Concise, direct, no fluff',
|
||||
promptHint: 'Write minimally, every word counts, eliminate all unnecessary words',
|
||||
},
|
||||
dramatic: {
|
||||
emoji: '🎭',
|
||||
description: 'Intense, emotional, theatrical',
|
||||
promptHint: 'Write dramatically with intensity, building tension and emotional impact',
|
||||
},
|
||||
educational: {
|
||||
emoji: '📚',
|
||||
description: 'Teaching-focused, informative',
|
||||
promptHint: 'Write to educate, explain concepts clearly with examples and structure',
|
||||
},
|
||||
};
|
||||
|
||||
// Preset Writing Styles (combining tone + other settings)
|
||||
export const PRESET_STYLES: Record<string, WritingStyleConfig> = {
|
||||
master_storyteller: {
|
||||
name: 'Master Storyteller',
|
||||
description: 'Narrative-driven, emotionally engaging content with story arcs',
|
||||
tone: 'storyteller',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'intermediate',
|
||||
sentenceLength: 'varied',
|
||||
emojiUsage: 'minimal',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'paragraphs',
|
||||
engagementStyle: 'storytelling',
|
||||
preferredPhrases: ['Let me tell you...', 'Picture this:', 'Here\'s what happened:'],
|
||||
},
|
||||
sharp_sarcastic: {
|
||||
name: 'Sharp & Sarcastic',
|
||||
description: 'Witty observations with ironic humor',
|
||||
tone: 'sarcastic',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'intermediate',
|
||||
sentenceLength: 'short',
|
||||
emojiUsage: 'minimal',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'paragraphs',
|
||||
engagementStyle: 'provocative',
|
||||
preferredPhrases: ['Oh sure,', 'Because obviously,', 'Shocking, I know.'],
|
||||
avoidPhrases: ['To be honest', 'Actually'],
|
||||
},
|
||||
documentary_narrator: {
|
||||
name: 'Documentary Narrator',
|
||||
description: 'Observational, descriptive, cinematic',
|
||||
tone: 'narrator',
|
||||
voice: 'third_person',
|
||||
vocabulary: 'advanced',
|
||||
sentenceLength: 'long',
|
||||
emojiUsage: 'none',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'paragraphs',
|
||||
engagementStyle: 'storytelling',
|
||||
},
|
||||
motivational_coach: {
|
||||
name: 'Motivational Coach',
|
||||
description: 'Inspiring, action-oriented, empowering',
|
||||
tone: 'inspirational',
|
||||
voice: 'second_person',
|
||||
vocabulary: 'simple',
|
||||
sentenceLength: 'short',
|
||||
emojiUsage: 'moderate',
|
||||
hashtagStyle: 'minimal',
|
||||
structurePreference: 'bullets',
|
||||
engagementStyle: 'conversational',
|
||||
preferredPhrases: ['You can do this!', 'Here\'s the truth:', 'Your time is now.'],
|
||||
},
|
||||
data_analyst: {
|
||||
name: 'Data Analyst',
|
||||
description: 'Facts, figures, and logical conclusions',
|
||||
tone: 'analytical',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'technical',
|
||||
sentenceLength: 'medium',
|
||||
emojiUsage: 'none',
|
||||
hashtagStyle: 'topic_based',
|
||||
structurePreference: 'numbered',
|
||||
engagementStyle: 'data_driven',
|
||||
preferredPhrases: ['The data shows:', 'Research indicates:', 'Here are the numbers:'],
|
||||
},
|
||||
friendly_teacher: {
|
||||
name: 'Friendly Teacher',
|
||||
description: 'Educational, patient, encouraging',
|
||||
tone: 'educational',
|
||||
voice: 'second_person',
|
||||
vocabulary: 'simple',
|
||||
sentenceLength: 'short',
|
||||
emojiUsage: 'moderate',
|
||||
hashtagStyle: 'topic_based',
|
||||
structurePreference: 'numbered',
|
||||
engagementStyle: 'educational',
|
||||
preferredPhrases: ['Let me explain:', 'Think of it this way:', 'Here\'s a simple example:'],
|
||||
},
|
||||
corporate_executive: {
|
||||
name: 'Corporate Executive',
|
||||
description: 'Professional, strategic, leadership-focused',
|
||||
tone: 'professional',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'advanced',
|
||||
sentenceLength: 'medium',
|
||||
emojiUsage: 'none',
|
||||
hashtagStyle: 'topic_based',
|
||||
structurePreference: 'mixed',
|
||||
engagementStyle: 'data_driven',
|
||||
},
|
||||
stand_up_comedian: {
|
||||
name: 'Stand-up Comedian',
|
||||
description: 'Funny, self-deprecating, observational humor',
|
||||
tone: 'humorous',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'simple',
|
||||
sentenceLength: 'varied',
|
||||
emojiUsage: 'moderate',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'paragraphs',
|
||||
engagementStyle: 'conversational',
|
||||
},
|
||||
thought_provocateur: {
|
||||
name: 'Thought Provocateur',
|
||||
description: 'Bold statements, contrarian views, challenges assumptions',
|
||||
tone: 'provocative',
|
||||
voice: 'first_person',
|
||||
vocabulary: 'advanced',
|
||||
sentenceLength: 'varied',
|
||||
emojiUsage: 'none',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'paragraphs',
|
||||
engagementStyle: 'provocative',
|
||||
preferredPhrases: ['Unpopular opinion:', 'Hot take:', 'Everyone is wrong about:'],
|
||||
},
|
||||
zen_minimalist: {
|
||||
name: 'Zen Minimalist',
|
||||
description: 'Every word matters, no fluff, pure clarity',
|
||||
tone: 'minimalist',
|
||||
voice: 'second_person',
|
||||
vocabulary: 'simple',
|
||||
sentenceLength: 'short',
|
||||
emojiUsage: 'none',
|
||||
hashtagStyle: 'none',
|
||||
structurePreference: 'bullets',
|
||||
engagementStyle: 'educational',
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WritingStylesService {
|
||||
private readonly logger = new Logger(WritingStylesService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
/**
|
||||
* Get all available tones
|
||||
*/
|
||||
getTones(): typeof WRITING_TONES {
|
||||
return WRITING_TONES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all preset styles
|
||||
*/
|
||||
getPresets(): typeof PRESET_STYLES {
|
||||
return PRESET_STYLES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom writing style
|
||||
*/
|
||||
async create(userId: string, config: WritingStyleConfig) {
|
||||
return this.prisma.writingStyle.create({
|
||||
data: {
|
||||
userId,
|
||||
name: config.name,
|
||||
type: 'CUSTOM',
|
||||
tone: config.tone,
|
||||
vocabulary: Array.isArray(config.vocabulary) ? config.vocabulary : [config.vocabulary],
|
||||
sentenceLength: config.sentenceLength,
|
||||
emojiUsage: config.emojiUsage,
|
||||
hashtagStyle: config.hashtagStyle,
|
||||
structurePreference: config.structurePreference,
|
||||
engagementStyle: config.engagementStyle,
|
||||
signatureElements: config.signatureElements || [],
|
||||
avoidWords: config.avoidPhrases || [],
|
||||
preferredPhrases: config.preferredPhrases || [],
|
||||
isDefault: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get writing style by ID
|
||||
*/
|
||||
async getById(id: string) {
|
||||
// Check if it's a preset
|
||||
if (id.startsWith('preset-')) {
|
||||
const presetKey = id.replace('preset-', '');
|
||||
return PRESET_STYLES[presetKey] ? { id, ...PRESET_STYLES[presetKey] } : null;
|
||||
}
|
||||
|
||||
return this.prisma.writingStyle.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's default writing style
|
||||
*/
|
||||
async getDefault(userId: string) {
|
||||
const userDefault = await this.prisma.writingStyle.findFirst({
|
||||
where: { userId, isDefault: true },
|
||||
});
|
||||
|
||||
return userDefault || { id: 'preset-master_storyteller', ...PRESET_STYLES.master_storyteller };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user's writing styles (custom + presets)
|
||||
*/
|
||||
async getAll(userId: string) {
|
||||
const customStyles = await this.prisma.writingStyle.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const presets = Object.entries(PRESET_STYLES).map(([key, style]) => ({
|
||||
id: `preset-${key}`,
|
||||
...style,
|
||||
isPreset: true,
|
||||
}));
|
||||
|
||||
return [...customStyles, ...presets];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set default writing style
|
||||
*/
|
||||
async setDefault(userId: string, styleId: string) {
|
||||
await this.prisma.writingStyle.updateMany({
|
||||
where: { userId, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
|
||||
return this.prisma.writingStyle.update({
|
||||
where: { id: styleId },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI prompt for style
|
||||
*/
|
||||
generatePrompt(style: WritingStyleConfig, language?: ContentLanguage): string {
|
||||
const toneInfo = WRITING_TONES[style.tone];
|
||||
|
||||
let prompt = `
|
||||
WRITING STYLE INSTRUCTIONS:
|
||||
${toneInfo.promptHint}
|
||||
|
||||
STYLE PARAMETERS:
|
||||
- Tone: ${style.tone} (${toneInfo.description})
|
||||
- Voice: ${style.voice.replace('_', ' ')}
|
||||
- Vocabulary: ${style.vocabulary}
|
||||
- Sentence length: ${style.sentenceLength}
|
||||
- Emoji usage: ${style.emojiUsage}
|
||||
- Structure: ${style.structurePreference}
|
||||
- Engagement: ${style.engagementStyle}
|
||||
`;
|
||||
|
||||
if (style.preferredPhrases?.length) {
|
||||
prompt += `\n- Use phrases like: ${style.preferredPhrases.join(', ')}`;
|
||||
}
|
||||
|
||||
if (style.avoidPhrases?.length) {
|
||||
prompt += `\n- Avoid phrases: ${style.avoidPhrases.join(', ')}`;
|
||||
}
|
||||
|
||||
if (language) {
|
||||
prompt += `\n\nWRITE CONTENT IN: ${language}`;
|
||||
}
|
||||
|
||||
return prompt.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply writing style transformations
|
||||
*/
|
||||
applyStyle(content: string, style: WritingStyleConfig): string {
|
||||
let styledContent = content;
|
||||
|
||||
if (style.emojiUsage === 'none') {
|
||||
styledContent = styledContent.replace(/[\u{1F300}-\u{1F9FF}]/gu, '');
|
||||
}
|
||||
|
||||
if (style.structurePreference === 'bullets') {
|
||||
styledContent = this.convertToBullets(styledContent);
|
||||
} else if (style.structurePreference === 'numbered') {
|
||||
styledContent = this.convertToNumbered(styledContent);
|
||||
}
|
||||
|
||||
return styledContent;
|
||||
}
|
||||
|
||||
private convertToBullets(content: string): string {
|
||||
const sentences = content.split(/(?<=[.!?])\s+/);
|
||||
return sentences.map((s) => `• ${s.trim()}`).join('\n');
|
||||
}
|
||||
|
||||
private convertToNumbered(content: string): string {
|
||||
const sentences = content.split(/(?<=[.!?])\s+/);
|
||||
return sentences.map((s, i) => `${i + 1}. ${s.trim()}`).join('\n');
|
||||
}
|
||||
}
|
||||
74
src/modules/credits/credits.controller.ts
Normal file
74
src/modules/credits/credits.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
// Credits Controller - API endpoints for credit management
|
||||
// Path: src/modules/credits/credits.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
} from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { CreditsService, CREDIT_COSTS, PLAN_LIMITS } from './credits.service';
|
||||
import { CurrentUser } from '../../common/decorators';
|
||||
import { CreditCategory, CreditTransactionType } from '@prisma/client';
|
||||
|
||||
@ApiTags('credits')
|
||||
@ApiBearerAuth()
|
||||
@Controller('credits')
|
||||
export class CreditsController {
|
||||
constructor(private readonly creditsService: CreditsService) { }
|
||||
|
||||
@Get('balance')
|
||||
@ApiOperation({ summary: 'Get current credit balance' })
|
||||
async getBalance(@CurrentUser('id') userId: string) {
|
||||
return this.creditsService.getBalance(userId);
|
||||
}
|
||||
|
||||
@Get('history')
|
||||
@ApiOperation({ summary: 'Get credit transaction history' })
|
||||
async getHistory(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
|
||||
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
|
||||
@Query('category') category?: CreditCategory,
|
||||
@Query('type') type?: CreditTransactionType,
|
||||
) {
|
||||
return this.creditsService.getTransactionHistory(userId, {
|
||||
limit,
|
||||
offset,
|
||||
category,
|
||||
type,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('costs')
|
||||
@ApiOperation({ summary: 'Get credit costs for all actions' })
|
||||
getCosts() {
|
||||
return {
|
||||
costs: CREDIT_COSTS,
|
||||
plans: PLAN_LIMITS,
|
||||
};
|
||||
}
|
||||
|
||||
@Post('check')
|
||||
@ApiOperation({ summary: 'Check if user has enough credits for an action' })
|
||||
async checkCredits(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body('category') category: CreditCategory,
|
||||
) {
|
||||
const hasEnough = await this.creditsService.hasEnoughCredits(
|
||||
userId,
|
||||
category,
|
||||
);
|
||||
const cost = this.creditsService.getCreditCost(category);
|
||||
|
||||
return {
|
||||
hasEnough,
|
||||
cost,
|
||||
category,
|
||||
};
|
||||
}
|
||||
}
|
||||
13
src/modules/credits/credits.module.ts
Normal file
13
src/modules/credits/credits.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Credits Module - Handles user credits, transactions, and plan-based limits
|
||||
// Path: src/modules/credits/credits.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CreditsService } from './credits.service';
|
||||
import { CreditsController } from './credits.controller';
|
||||
|
||||
@Module({
|
||||
providers: [CreditsService],
|
||||
controllers: [CreditsController],
|
||||
exports: [CreditsService],
|
||||
})
|
||||
export class CreditsModule {}
|
||||
337
src/modules/credits/credits.service.ts
Normal file
337
src/modules/credits/credits.service.ts
Normal file
@@ -0,0 +1,337 @@
|
||||
// Credits Service - Business logic for credit management
|
||||
// Path: src/modules/credits/credits.service.ts
|
||||
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { CreditTransactionType, CreditCategory, UserPlan } from '@prisma/client';
|
||||
|
||||
// Credit costs for each action
|
||||
export const CREDIT_COSTS: Record<CreditCategory, number> = {
|
||||
TREND_SCAN: 1,
|
||||
DEEP_RESEARCH: 5,
|
||||
MASTER_CONTENT: 3,
|
||||
BUILDING_BLOCKS: 1,
|
||||
PLATFORM_CONTENT: 1,
|
||||
IMAGE_GENERATION: 2,
|
||||
VIDEO_SCRIPT: 3,
|
||||
THUMBNAIL: 2,
|
||||
SEO_OPTIMIZATION: 1,
|
||||
NEURO_ANALYSIS: 1,
|
||||
SOURCE_ANALYSIS: 1,
|
||||
AUTO_PUBLISH: 1,
|
||||
};
|
||||
|
||||
// Monthly credit limits per plan
|
||||
export const PLAN_LIMITS: Record<UserPlan, number> = {
|
||||
FREE: 50,
|
||||
STARTER: 200,
|
||||
PRO: 500,
|
||||
ULTIMATE: 2000,
|
||||
ENTERPRISE: -1, // Unlimited
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CreditsService {
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
/**
|
||||
* Get user's current credit balance
|
||||
*/
|
||||
async getBalance(userId: string): Promise<{
|
||||
credits: number;
|
||||
plan: UserPlan;
|
||||
monthlyLimit: number;
|
||||
resetsAt: Date | null;
|
||||
}> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
credits: true,
|
||||
plan: true,
|
||||
creditsResetAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
return {
|
||||
credits: user.credits,
|
||||
plan: user.plan,
|
||||
monthlyLimit: PLAN_LIMITS[user.plan],
|
||||
resetsAt: user.creditsResetAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has enough credits for an action
|
||||
*/
|
||||
async hasEnoughCredits(
|
||||
userId: string,
|
||||
category: CreditCategory,
|
||||
): Promise<boolean> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true, plan: true },
|
||||
});
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
// Enterprise has unlimited credits
|
||||
if (user.plan === 'ENTERPRISE') return true;
|
||||
|
||||
return user.credits >= CREDIT_COSTS[category];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spend credits for an action
|
||||
*/
|
||||
async spendCredits(
|
||||
userId: string,
|
||||
category: CreditCategory,
|
||||
description?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<{ success: boolean; remainingCredits: number }> {
|
||||
const cost = CREDIT_COSTS[category];
|
||||
|
||||
// Check balance
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true, plan: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
// Enterprise has unlimited credits
|
||||
if (user.plan !== 'ENTERPRISE' && user.credits < cost) {
|
||||
throw new BadRequestException('INSUFFICIENT_CREDITS');
|
||||
}
|
||||
|
||||
// Atomic transaction: deduct credits and create log
|
||||
const [updatedUser] = await this.prisma.$transaction([
|
||||
// Deduct credits (skip for Enterprise)
|
||||
this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
credits:
|
||||
user.plan === 'ENTERPRISE'
|
||||
? user.credits
|
||||
: { decrement: cost },
|
||||
},
|
||||
select: { credits: true },
|
||||
}),
|
||||
// Create transaction log
|
||||
this.prisma.creditTransaction.create({
|
||||
data: {
|
||||
userId,
|
||||
type: CreditTransactionType.SPEND,
|
||||
amount: -cost,
|
||||
category,
|
||||
description: description ?? `Credit spent for ${category}`,
|
||||
balanceAfter:
|
||||
user.plan === 'ENTERPRISE' ? user.credits : user.credits - cost,
|
||||
metadata: metadata as any,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
remainingCredits: updatedUser.credits,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add credits to user account
|
||||
*/
|
||||
async addCredits(
|
||||
userId: string,
|
||||
amount: number,
|
||||
type: CreditTransactionType,
|
||||
description?: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): Promise<{ success: boolean; newBalance: number }> {
|
||||
if (amount <= 0) {
|
||||
throw new BadRequestException('INVALID_CREDIT_AMOUNT');
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { credits: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
const [updatedUser] = await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { credits: { increment: amount } },
|
||||
select: { credits: true },
|
||||
}),
|
||||
this.prisma.creditTransaction.create({
|
||||
data: {
|
||||
userId,
|
||||
type,
|
||||
amount,
|
||||
description: description ?? `Credits added: ${type}`,
|
||||
balanceAfter: user.credits + amount,
|
||||
metadata: metadata as any,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newBalance: updatedUser.credits,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's credit transaction history
|
||||
*/
|
||||
async getTransactionHistory(
|
||||
userId: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
category?: CreditCategory;
|
||||
type?: CreditTransactionType;
|
||||
},
|
||||
) {
|
||||
const { limit = 20, offset = 0, category, type } = options ?? {};
|
||||
|
||||
const where: any = { userId };
|
||||
if (category) where.category = category;
|
||||
if (type) where.type = type;
|
||||
|
||||
const [transactions, total] = await Promise.all([
|
||||
this.prisma.creditTransaction.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
this.prisma.creditTransaction.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
transactions,
|
||||
total,
|
||||
hasMore: offset + transactions.length < total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset monthly credits for a user (called by cron or on subscription renewal)
|
||||
*/
|
||||
async resetMonthlyCredits(userId: string): Promise<void> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { plan: true, credits: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
const monthlyLimit = PLAN_LIMITS[user.plan];
|
||||
if (monthlyLimit === -1) return; // Enterprise doesn't reset
|
||||
|
||||
const nextResetDate = new Date();
|
||||
nextResetDate.setMonth(nextResetDate.getMonth() + 1);
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
credits: monthlyLimit,
|
||||
creditsResetAt: nextResetDate,
|
||||
},
|
||||
}),
|
||||
this.prisma.creditTransaction.create({
|
||||
data: {
|
||||
userId,
|
||||
type: CreditTransactionType.RESET,
|
||||
amount: monthlyLimit - user.credits,
|
||||
description: 'Monthly credit reset',
|
||||
balanceAfter: monthlyLimit,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrade user plan and adjust credits
|
||||
*/
|
||||
async upgradePlan(
|
||||
userId: string,
|
||||
newPlan: UserPlan,
|
||||
): Promise<{ success: boolean; newCredits: number }> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { plan: true, credits: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
const currentLimit = PLAN_LIMITS[user.plan];
|
||||
const newLimit = PLAN_LIMITS[newPlan];
|
||||
|
||||
// Calculate bonus credits (difference between plans)
|
||||
const bonusCredits =
|
||||
newLimit === -1 ? 0 : Math.max(0, newLimit - currentLimit);
|
||||
|
||||
const [updatedUser] = await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
plan: newPlan,
|
||||
credits:
|
||||
newLimit === -1
|
||||
? user.credits
|
||||
: { increment: bonusCredits },
|
||||
},
|
||||
select: { credits: true },
|
||||
}),
|
||||
this.prisma.creditTransaction.create({
|
||||
data: {
|
||||
userId,
|
||||
type: CreditTransactionType.BONUS,
|
||||
amount: bonusCredits,
|
||||
description: `Plan upgraded to ${newPlan}`,
|
||||
balanceAfter: user.credits + bonusCredits,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newCredits: updatedUser.credits,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get credit cost for an action
|
||||
*/
|
||||
getCreditCost(category: CreditCategory): number {
|
||||
return CREDIT_COSTS[category];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all plan limits
|
||||
*/
|
||||
getPlanLimits(): Record<UserPlan, number> {
|
||||
return PLAN_LIMITS;
|
||||
}
|
||||
}
|
||||
4
src/modules/credits/index.ts
Normal file
4
src/modules/credits/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Credits Module Index
|
||||
export * from './credits.module';
|
||||
export * from './credits.service';
|
||||
export * from './credits.controller';
|
||||
@@ -56,6 +56,8 @@ export class GeminiService implements OnModuleInit {
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
this.logger.log(`Initializing GeminiService. isEnabled: ${this.isEnabled}`);
|
||||
|
||||
if (!this.isEnabled) {
|
||||
this.logger.log(
|
||||
'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.',
|
||||
@@ -64,6 +66,8 @@ export class GeminiService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const apiKey = this.configService.get<string>('gemini.apiKey');
|
||||
this.logger.log(`API Key found: ${!!apiKey}`);
|
||||
|
||||
if (!apiKey) {
|
||||
this.logger.warn(
|
||||
'GOOGLE_API_KEY is not set. Gemini features will not work.',
|
||||
|
||||
196
src/modules/i18n/i18n.controller.ts
Normal file
196
src/modules/i18n/i18n.controller.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// i18n Controller - API endpoints
|
||||
// Path: src/modules/i18n/i18n.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
Headers,
|
||||
} from '@nestjs/common';
|
||||
import { I18nService, SupportedLocale } from './i18n.service';
|
||||
import { TranslationService } from './services/translation.service';
|
||||
import { TranslationManagementService } from './services/translation-management.service';
|
||||
|
||||
@Controller('i18n')
|
||||
export class I18nController {
|
||||
constructor(
|
||||
private readonly i18nService: I18nService,
|
||||
private readonly translationService: TranslationService,
|
||||
private readonly managementService: TranslationManagementService,
|
||||
) { }
|
||||
|
||||
// ========== LOCALES ==========
|
||||
|
||||
@Get('locales')
|
||||
getLocales() {
|
||||
return this.i18nService.getSupportedLocales();
|
||||
}
|
||||
|
||||
@Get('locales/:code')
|
||||
getLocaleConfig(@Param('code') code: string) {
|
||||
return this.i18nService.getLocaleConfig(code as SupportedLocale);
|
||||
}
|
||||
|
||||
// ========== TRANSLATIONS ==========
|
||||
|
||||
@Get('translations/:locale')
|
||||
getTranslations(@Param('locale') locale: string) {
|
||||
return this.translationService.getTranslationsForLocale(locale);
|
||||
}
|
||||
|
||||
@Get('translate')
|
||||
translate(
|
||||
@Query('key') key: string,
|
||||
@Query('locale') locale = 'en',
|
||||
@Query('args') args?: string,
|
||||
) {
|
||||
const parsedArgs = args ? JSON.parse(args) : undefined;
|
||||
return { translation: this.i18nService.t(key, parsedArgs, locale as SupportedLocale) };
|
||||
}
|
||||
|
||||
// ========== MANAGEMENT - KEYS ==========
|
||||
|
||||
@Post('admin/keys')
|
||||
createKey(@Body() body: { key: string; namespace: string; description?: string; context?: string }) {
|
||||
return this.managementService.createKey(body);
|
||||
}
|
||||
|
||||
@Get('admin/keys')
|
||||
listKeys(
|
||||
@Query('namespace') namespace?: string,
|
||||
@Query('search') search?: string,
|
||||
@Query('page') page = '1',
|
||||
@Query('limit') limit = '20',
|
||||
) {
|
||||
return this.managementService.listKeys({
|
||||
namespace,
|
||||
search,
|
||||
page: parseInt(page, 10),
|
||||
limit: parseInt(limit, 10),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('admin/keys/:keyId')
|
||||
getKey(@Param('keyId') keyId: string) {
|
||||
return this.managementService.getKey(keyId);
|
||||
}
|
||||
|
||||
@Put('admin/keys/:keyId')
|
||||
updateKey(@Param('keyId') keyId: string, @Body() body: { description?: string; context?: string }) {
|
||||
return this.managementService.updateKey(keyId, body);
|
||||
}
|
||||
|
||||
@Delete('admin/keys/:keyId')
|
||||
deleteKey(@Param('keyId') keyId: string) {
|
||||
return { success: this.managementService.deleteKey(keyId) };
|
||||
}
|
||||
|
||||
// ========== MANAGEMENT - TRANSLATIONS ==========
|
||||
|
||||
@Post('admin/keys/:keyId/translations')
|
||||
setTranslation(
|
||||
@Param('keyId') keyId: string,
|
||||
@Body() body: { locale: string; value: string; status?: 'draft' | 'review' | 'approved' | 'published' },
|
||||
) {
|
||||
return this.managementService.setTranslation(keyId, body.locale, body.value, body.status);
|
||||
}
|
||||
|
||||
@Post('admin/translations/bulk')
|
||||
bulkSetTranslations(@Body() body: { translations: Array<{ keyId: string; locale: string; value: string; status?: 'draft' | 'review' | 'approved' | 'published' }> }) {
|
||||
const count = this.managementService.bulkSetTranslations(body.translations);
|
||||
return { updated: count };
|
||||
}
|
||||
|
||||
@Put('admin/translations/:recordId/status')
|
||||
updateTranslationStatus(
|
||||
@Param('recordId') recordId: string,
|
||||
@Body() body: { status: 'draft' | 'review' | 'approved' | 'published'; reviewedBy?: string },
|
||||
) {
|
||||
return this.managementService.updateTranslationStatus(recordId, body.status, body.reviewedBy);
|
||||
}
|
||||
|
||||
@Get('admin/missing/:locale')
|
||||
getMissingTranslations(@Param('locale') locale: string) {
|
||||
return this.managementService.getMissingTranslations(locale);
|
||||
}
|
||||
|
||||
// ========== IMPORT/EXPORT ==========
|
||||
|
||||
@Get('admin/export/:locale')
|
||||
exportTranslations(
|
||||
@Param('locale') locale: string,
|
||||
@Query('namespace') namespace?: string,
|
||||
@Query('format') format: 'json' | 'csv' | 'xliff' | 'po' = 'json',
|
||||
) {
|
||||
return this.managementService.exportTranslations(locale, { namespace, format });
|
||||
}
|
||||
|
||||
@Post('admin/import/:locale')
|
||||
importTranslations(
|
||||
@Param('locale') locale: string,
|
||||
@Body() body: { data: string; format: 'json' | 'csv' },
|
||||
) {
|
||||
const count = this.managementService.importTranslations(locale, body.data, body.format);
|
||||
return { imported: count };
|
||||
}
|
||||
|
||||
// ========== STATISTICS ==========
|
||||
|
||||
@Get('admin/stats')
|
||||
getStats() {
|
||||
return this.managementService.getStats();
|
||||
}
|
||||
|
||||
@Get('admin/namespaces')
|
||||
getNamespaces() {
|
||||
return this.managementService.getNamespaces();
|
||||
}
|
||||
|
||||
@Put('admin/namespaces/:oldName')
|
||||
renameNamespace(@Param('oldName') oldName: string, @Body() body: { newName: string }) {
|
||||
const count = this.managementService.renameNamespace(oldName, body.newName);
|
||||
return { renamed: count };
|
||||
}
|
||||
|
||||
// ========== FORMATTING ==========
|
||||
|
||||
@Get('format/date')
|
||||
formatDate(
|
||||
@Query('date') dateStr: string,
|
||||
@Query('locale') locale = 'en',
|
||||
@Query('format') format?: string,
|
||||
) {
|
||||
const date = new Date(dateStr);
|
||||
return { formatted: this.i18nService.formatDate(date, locale as SupportedLocale, format) };
|
||||
}
|
||||
|
||||
@Get('format/number')
|
||||
formatNumber(
|
||||
@Query('value') valueStr: string,
|
||||
@Query('locale') locale = 'en',
|
||||
) {
|
||||
const value = parseFloat(valueStr);
|
||||
return { formatted: this.i18nService.formatNumber(value, locale as SupportedLocale) };
|
||||
}
|
||||
|
||||
@Get('format/currency')
|
||||
formatCurrency(
|
||||
@Query('value') valueStr: string,
|
||||
@Query('locale') locale = 'en',
|
||||
@Query('currency') currency?: string,
|
||||
) {
|
||||
const value = parseFloat(valueStr);
|
||||
return { formatted: this.i18nService.formatCurrency(value, currency, locale as SupportedLocale) };
|
||||
}
|
||||
|
||||
@Get('format/relative')
|
||||
formatRelativeTime(@Query('date') dateStr: string, @Query('locale') locale = 'en') {
|
||||
const date = new Date(dateStr);
|
||||
return { formatted: this.i18nService.formatRelativeTime(date, locale as SupportedLocale) };
|
||||
}
|
||||
}
|
||||
18
src/modules/i18n/i18n.module.ts
Normal file
18
src/modules/i18n/i18n.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// i18n Module - Backend internationalization
|
||||
// Path: src/modules/i18n/i18n.module.ts
|
||||
|
||||
import { Module, Global } from '@nestjs/common';
|
||||
import { I18nService } from './i18n.service';
|
||||
import { TranslationService } from './services/translation.service';
|
||||
import { TranslationManagementService } from './services/translation-management.service';
|
||||
import { I18nController } from './i18n.controller';
|
||||
import { PrismaModule } from '../../database/prisma.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [I18nService, TranslationService, TranslationManagementService],
|
||||
controllers: [I18nController],
|
||||
exports: [I18nService, TranslationService],
|
||||
})
|
||||
export class ContentHunterI18nModule { }
|
||||
290
src/modules/i18n/i18n.service.ts
Normal file
290
src/modules/i18n/i18n.service.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
// i18n Service - Main internationalization service
|
||||
// Path: src/modules/i18n/i18n.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TranslationService } from './services/translation.service';
|
||||
|
||||
export type SupportedLocale =
|
||||
| 'en' | 'en-US' | 'en-GB'
|
||||
| 'tr' | 'tr-TR'
|
||||
| 'es' | 'es-ES' | 'es-MX'
|
||||
| 'fr' | 'fr-FR'
|
||||
| 'de' | 'de-DE'
|
||||
| 'zh' | 'zh-CN' | 'zh-TW'
|
||||
| 'pt' | 'pt-BR' | 'pt-PT'
|
||||
| 'ar' | 'ar-SA'
|
||||
| 'ru' | 'ru-RU'
|
||||
| 'ja' | 'ja-JP';
|
||||
|
||||
export interface LocaleConfig {
|
||||
code: SupportedLocale;
|
||||
name: string;
|
||||
nativeName: string;
|
||||
direction: 'ltr' | 'rtl';
|
||||
dateFormat: string;
|
||||
timeFormat: string;
|
||||
numberFormat: {
|
||||
decimal: string;
|
||||
thousands: string;
|
||||
currency: string;
|
||||
};
|
||||
pluralRules: 'one_other' | 'one_few_many_other' | 'zero_one_other' | 'complex';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class I18nService {
|
||||
private readonly logger = new Logger(I18nService.name);
|
||||
private currentLocale: SupportedLocale = 'en';
|
||||
private fallbackLocale: SupportedLocale = 'en';
|
||||
|
||||
private readonly localeConfigs: Record<string, LocaleConfig> = {
|
||||
en: {
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
nativeName: 'English',
|
||||
direction: 'ltr',
|
||||
dateFormat: 'MM/DD/YYYY',
|
||||
timeFormat: 'h:mm A',
|
||||
numberFormat: { decimal: '.', thousands: ',', currency: 'USD' },
|
||||
pluralRules: 'one_other',
|
||||
},
|
||||
tr: {
|
||||
code: 'tr',
|
||||
name: 'Turkish',
|
||||
nativeName: 'Türkçe',
|
||||
direction: 'ltr',
|
||||
dateFormat: 'DD.MM.YYYY',
|
||||
timeFormat: 'HH:mm',
|
||||
numberFormat: { decimal: ',', thousands: '.', currency: 'TRY' },
|
||||
pluralRules: 'one_other',
|
||||
},
|
||||
es: {
|
||||
code: 'es',
|
||||
name: 'Spanish',
|
||||
nativeName: 'Español',
|
||||
direction: 'ltr',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
timeFormat: 'HH:mm',
|
||||
numberFormat: { decimal: ',', thousands: '.', currency: 'EUR' },
|
||||
pluralRules: 'one_other',
|
||||
},
|
||||
fr: {
|
||||
code: 'fr',
|
||||
name: 'French',
|
||||
nativeName: 'Français',
|
||||
direction: 'ltr',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
timeFormat: 'HH:mm',
|
||||
numberFormat: { decimal: ',', thousands: ' ', currency: 'EUR' },
|
||||
pluralRules: 'one_other',
|
||||
},
|
||||
de: {
|
||||
code: 'de',
|
||||
name: 'German',
|
||||
nativeName: 'Deutsch',
|
||||
direction: 'ltr',
|
||||
dateFormat: 'DD.MM.YYYY',
|
||||
timeFormat: 'HH:mm',
|
||||
numberFormat: { decimal: ',', thousands: '.', currency: 'EUR' },
|
||||
pluralRules: 'one_other',
|
||||
},
|
||||
zh: {
|
||||
code: 'zh',
|
||||
name: 'Chinese',
|
||||
nativeName: '中文',
|
||||
direction: 'ltr',
|
||||
dateFormat: 'YYYY-MM-DD',
|
||||
timeFormat: 'HH:mm',
|
||||
numberFormat: { decimal: '.', thousands: ',', currency: 'CNY' },
|
||||
pluralRules: 'one_other',
|
||||
},
|
||||
pt: {
|
||||
code: 'pt',
|
||||
name: 'Portuguese',
|
||||
nativeName: 'Português',
|
||||
direction: 'ltr',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
timeFormat: 'HH:mm',
|
||||
numberFormat: { decimal: ',', thousands: '.', currency: 'BRL' },
|
||||
pluralRules: 'one_other',
|
||||
},
|
||||
ar: {
|
||||
code: 'ar',
|
||||
name: 'Arabic',
|
||||
nativeName: 'العربية',
|
||||
direction: 'rtl',
|
||||
dateFormat: 'DD/MM/YYYY',
|
||||
timeFormat: 'HH:mm',
|
||||
numberFormat: { decimal: '٫', thousands: '٬', currency: 'SAR' },
|
||||
pluralRules: 'complex',
|
||||
},
|
||||
ru: {
|
||||
code: 'ru',
|
||||
name: 'Russian',
|
||||
nativeName: 'Русский',
|
||||
direction: 'ltr',
|
||||
dateFormat: 'DD.MM.YYYY',
|
||||
timeFormat: 'HH:mm',
|
||||
numberFormat: { decimal: ',', thousands: ' ', currency: 'RUB' },
|
||||
pluralRules: 'one_few_many_other',
|
||||
},
|
||||
ja: {
|
||||
code: 'ja',
|
||||
name: 'Japanese',
|
||||
nativeName: '日本語',
|
||||
direction: 'ltr',
|
||||
dateFormat: 'YYYY/MM/DD',
|
||||
timeFormat: 'HH:mm',
|
||||
numberFormat: { decimal: '.', thousands: ',', currency: 'JPY' },
|
||||
pluralRules: 'one_other',
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private readonly translationService: TranslationService) { }
|
||||
|
||||
/**
|
||||
* Set current locale
|
||||
*/
|
||||
setLocale(locale: SupportedLocale): void {
|
||||
if (this.isSupported(locale)) {
|
||||
this.currentLocale = this.normalizeLocale(locale as any) as SupportedLocale;
|
||||
this.logger.debug(`Locale set to: ${this.currentLocale}`);
|
||||
} else {
|
||||
this.logger.warn(`Unsupported locale: ${locale}, falling back to ${this.fallbackLocale}`);
|
||||
this.currentLocale = this.fallbackLocale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current locale
|
||||
*/
|
||||
getLocale(): SupportedLocale {
|
||||
return this.currentLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get locale configuration
|
||||
*/
|
||||
getLocaleConfig(locale?: SupportedLocale): LocaleConfig | undefined {
|
||||
const targetLocale = locale ? this.normalizeLocale(locale) : this.currentLocale;
|
||||
return this.localeConfigs[targetLocale];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if locale is supported
|
||||
*/
|
||||
isSupported(locale: string): boolean {
|
||||
const normalized = this.normalizeLocale(locale as SupportedLocale);
|
||||
return normalized in this.localeConfigs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported locales
|
||||
*/
|
||||
getSupportedLocales(): LocaleConfig[] {
|
||||
return Object.values(this.localeConfigs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize locale code (e.g., 'en-US' -> 'en')
|
||||
*/
|
||||
private normalizeLocale(locale: SupportedLocale): string {
|
||||
return locale.split('-')[0].toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key
|
||||
*/
|
||||
t(key: string, args?: Record<string, any>, locale?: SupportedLocale): string {
|
||||
const targetLocale = locale || this.currentLocale;
|
||||
return this.translationService.translate(key, targetLocale, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate with plural support
|
||||
*/
|
||||
tp(key: string, count: number, args?: Record<string, any>, locale?: SupportedLocale): string {
|
||||
const targetLocale = locale || this.currentLocale;
|
||||
return this.translationService.translatePlural(key, count, targetLocale, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date according to locale
|
||||
*/
|
||||
formatDate(date: Date, locale?: SupportedLocale, format?: string): string {
|
||||
const config = this.getLocaleConfig(locale);
|
||||
if (!config) return date.toLocaleDateString();
|
||||
|
||||
const useFormat = format || config.dateFormat;
|
||||
return this.formatDateString(date, useFormat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format number according to locale
|
||||
*/
|
||||
formatNumber(value: number, locale?: SupportedLocale, options?: Intl.NumberFormatOptions): string {
|
||||
const config = this.getLocaleConfig(locale);
|
||||
if (!config) return value.toString();
|
||||
|
||||
return new Intl.NumberFormat(config.code, options).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency according to locale
|
||||
*/
|
||||
formatCurrency(value: number, currency?: string, locale?: SupportedLocale): string {
|
||||
const config = this.getLocaleConfig(locale);
|
||||
if (!config) return value.toString();
|
||||
|
||||
return new Intl.NumberFormat(config.code, {
|
||||
style: 'currency',
|
||||
currency: currency || config.numberFormat.currency,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time
|
||||
*/
|
||||
formatRelativeTime(date: Date, locale?: SupportedLocale): string {
|
||||
const config = this.getLocaleConfig(locale);
|
||||
if (!config) return date.toLocaleString();
|
||||
|
||||
const rtf = new Intl.RelativeTimeFormat(config.code, { numeric: 'auto' });
|
||||
const now = new Date();
|
||||
const diffMs = date.getTime() - now.getTime();
|
||||
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (Math.abs(diffDays) < 1) {
|
||||
const diffHours = Math.round(diffMs / (1000 * 60 * 60));
|
||||
if (Math.abs(diffHours) < 1) {
|
||||
const diffMinutes = Math.round(diffMs / (1000 * 60));
|
||||
return rtf.format(diffMinutes, 'minute');
|
||||
}
|
||||
return rtf.format(diffHours, 'hour');
|
||||
}
|
||||
|
||||
return rtf.format(diffDays, 'day');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text direction for locale
|
||||
*/
|
||||
getDirection(locale?: SupportedLocale): 'ltr' | 'rtl' {
|
||||
const config = this.getLocaleConfig(locale);
|
||||
return config?.direction || 'ltr';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date string according to pattern
|
||||
*/
|
||||
private formatDateString(date: Date, format: string): string {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
||||
const year = date.getFullYear().toString();
|
||||
|
||||
return format
|
||||
.replace('DD', day)
|
||||
.replace('MM', month)
|
||||
.replace('YYYY', year);
|
||||
}
|
||||
}
|
||||
8
src/modules/i18n/index.ts
Normal file
8
src/modules/i18n/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// i18n Module - Index exports
|
||||
// Path: src/modules/i18n/index.ts
|
||||
|
||||
export * from './i18n.module';
|
||||
export * from './i18n.service';
|
||||
export * from './i18n.controller';
|
||||
export * from './services/translation.service';
|
||||
export * from './services/translation-management.service';
|
||||
512
src/modules/i18n/services/translation-management.service.ts
Normal file
512
src/modules/i18n/services/translation-management.service.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
// Translation Management Service - Admin translation management
|
||||
// Path: src/modules/i18n/services/translation-management.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
|
||||
export interface TranslationKey {
|
||||
id: string;
|
||||
key: string;
|
||||
namespace: string;
|
||||
description?: string;
|
||||
context?: string;
|
||||
translations: TranslationRecord[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TranslationRecord {
|
||||
id: string;
|
||||
keyId: string;
|
||||
locale: string;
|
||||
value: string;
|
||||
status: 'draft' | 'review' | 'approved' | 'published';
|
||||
translatedBy?: string;
|
||||
reviewedBy?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface TranslationExport {
|
||||
locale: string;
|
||||
namespace?: string;
|
||||
format: 'json' | 'csv' | 'xliff' | 'po';
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface TranslationStats {
|
||||
totalKeys: number;
|
||||
byNamespace: Record<string, number>;
|
||||
byLocale: Record<string, {
|
||||
translated: number;
|
||||
pending: number;
|
||||
percentage: number;
|
||||
}>;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TranslationManagementService {
|
||||
private readonly logger = new Logger(TranslationManagementService.name);
|
||||
|
||||
// In-memory storage (in production, use database)
|
||||
private readonly translationKeys = new Map<string, TranslationKey>();
|
||||
private readonly translationRecords = new Map<string, TranslationRecord>();
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {
|
||||
this.initializeDefaultKeys();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize default translation keys
|
||||
*/
|
||||
private initializeDefaultKeys(): void {
|
||||
const defaultNamespaces = ['common', 'auth', 'content', 'analytics', 'errors'];
|
||||
const defaultLocales = ['en', 'tr', 'es', 'fr', 'de', 'zh', 'pt', 'ar', 'ru', 'ja'];
|
||||
|
||||
let keyIndex = 0;
|
||||
for (const namespace of defaultNamespaces) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const keyId = `key-${keyIndex++}`;
|
||||
const key: TranslationKey = {
|
||||
id: keyId,
|
||||
key: `${namespace}.item${i}`,
|
||||
namespace,
|
||||
description: `Translation for ${namespace} item ${i}`,
|
||||
translations: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Create translation records for each locale
|
||||
for (const locale of defaultLocales) {
|
||||
const recordId = `record-${keyId}-${locale}`;
|
||||
const record: TranslationRecord = {
|
||||
id: recordId,
|
||||
keyId,
|
||||
locale,
|
||||
value: `[${locale.toUpperCase()}] ${namespace}.item${i}`,
|
||||
status: 'published',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
this.translationRecords.set(recordId, record);
|
||||
key.translations.push(record);
|
||||
}
|
||||
|
||||
this.translationKeys.set(keyId, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== KEY MANAGEMENT ==========
|
||||
|
||||
/**
|
||||
* Create a new translation key
|
||||
*/
|
||||
createKey(input: {
|
||||
key: string;
|
||||
namespace: string;
|
||||
description?: string;
|
||||
context?: string;
|
||||
}): TranslationKey {
|
||||
const id = `key-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
const translationKey: TranslationKey = {
|
||||
id,
|
||||
key: input.key,
|
||||
namespace: input.namespace,
|
||||
description: input.description,
|
||||
context: input.context,
|
||||
translations: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.translationKeys.set(id, translationKey);
|
||||
return translationKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a translation key by ID
|
||||
*/
|
||||
getKey(keyId: string): TranslationKey | null {
|
||||
return this.translationKeys.get(keyId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get key by key string
|
||||
*/
|
||||
getKeyByString(keyString: string): TranslationKey | null {
|
||||
for (const key of this.translationKeys.values()) {
|
||||
if (key.key === keyString) {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all keys with filtering
|
||||
*/
|
||||
listKeys(options?: {
|
||||
namespace?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): { keys: TranslationKey[]; total: number } {
|
||||
let keys = Array.from(this.translationKeys.values());
|
||||
|
||||
// Filter by namespace
|
||||
if (options?.namespace) {
|
||||
keys = keys.filter(k => k.namespace === options.namespace);
|
||||
}
|
||||
|
||||
// Filter by search
|
||||
if (options?.search) {
|
||||
const search = options.search.toLowerCase();
|
||||
keys = keys.filter(k =>
|
||||
k.key.toLowerCase().includes(search) ||
|
||||
k.description?.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// Paginate
|
||||
const page = options?.page || 1;
|
||||
const limit = options?.limit || 20;
|
||||
const start = (page - 1) * limit;
|
||||
const paginatedKeys = keys.slice(start, start + limit);
|
||||
|
||||
return { keys: paginatedKeys, total: keys.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a translation key
|
||||
*/
|
||||
updateKey(keyId: string, updates: Partial<Pick<TranslationKey, 'description' | 'context'>>): TranslationKey | null {
|
||||
const key = this.translationKeys.get(keyId);
|
||||
if (!key) return null;
|
||||
|
||||
if (updates.description !== undefined) key.description = updates.description;
|
||||
if (updates.context !== undefined) key.context = updates.context;
|
||||
key.updatedAt = new Date();
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a translation key
|
||||
*/
|
||||
deleteKey(keyId: string): boolean {
|
||||
const key = this.translationKeys.get(keyId);
|
||||
if (!key) return false;
|
||||
|
||||
// Delete all translation records
|
||||
for (const record of key.translations) {
|
||||
this.translationRecords.delete(record.id);
|
||||
}
|
||||
|
||||
return this.translationKeys.delete(keyId);
|
||||
}
|
||||
|
||||
// ========== TRANSLATION MANAGEMENT ==========
|
||||
|
||||
/**
|
||||
* Add or update translation for a key
|
||||
*/
|
||||
setTranslation(keyId: string, locale: string, value: string, status: TranslationRecord['status'] = 'draft'): TranslationRecord | null {
|
||||
const key = this.translationKeys.get(keyId);
|
||||
if (!key) return null;
|
||||
|
||||
// Find existing record
|
||||
const existingIndex = key.translations.findIndex(t => t.locale === locale);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing
|
||||
const record = key.translations[existingIndex];
|
||||
record.value = value;
|
||||
record.status = status;
|
||||
record.updatedAt = new Date();
|
||||
return record;
|
||||
} else {
|
||||
// Create new
|
||||
const recordId = `record-${keyId}-${locale}`;
|
||||
const record: TranslationRecord = {
|
||||
id: recordId,
|
||||
keyId,
|
||||
locale,
|
||||
value,
|
||||
status,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
key.translations.push(record);
|
||||
this.translationRecords.set(recordId, record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update translations
|
||||
*/
|
||||
bulkSetTranslations(translations: Array<{
|
||||
keyId: string;
|
||||
locale: string;
|
||||
value: string;
|
||||
status?: TranslationRecord['status'];
|
||||
}>): number {
|
||||
let updated = 0;
|
||||
for (const t of translations) {
|
||||
const result = this.setTranslation(t.keyId, t.locale, t.value, t.status);
|
||||
if (result) updated++;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update translation status
|
||||
*/
|
||||
updateTranslationStatus(recordId: string, status: TranslationRecord['status'], reviewedBy?: string): TranslationRecord | null {
|
||||
const record = this.translationRecords.get(recordId);
|
||||
if (!record) return null;
|
||||
|
||||
record.status = status;
|
||||
record.updatedAt = new Date();
|
||||
if (reviewedBy) record.reviewedBy = reviewedBy;
|
||||
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get missing translations for a locale
|
||||
*/
|
||||
getMissingTranslations(locale: string): TranslationKey[] {
|
||||
const missing: TranslationKey[] = [];
|
||||
|
||||
for (const key of this.translationKeys.values()) {
|
||||
const hasTranslation = key.translations.some(t => t.locale === locale && t.value);
|
||||
if (!hasTranslation) {
|
||||
missing.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
// ========== IMPORT/EXPORT ==========
|
||||
|
||||
/**
|
||||
* Export translations
|
||||
*/
|
||||
exportTranslations(locale: string, options?: {
|
||||
namespace?: string;
|
||||
format?: 'json' | 'csv' | 'xliff' | 'po';
|
||||
includeMetadata?: boolean;
|
||||
}): TranslationExport {
|
||||
const format = options?.format || 'json';
|
||||
let keys = Array.from(this.translationKeys.values());
|
||||
|
||||
if (options?.namespace) {
|
||||
keys = keys.filter(k => k.namespace === options.namespace);
|
||||
}
|
||||
|
||||
const translations: Record<string, string> = {};
|
||||
for (const key of keys) {
|
||||
const record = key.translations.find(t => t.locale === locale);
|
||||
if (record) {
|
||||
translations[key.key] = record.value;
|
||||
}
|
||||
}
|
||||
|
||||
let data: string;
|
||||
switch (format) {
|
||||
case 'json':
|
||||
data = JSON.stringify(translations, null, 2);
|
||||
break;
|
||||
case 'csv':
|
||||
data = this.toCSV(translations);
|
||||
break;
|
||||
case 'xliff':
|
||||
data = this.toXLIFF(translations, locale);
|
||||
break;
|
||||
case 'po':
|
||||
data = this.toPO(translations);
|
||||
break;
|
||||
default:
|
||||
data = JSON.stringify(translations, null, 2);
|
||||
}
|
||||
|
||||
return { locale, namespace: options?.namespace, format, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Import translations
|
||||
*/
|
||||
importTranslations(locale: string, data: string, format: 'json' | 'csv'): number {
|
||||
let translations: Record<string, string>;
|
||||
|
||||
if (format === 'json') {
|
||||
translations = JSON.parse(data);
|
||||
} else {
|
||||
translations = this.fromCSV(data);
|
||||
}
|
||||
|
||||
let imported = 0;
|
||||
for (const [keyString, value] of Object.entries(translations)) {
|
||||
const key = this.getKeyByString(keyString);
|
||||
if (key) {
|
||||
this.setTranslation(key.id, locale, value, 'draft');
|
||||
imported++;
|
||||
} else {
|
||||
// Create new key
|
||||
const namespace = keyString.split('.')[0];
|
||||
const newKey = this.createKey({ key: keyString, namespace });
|
||||
this.setTranslation(newKey.id, locale, value, 'draft');
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
|
||||
return imported;
|
||||
}
|
||||
|
||||
// ========== STATISTICS ==========
|
||||
|
||||
/**
|
||||
* Get translation statistics
|
||||
*/
|
||||
getStats(): TranslationStats {
|
||||
const locales = ['en', 'tr', 'es', 'fr', 'de', 'zh', 'pt', 'ar', 'ru', 'ja'];
|
||||
const keys = Array.from(this.translationKeys.values());
|
||||
|
||||
const byNamespace: Record<string, number> = {};
|
||||
for (const key of keys) {
|
||||
byNamespace[key.namespace] = (byNamespace[key.namespace] || 0) + 1;
|
||||
}
|
||||
|
||||
const byLocale: Record<string, { translated: number; pending: number; percentage: number }> = {};
|
||||
for (const locale of locales) {
|
||||
let translated = 0;
|
||||
let pending = 0;
|
||||
|
||||
for (const key of keys) {
|
||||
const record = key.translations.find(t => t.locale === locale);
|
||||
if (record && record.value) {
|
||||
if (record.status === 'published') {
|
||||
translated++;
|
||||
} else {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
byLocale[locale] = {
|
||||
translated,
|
||||
pending,
|
||||
percentage: keys.length > 0 ? Math.round((translated / keys.length) * 100) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
totalKeys: keys.length,
|
||||
byNamespace,
|
||||
byLocale,
|
||||
lastUpdated: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// ========== NAMESPACES ==========
|
||||
|
||||
/**
|
||||
* Get all namespaces
|
||||
*/
|
||||
getNamespaces(): string[] {
|
||||
const namespaces = new Set<string>();
|
||||
for (const key of this.translationKeys.values()) {
|
||||
namespaces.add(key.namespace);
|
||||
}
|
||||
return Array.from(namespaces);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename namespace
|
||||
*/
|
||||
renameNamespace(oldName: string, newName: string): number {
|
||||
let renamed = 0;
|
||||
for (const key of this.translationKeys.values()) {
|
||||
if (key.namespace === oldName) {
|
||||
key.namespace = newName;
|
||||
key.key = key.key.replace(`${oldName}.`, `${newName}.`);
|
||||
key.updatedAt = new Date();
|
||||
renamed++;
|
||||
}
|
||||
}
|
||||
return renamed;
|
||||
}
|
||||
|
||||
// ========== HELPER METHODS ==========
|
||||
|
||||
private toCSV(translations: Record<string, string>): string {
|
||||
const lines = ['key,value'];
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
const escapedValue = value.replace(/"/g, '""');
|
||||
lines.push(`"${key}","${escapedValue}"`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private fromCSV(data: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
const lines = data.split('\n').slice(1); // Skip header
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^"([^"]+)","(.+)"$/);
|
||||
if (match) {
|
||||
result[match[1]] = match[2].replace(/""/g, '"');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private toXLIFF(translations: Record<string, string>, locale: string): string {
|
||||
let xliff = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<file source-language="en" target-language="${locale}" datatype="plaintext">
|
||||
<body>`;
|
||||
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
xliff += `
|
||||
<trans-unit id="${key}">
|
||||
<source>${key}</source>
|
||||
<target>${this.escapeXml(value)}</target>
|
||||
</trans-unit>`;
|
||||
}
|
||||
|
||||
xliff += `
|
||||
</body>
|
||||
</file>
|
||||
</xliff>`;
|
||||
|
||||
return xliff;
|
||||
}
|
||||
|
||||
private toPO(translations: Record<string, string>): string {
|
||||
let po = '';
|
||||
for (const [key, value] of Object.entries(translations)) {
|
||||
po += `
|
||||
msgid "${key}"
|
||||
msgstr "${value.replace(/"/g, '\\"')}"
|
||||
`;
|
||||
}
|
||||
return po.trim();
|
||||
}
|
||||
|
||||
private escapeXml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
}
|
||||
228
src/modules/i18n/services/translation.service.ts
Normal file
228
src/modules/i18n/services/translation.service.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// Translation Service - Key-based translations
|
||||
// Path: src/modules/i18n/services/translation.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
interface TranslationEntry {
|
||||
key: string;
|
||||
translations: Record<string, string>;
|
||||
context?: string;
|
||||
pluralForms?: Record<string, Record<string, string>>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TranslationService {
|
||||
private readonly logger = new Logger(TranslationService.name);
|
||||
private readonly translations = new Map<string, TranslationEntry>();
|
||||
private fallbackLocale = 'en';
|
||||
|
||||
constructor() {
|
||||
this.loadDefaultTranslations();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load default system translations
|
||||
*/
|
||||
private loadDefaultTranslations(): void {
|
||||
// Common UI translations
|
||||
this.addTranslations('common', {
|
||||
'common.loading': { en: 'Loading...', tr: 'Yükleniyor...', es: 'Cargando...', fr: 'Chargement...', de: 'Laden...', zh: '加载中...', pt: 'Carregando...', ar: 'جار التحميل...', ru: 'Загрузка...', ja: '読み込み中...' },
|
||||
'common.save': { en: 'Save', tr: 'Kaydet', es: 'Guardar', fr: 'Enregistrer', de: 'Speichern', zh: '保存', pt: 'Salvar', ar: 'حفظ', ru: 'Сохранить', ja: '保存' },
|
||||
'common.cancel': { en: 'Cancel', tr: 'İptal', es: 'Cancelar', fr: 'Annuler', de: 'Abbrechen', zh: '取消', pt: 'Cancelar', ar: 'إلغاء', ru: 'Отмена', ja: 'キャンセル' },
|
||||
'common.delete': { en: 'Delete', tr: 'Sil', es: 'Eliminar', fr: 'Supprimer', de: 'Löschen', zh: '删除', pt: 'Excluir', ar: 'حذف', ru: 'Удалить', ja: '削除' },
|
||||
'common.edit': { en: 'Edit', tr: 'Düzenle', es: 'Editar', fr: 'Modifier', de: 'Bearbeiten', zh: '编辑', pt: 'Editar', ar: 'تحرير', ru: 'Редактировать', ja: '編集' },
|
||||
'common.create': { en: 'Create', tr: 'Oluştur', es: 'Crear', fr: 'Créer', de: 'Erstellen', zh: '创建', pt: 'Criar', ar: 'إنشاء', ru: 'Создать', ja: '作成' },
|
||||
'common.search': { en: 'Search', tr: 'Ara', es: 'Buscar', fr: 'Rechercher', de: 'Suchen', zh: '搜索', pt: 'Pesquisar', ar: 'بحث', ru: 'Поиск', ja: '検索' },
|
||||
'common.back': { en: 'Back', tr: 'Geri', es: 'Volver', fr: 'Retour', de: 'Zurück', zh: '返回', pt: 'Voltar', ar: 'رجوع', ru: 'Назад', ja: '戻る' },
|
||||
'common.next': { en: 'Next', tr: 'İleri', es: 'Siguiente', fr: 'Suivant', de: 'Weiter', zh: '下一步', pt: 'Próximo', ar: 'التالي', ru: 'Далее', ja: '次へ' },
|
||||
'common.confirm': { en: 'Confirm', tr: 'Onayla', es: 'Confirmar', fr: 'Confirmer', de: 'Bestätigen', zh: '确认', pt: 'Confirmar', ar: 'تأكيد', ru: 'Подтвердить', ja: '確認' },
|
||||
});
|
||||
|
||||
// Error messages
|
||||
this.addTranslations('errors', {
|
||||
'error.generic': { en: 'Something went wrong', tr: 'Bir şeyler yanlış gitti', es: 'Algo salió mal', fr: 'Une erreur est survenue', de: 'Etwas ist schiefgelaufen', zh: '出了点问题', pt: 'Algo deu errado', ar: 'حدث خطأ ما', ru: 'Что-то пошло не так', ja: '問題が発生しました' },
|
||||
'error.notFound': { en: 'Not found', tr: 'Bulunamadı', es: 'No encontrado', fr: 'Non trouvé', de: 'Nicht gefunden', zh: '未找到', pt: 'Não encontrado', ar: 'غير موجود', ru: 'Не найдено', ja: '見つかりません' },
|
||||
'error.unauthorized': { en: 'Unauthorized', tr: 'Yetkisiz', es: 'No autorizado', fr: 'Non autorisé', de: 'Nicht autorisiert', zh: '未授权', pt: 'Não autorizado', ar: 'غير مصرح', ru: 'Не авторизован', ja: '権限がありません' },
|
||||
'error.forbidden': { en: 'Access denied', tr: 'Erişim engellendi', es: 'Acceso denegado', fr: 'Accès refusé', de: 'Zugriff verweigert', zh: '拒绝访问', pt: 'Acesso negado', ar: 'تم رفض الوصول', ru: 'Доступ запрещен', ja: 'アクセスが拒否されました' },
|
||||
'error.validation': { en: 'Validation error', tr: 'Doğrulama hatası', es: 'Error de validación', fr: 'Erreur de validation', de: 'Validierungsfehler', zh: '验证错误', pt: 'Erro de validação', ar: 'خطأ في التحقق', ru: 'Ошибка валидации', ja: '検証エラー' },
|
||||
});
|
||||
|
||||
// Auth translations
|
||||
this.addTranslations('auth', {
|
||||
'auth.login': { en: 'Login', tr: 'Giriş Yap', es: 'Iniciar sesión', fr: 'Connexion', de: 'Anmelden', zh: '登录', pt: 'Entrar', ar: 'تسجيل الدخول', ru: 'Войти', ja: 'ログイン' },
|
||||
'auth.register': { en: 'Register', tr: 'Kayıt Ol', es: 'Registrarse', fr: "S'inscrire", de: 'Registrieren', zh: '注册', pt: 'Cadastrar', ar: 'التسجيل', ru: 'Регистрация', ja: '登録' },
|
||||
'auth.logout': { en: 'Logout', tr: 'Çıkış Yap', es: 'Cerrar sesión', fr: 'Déconnexion', de: 'Abmelden', zh: '退出', pt: 'Sair', ar: 'تسجيل الخروج', ru: 'Выйти', ja: 'ログアウト' },
|
||||
'auth.forgotPassword': { en: 'Forgot password?', tr: 'Şifreni mi unuttun?', es: '¿Olvidaste tu contraseña?', fr: 'Mot de passe oublié?', de: 'Passwort vergessen?', zh: '忘记密码?', pt: 'Esqueceu a senha?', ar: 'نسيت كلمة المرور؟', ru: 'Забыли пароль?', ja: 'パスワードを忘れましたか?' },
|
||||
});
|
||||
|
||||
// Content translations
|
||||
this.addTranslations('content', {
|
||||
'content.create': { en: 'Create Content', tr: 'İçerik Oluştur', es: 'Crear contenido', fr: 'Créer du contenu', de: 'Inhalt erstellen', zh: '创建内容', pt: 'Criar conteúdo', ar: 'إنشاء محتوى', ru: 'Создать контент', ja: 'コンテンツを作成' },
|
||||
'content.generate': { en: 'Generate', tr: 'Oluştur', es: 'Generar', fr: 'Générer', de: 'Generieren', zh: '生成', pt: 'Gerar', ar: 'توليد', ru: 'Сгенерировать', ja: '生成する' },
|
||||
'content.publish': { en: 'Publish', tr: 'Yayınla', es: 'Publicar', fr: 'Publier', de: 'Veröffentlichen', zh: '发布', pt: 'Publicar', ar: 'نشر', ru: 'Опубликовать', ja: '公開' },
|
||||
'content.schedule': { en: 'Schedule', tr: 'Zamanla', es: 'Programar', fr: 'Planifier', de: 'Planen', zh: '定时', pt: 'Agendar', ar: 'جدولة', ru: 'Запланировать', ja: 'スケジュール' },
|
||||
'content.draft': { en: 'Draft', tr: 'Taslak', es: 'Borrador', fr: 'Brouillon', de: 'Entwurf', zh: '草稿', pt: 'Rascunho', ar: 'مسودة', ru: 'Черновик', ja: '下書き' },
|
||||
});
|
||||
|
||||
// Analytics translations
|
||||
this.addTranslations('analytics', {
|
||||
'analytics.engagement': { en: 'Engagement', tr: 'Etkileşim', es: 'Interacción', fr: 'Engagement', de: 'Engagement', zh: '互动', pt: 'Engajamento', ar: 'التفاعل', ru: 'Вовлечённость', ja: 'エンゲージメント' },
|
||||
'analytics.reach': { en: 'Reach', tr: 'Erişim', es: 'Alcance', fr: 'Portée', de: 'Reichweite', zh: '覆盖', pt: 'Alcance', ar: 'الوصول', ru: 'Охват', ja: 'リーチ' },
|
||||
'analytics.impressions': { en: 'Impressions', tr: 'Gösterim', es: 'Impresiones', fr: 'Impressions', de: 'Impressionen', zh: '展示次数', pt: 'Impressões', ar: 'المشاهدات', ru: 'Показы', ja: 'インプレッション' },
|
||||
'analytics.clicks': { en: 'Clicks', tr: 'Tıklama', es: 'Clics', fr: 'Clics', de: 'Klicks', zh: '点击', pt: 'Cliques', ar: 'النقرات', ru: 'Клики', ja: 'クリック' },
|
||||
});
|
||||
|
||||
// Plural forms
|
||||
this.addPluralTranslations('content.items', {
|
||||
en: { one: '{{count}} item', other: '{{count}} items' },
|
||||
tr: { one: '{{count}} öğe', other: '{{count}} öğe' },
|
||||
es: { one: '{{count}} elemento', other: '{{count}} elementos' },
|
||||
fr: { one: '{{count}} élément', other: '{{count}} éléments' },
|
||||
de: { one: '{{count}} Element', other: '{{count}} Elemente' },
|
||||
zh: { other: '{{count}} 项' },
|
||||
pt: { one: '{{count}} item', other: '{{count}} itens' },
|
||||
ar: { zero: 'لا عناصر', one: 'عنصر واحد', two: 'عنصران', few: '{{count}} عناصر', many: '{{count}} عنصر', other: '{{count}} عنصر' },
|
||||
ru: { one: '{{count}} элемент', few: '{{count}} элемента', many: '{{count}} элементов', other: '{{count}} элементов' },
|
||||
ja: { other: '{{count}} アイテム' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add translations for a namespace
|
||||
*/
|
||||
private addTranslations(namespace: string, entries: Record<string, Record<string, string>>): void {
|
||||
for (const [key, translations] of Object.entries(entries)) {
|
||||
this.translations.set(key, { key, translations });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add plural translations
|
||||
*/
|
||||
private addPluralTranslations(key: string, pluralForms: Record<string, Record<string, string>>): void {
|
||||
this.translations.set(key, { key, translations: {}, pluralForms });
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key
|
||||
*/
|
||||
translate(key: string, locale: string, args?: Record<string, any>): string {
|
||||
const entry = this.translations.get(key);
|
||||
if (!entry) {
|
||||
this.logger.warn(`Missing translation for key: ${key}`);
|
||||
return key;
|
||||
}
|
||||
|
||||
const normalizedLocale = locale.split('-')[0];
|
||||
let translation = entry.translations[normalizedLocale] || entry.translations[this.fallbackLocale] || key;
|
||||
|
||||
// Replace interpolation variables
|
||||
if (args) {
|
||||
for (const [argKey, argValue] of Object.entries(args)) {
|
||||
translation = translation.replace(new RegExp(`{{${argKey}}}`, 'g'), String(argValue));
|
||||
}
|
||||
}
|
||||
|
||||
return translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate with plural support
|
||||
*/
|
||||
translatePlural(key: string, count: number, locale: string, args?: Record<string, any>): string {
|
||||
const entry = this.translations.get(key);
|
||||
if (!entry || !entry.pluralForms) {
|
||||
return this.translate(key, locale, { ...args, count });
|
||||
}
|
||||
|
||||
const normalizedLocale = locale.split('-')[0];
|
||||
const pluralForms = entry.pluralForms[normalizedLocale] || entry.pluralForms[this.fallbackLocale];
|
||||
if (!pluralForms) {
|
||||
return this.translate(key, locale, { ...args, count });
|
||||
}
|
||||
|
||||
const form = this.getPluralForm(count, normalizedLocale);
|
||||
let translation = pluralForms[form] || pluralForms['other'] || key;
|
||||
|
||||
// Replace interpolation variables
|
||||
const allArgs = { ...args, count };
|
||||
for (const [argKey, argValue] of Object.entries(allArgs)) {
|
||||
translation = translation.replace(new RegExp(`{{${argKey}}}`, 'g'), String(argValue));
|
||||
}
|
||||
|
||||
return translation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plural form based on locale rules
|
||||
*/
|
||||
private getPluralForm(count: number, locale: string): string {
|
||||
// Arabic has 6 plural forms
|
||||
if (locale === 'ar') {
|
||||
if (count === 0) return 'zero';
|
||||
if (count === 1) return 'one';
|
||||
if (count === 2) return 'two';
|
||||
if (count >= 3 && count <= 10) return 'few';
|
||||
if (count >= 11 && count <= 99) return 'many';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
// Russian has 3 plural forms
|
||||
if (locale === 'ru') {
|
||||
const mod10 = count % 10;
|
||||
const mod100 = count % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return 'one';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return 'few';
|
||||
return 'many';
|
||||
}
|
||||
|
||||
// Most languages use simple one/other
|
||||
return count === 1 ? 'one' : 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if translation exists
|
||||
*/
|
||||
hasTranslation(key: string, locale?: string): boolean {
|
||||
const entry = this.translations.get(key);
|
||||
if (!entry) return false;
|
||||
if (!locale) return true;
|
||||
|
||||
const normalizedLocale = locale.split('-')[0];
|
||||
return normalizedLocale in entry.translations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all translation keys
|
||||
*/
|
||||
getAllKeys(): string[] {
|
||||
return Array.from(this.translations.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update a translation
|
||||
*/
|
||||
setTranslation(key: string, locale: string, value: string): void {
|
||||
const entry = this.translations.get(key) || { key, translations: {} };
|
||||
const normalizedLocale = locale.split('-')[0];
|
||||
entry.translations[normalizedLocale] = value;
|
||||
this.translations.set(key, entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all translations for a locale
|
||||
*/
|
||||
getTranslationsForLocale(locale: string): Record<string, string> {
|
||||
const normalizedLocale = locale.split('-')[0];
|
||||
const result: Record<string, string> = {};
|
||||
|
||||
for (const [key, entry] of this.translations.entries()) {
|
||||
const translation = entry.translations[normalizedLocale] || entry.translations[this.fallbackLocale];
|
||||
if (translation) {
|
||||
result[key] = translation;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
4
src/modules/languages/index.ts
Normal file
4
src/modules/languages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Languages Module Index
|
||||
export * from './languages.module';
|
||||
export * from './languages.service';
|
||||
export * from './languages.controller';
|
||||
49
src/modules/languages/languages.controller.ts
Normal file
49
src/modules/languages/languages.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Languages Controller - API endpoints for language management
|
||||
// Path: src/modules/languages/languages.controller.ts
|
||||
|
||||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { LanguagesService } from './languages.service';
|
||||
import { Public } from '../../common/decorators';
|
||||
import { ContentLanguage } from '@prisma/client';
|
||||
|
||||
@ApiTags('languages')
|
||||
@Controller('languages')
|
||||
export class LanguagesController {
|
||||
constructor(private readonly languagesService: LanguagesService) { }
|
||||
|
||||
@Get()
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Get all supported languages for content generation' })
|
||||
getSupportedLanguages() {
|
||||
return {
|
||||
languages: this.languagesService.getSupportedLanguages(),
|
||||
total: this.languagesService.getSupportedLanguages().length,
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':code')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Get language info and writing guide' })
|
||||
getLanguageInfo(@Param('code') code: ContentLanguage) {
|
||||
const info = this.languagesService.getLanguageInfo(code);
|
||||
const guide = this.languagesService.getWritingGuide(code);
|
||||
|
||||
return {
|
||||
info,
|
||||
guide,
|
||||
};
|
||||
}
|
||||
|
||||
@Get(':code/prompt')
|
||||
@Public()
|
||||
@ApiOperation({ summary: 'Get AI prompt template for language' })
|
||||
getLanguagePrompt(
|
||||
@Param('code') code: ContentLanguage,
|
||||
) {
|
||||
return {
|
||||
prompt: this.languagesService.buildLanguagePrompt(code),
|
||||
direction: this.languagesService.getDirection(code),
|
||||
};
|
||||
}
|
||||
}
|
||||
13
src/modules/languages/languages.module.ts
Normal file
13
src/modules/languages/languages.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Languages Module - Multi-language content generation
|
||||
// Path: src/modules/languages/languages.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LanguagesService } from './languages.service';
|
||||
import { LanguagesController } from './languages.controller';
|
||||
|
||||
@Module({
|
||||
providers: [LanguagesService],
|
||||
controllers: [LanguagesController],
|
||||
exports: [LanguagesService],
|
||||
})
|
||||
export class LanguagesModule { }
|
||||
237
src/modules/languages/languages.service.ts
Normal file
237
src/modules/languages/languages.service.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
// Languages Service - Multi-language content generation logic
|
||||
// Path: src/modules/languages/languages.service.ts
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ContentLanguage } from '@prisma/client';
|
||||
|
||||
// Dil bilgileri
|
||||
export interface LanguageInfo {
|
||||
code: ContentLanguage;
|
||||
name: string;
|
||||
nativeName: string;
|
||||
direction: 'ltr' | 'rtl';
|
||||
flag: string;
|
||||
}
|
||||
|
||||
// 10 desteklenen dil
|
||||
export const SUPPORTED_LANGUAGES: LanguageInfo[] = [
|
||||
{ code: 'EN', name: 'English', nativeName: 'English', direction: 'ltr', flag: '🇺🇸' },
|
||||
{ code: 'TR', name: 'Turkish', nativeName: 'Türkçe', direction: 'ltr', flag: '🇹🇷' },
|
||||
{ code: 'ES', name: 'Spanish', nativeName: 'Español', direction: 'ltr', flag: '🇪🇸' },
|
||||
{ code: 'FR', name: 'French', nativeName: 'Français', direction: 'ltr', flag: '🇫🇷' },
|
||||
{ code: 'DE', name: 'German', nativeName: 'Deutsch', direction: 'ltr', flag: '🇩🇪' },
|
||||
{ code: 'ZH', name: 'Chinese', nativeName: '中文', direction: 'ltr', flag: '🇨🇳' },
|
||||
{ code: 'PT', name: 'Portuguese', nativeName: 'Português', direction: 'ltr', flag: '🇧🇷' },
|
||||
{ code: 'AR', name: 'Arabic', nativeName: 'العربية', direction: 'rtl', flag: '🇸🇦' },
|
||||
{ code: 'RU', name: 'Russian', nativeName: 'Русский', direction: 'ltr', flag: '🇷🇺' },
|
||||
{ code: 'JA', name: 'Japanese', nativeName: '日本語', direction: 'ltr', flag: '🇯🇵' },
|
||||
];
|
||||
|
||||
// Dil bazlı yazım stili ve format önerileri
|
||||
export const LANGUAGE_WRITING_GUIDES: Record<ContentLanguage, {
|
||||
formalityLevel: 'formal' | 'informal' | 'neutral';
|
||||
averageReadingSpeed: number; // words per minute
|
||||
commonHashtagPrefix?: string;
|
||||
characterLimit?: number; // Some languages need more characters
|
||||
culturalNotes: string[];
|
||||
}> = {
|
||||
EN: {
|
||||
formalityLevel: 'neutral',
|
||||
averageReadingSpeed: 250,
|
||||
culturalNotes: [
|
||||
'Use action verbs',
|
||||
'Keep sentences short and punchy',
|
||||
'Numbers in headlines increase engagement',
|
||||
],
|
||||
},
|
||||
TR: {
|
||||
formalityLevel: 'formal',
|
||||
averageReadingSpeed: 180,
|
||||
culturalNotes: [
|
||||
'Siz/Sen ayrımına dikkat et',
|
||||
'Türk atasözleri kullan',
|
||||
'Yerel referanslar ekle',
|
||||
],
|
||||
},
|
||||
ES: {
|
||||
formalityLevel: 'neutral',
|
||||
averageReadingSpeed: 200,
|
||||
culturalNotes: [
|
||||
'Use tú vs usted appropriately',
|
||||
'Consider regional variations (Spain vs Latin America)',
|
||||
'Emotional expressions resonate well',
|
||||
],
|
||||
},
|
||||
FR: {
|
||||
formalityLevel: 'formal',
|
||||
averageReadingSpeed: 195,
|
||||
culturalNotes: [
|
||||
'Respect formal language conventions',
|
||||
'Cultural references appreciated',
|
||||
'Wordplay and elegance valued',
|
||||
],
|
||||
},
|
||||
DE: {
|
||||
formalityLevel: 'formal',
|
||||
averageReadingSpeed: 190,
|
||||
culturalNotes: [
|
||||
'Compound words are common',
|
||||
'Precision and accuracy valued',
|
||||
'Du/Sie distinction important',
|
||||
],
|
||||
},
|
||||
ZH: {
|
||||
formalityLevel: 'formal',
|
||||
averageReadingSpeed: 260,
|
||||
characterLimit: 140, // Characters count differently
|
||||
culturalNotes: [
|
||||
'Use 4-character idioms (成语)',
|
||||
'Lucky numbers (8) and colors (red) appreciated',
|
||||
'Avoid unlucky references (4)',
|
||||
],
|
||||
},
|
||||
PT: {
|
||||
formalityLevel: 'neutral',
|
||||
averageReadingSpeed: 188,
|
||||
culturalNotes: [
|
||||
'Brazil vs Portugal differences',
|
||||
'Warm and expressive tone works well',
|
||||
'Football and music references popular',
|
||||
],
|
||||
},
|
||||
AR: {
|
||||
formalityLevel: 'formal',
|
||||
averageReadingSpeed: 150,
|
||||
culturalNotes: [
|
||||
'Right-to-left text direction',
|
||||
'Religious and cultural sensitivity important',
|
||||
'Poetic language appreciated',
|
||||
],
|
||||
},
|
||||
RU: {
|
||||
formalityLevel: 'formal',
|
||||
averageReadingSpeed: 175,
|
||||
culturalNotes: [
|
||||
'Formal address preferred in business',
|
||||
'Literary references valued',
|
||||
'Direct communication style',
|
||||
],
|
||||
},
|
||||
JA: {
|
||||
formalityLevel: 'formal',
|
||||
averageReadingSpeed: 240,
|
||||
culturalNotes: [
|
||||
'Keigo (formal language) levels important',
|
||||
'Indirect communication preferred',
|
||||
'Emoji and kaomoji common in social media',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class LanguagesService {
|
||||
/**
|
||||
* Get all supported languages
|
||||
*/
|
||||
getSupportedLanguages(): LanguageInfo[] {
|
||||
return SUPPORTED_LANGUAGES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language info by code
|
||||
*/
|
||||
getLanguageInfo(code: ContentLanguage): LanguageInfo | undefined {
|
||||
return SUPPORTED_LANGUAGES.find((lang) => lang.code === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get writing guide for a language
|
||||
*/
|
||||
getWritingGuide(code: ContentLanguage) {
|
||||
return LANGUAGE_WRITING_GUIDES[code];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build AI prompt for multi-language content generation
|
||||
*/
|
||||
buildLanguagePrompt(
|
||||
targetLanguage: ContentLanguage,
|
||||
sourceLanguage?: ContentLanguage,
|
||||
): string {
|
||||
const targetInfo = this.getLanguageInfo(targetLanguage);
|
||||
const guide = this.getWritingGuide(targetLanguage);
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Ana dil direktifi
|
||||
parts.push(
|
||||
`Generate content in ${targetInfo?.name} (${targetInfo?.nativeName}).`,
|
||||
);
|
||||
|
||||
// Kaynak dil varsa çeviri notu
|
||||
if (sourceLanguage && sourceLanguage !== targetLanguage) {
|
||||
const sourceInfo = this.getLanguageInfo(sourceLanguage);
|
||||
parts.push(
|
||||
`The source material is in ${sourceInfo?.name}. Adapt the content culturally, don't just translate literally.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Yazım stili
|
||||
if (guide) {
|
||||
parts.push(`Use ${guide.formalityLevel} tone.`);
|
||||
|
||||
// Kültürel notlar
|
||||
if (guide.culturalNotes.length > 0) {
|
||||
parts.push(`Cultural considerations: ${guide.culturalNotes.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// RTL diller için uyarı
|
||||
if (targetInfo?.direction === 'rtl') {
|
||||
parts.push('Note: This is a right-to-left language.');
|
||||
}
|
||||
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate content length adjustment for target language
|
||||
*/
|
||||
estimateLengthMultiplier(
|
||||
sourceLanguage: ContentLanguage,
|
||||
targetLanguage: ContentLanguage,
|
||||
): number {
|
||||
const lengthMultipliers: Record<ContentLanguage, number> = {
|
||||
EN: 1.0,
|
||||
TR: 1.1,
|
||||
ES: 1.2,
|
||||
FR: 1.25,
|
||||
DE: 1.3,
|
||||
ZH: 0.6, // Characters are denser
|
||||
PT: 1.15,
|
||||
AR: 1.1,
|
||||
RU: 1.15,
|
||||
JA: 0.7, // Characters are denser
|
||||
};
|
||||
|
||||
const sourceMultiplier = lengthMultipliers[sourceLanguage];
|
||||
const targetMultiplier = lengthMultipliers[targetLanguage];
|
||||
|
||||
return targetMultiplier / sourceMultiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a language is supported
|
||||
*/
|
||||
isSupported(code: string): code is ContentLanguage {
|
||||
return SUPPORTED_LANGUAGES.some((lang) => lang.code === code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get language direction
|
||||
*/
|
||||
getDirection(code: ContentLanguage): 'ltr' | 'rtl' {
|
||||
const lang = this.getLanguageInfo(code);
|
||||
return lang?.direction ?? 'ltr';
|
||||
}
|
||||
}
|
||||
11
src/modules/neuro-marketing/index.ts
Normal file
11
src/modules/neuro-marketing/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Neuro Marketing Module - Index exports
|
||||
// Path: src/modules/neuro-marketing/index.ts
|
||||
|
||||
export * from './neuro-marketing.module';
|
||||
export * from './neuro-marketing.service';
|
||||
export * from './neuro-marketing.controller';
|
||||
export * from './services/psychology-triggers.service';
|
||||
export * from './services/emotional-hooks.service';
|
||||
export * from './services/social-proof.service';
|
||||
export * from './services/urgency-tactics.service';
|
||||
export * from './services/engagement-predictor.service';
|
||||
184
src/modules/neuro-marketing/neuro-marketing.controller.ts
Normal file
184
src/modules/neuro-marketing/neuro-marketing.controller.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// Neuro Marketing Controller - API endpoints
|
||||
// Path: src/modules/neuro-marketing/neuro-marketing.controller.ts
|
||||
|
||||
import { Controller, Get, Post, Body, Query, Param } from '@nestjs/common';
|
||||
import { NeuroMarketingService } from './neuro-marketing.service';
|
||||
import type { TriggerCategory } from './services/psychology-triggers.service';
|
||||
import { PsychologyTriggersService } from './services/psychology-triggers.service';
|
||||
import type { EmotionType } from './services/emotional-hooks.service';
|
||||
import { EmotionalHooksService } from './services/emotional-hooks.service';
|
||||
import type { SocialProofType } from './services/social-proof.service';
|
||||
import { SocialProofService } from './services/social-proof.service';
|
||||
import type { UrgencyType } from './services/urgency-tactics.service';
|
||||
import { UrgencyTacticsService } from './services/urgency-tactics.service';
|
||||
import { EngagementPredictorService } from './services/engagement-predictor.service';
|
||||
|
||||
@Controller('neuro-marketing')
|
||||
export class NeuroMarketingController {
|
||||
constructor(
|
||||
private readonly neuroService: NeuroMarketingService,
|
||||
private readonly triggersService: PsychologyTriggersService,
|
||||
private readonly hooksService: EmotionalHooksService,
|
||||
private readonly proofService: SocialProofService,
|
||||
private readonly urgencyService: UrgencyTacticsService,
|
||||
private readonly predictorService: EngagementPredictorService,
|
||||
) { }
|
||||
|
||||
// ========== ANALYSIS ==========
|
||||
|
||||
@Post('analyze')
|
||||
analyze(
|
||||
@Body() body: { content: string; platform?: string },
|
||||
) {
|
||||
return this.neuroService.analyze(body.content, body.platform);
|
||||
}
|
||||
|
||||
@Post('predict-engagement')
|
||||
predictEngagement(
|
||||
@Body() body: { content: string; platform?: string },
|
||||
) {
|
||||
return this.predictorService.predict(body.content, body.platform);
|
||||
}
|
||||
|
||||
// ========== TOOLKIT ==========
|
||||
|
||||
@Get('toolkit')
|
||||
getToolkit() {
|
||||
return this.neuroService.getToolkit();
|
||||
}
|
||||
|
||||
// ========== PSYCHOLOGY TRIGGERS ==========
|
||||
|
||||
@Get('triggers')
|
||||
getAllTriggers() {
|
||||
return this.triggersService.getAll();
|
||||
}
|
||||
|
||||
@Get('triggers/category/:category')
|
||||
getTriggersByCategory(@Param('category') category: TriggerCategory) {
|
||||
return this.triggersService.getByCategory(category);
|
||||
}
|
||||
|
||||
@Get('triggers/power/:level')
|
||||
getTriggersByPower(@Param('level') level: string) {
|
||||
return this.triggersService.getByPowerLevel(parseInt(level, 10));
|
||||
}
|
||||
|
||||
@Get('triggers/random')
|
||||
getRandomTriggers(@Query('count') count: string = '3') {
|
||||
return this.triggersService.getRandomTriggers(parseInt(count, 10));
|
||||
}
|
||||
|
||||
// ========== EMOTIONAL HOOKS ==========
|
||||
|
||||
@Get('hooks')
|
||||
getAllHooks() {
|
||||
return this.hooksService.getAll();
|
||||
}
|
||||
|
||||
@Get('hooks/emotion/:emotion')
|
||||
getHooksByEmotion(@Param('emotion') emotion: EmotionType) {
|
||||
return this.hooksService.getByEmotion(emotion);
|
||||
}
|
||||
|
||||
@Get('hooks/intensity/:intensity')
|
||||
getHooksByIntensity(@Param('intensity') intensity: 'soft' | 'medium' | 'strong') {
|
||||
return this.hooksService.getByIntensity(intensity);
|
||||
}
|
||||
|
||||
@Post('hooks/generate')
|
||||
generateHook(
|
||||
@Body() body: { hookId: string; variables: Record<string, string> },
|
||||
) {
|
||||
return { hook: this.hooksService.generateHook(body.hookId, body.variables) };
|
||||
}
|
||||
|
||||
@Post('hooks/variations')
|
||||
generateHookVariations(
|
||||
@Body() body: { topic: string; emotions: EmotionType[]; count?: number },
|
||||
) {
|
||||
return this.neuroService.generateHookVariations(
|
||||
body.topic,
|
||||
body.emotions,
|
||||
body.count || 5,
|
||||
);
|
||||
}
|
||||
|
||||
// ========== SOCIAL PROOF ==========
|
||||
|
||||
@Get('social-proof')
|
||||
getAllProofPatterns() {
|
||||
return this.proofService.getAll();
|
||||
}
|
||||
|
||||
@Get('social-proof/type/:type')
|
||||
getProofByType(@Param('type') type: SocialProofType) {
|
||||
return this.proofService.getByType(type);
|
||||
}
|
||||
|
||||
@Get('social-proof/platform/:platform')
|
||||
getProofForPlatform(@Param('platform') platform: string) {
|
||||
return this.proofService.getForPlatform(platform);
|
||||
}
|
||||
|
||||
@Get('social-proof/high-impact')
|
||||
getHighImpactProof() {
|
||||
return this.proofService.getHighImpact();
|
||||
}
|
||||
|
||||
@Post('social-proof/generate')
|
||||
generateProof(
|
||||
@Body() body: { patternId: string; variables: Record<string, string> },
|
||||
) {
|
||||
return { proof: this.proofService.generate(body.patternId, body.variables) };
|
||||
}
|
||||
|
||||
// ========== URGENCY TACTICS ==========
|
||||
|
||||
@Get('urgency')
|
||||
getAllUrgencyTactics() {
|
||||
return this.urgencyService.getAll();
|
||||
}
|
||||
|
||||
@Get('urgency/type/:type')
|
||||
getUrgencyByType(@Param('type') type: UrgencyType) {
|
||||
return this.urgencyService.getByType(type);
|
||||
}
|
||||
|
||||
@Get('urgency/intensity/:intensity')
|
||||
getUrgencyByIntensity(@Param('intensity') intensity: 'subtle' | 'moderate' | 'aggressive') {
|
||||
return this.urgencyService.getByIntensity(intensity);
|
||||
}
|
||||
|
||||
@Get('urgency/ethical')
|
||||
getEthicalUrgency() {
|
||||
return this.urgencyService.getEthicalSuggestions();
|
||||
}
|
||||
|
||||
@Post('urgency/generate')
|
||||
generateUrgency(
|
||||
@Body() body: { tacticId: string; variables: Record<string, string> },
|
||||
) {
|
||||
return { urgency: this.urgencyService.generate(body.tacticId, body.variables) };
|
||||
}
|
||||
|
||||
@Post('urgency/analyze')
|
||||
analyzeUrgency(@Body() body: { content: string }) {
|
||||
return this.urgencyService.analyzeUrgency(body.content);
|
||||
}
|
||||
|
||||
// ========== AI PROMPT GENERATION ==========
|
||||
|
||||
@Post('generate-prompt')
|
||||
generateNeuroPrompt(
|
||||
@Body() body: {
|
||||
topic: string;
|
||||
platform: string;
|
||||
targetEmotions: EmotionType[];
|
||||
includeTriggers: string[];
|
||||
urgencyLevel: string;
|
||||
},
|
||||
) {
|
||||
return { prompt: this.neuroService.generateNeuroPrompt(body) };
|
||||
}
|
||||
}
|
||||
25
src/modules/neuro-marketing/neuro-marketing.module.ts
Normal file
25
src/modules/neuro-marketing/neuro-marketing.module.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
// Neuro Marketing Module - Psychology-driven content optimization
|
||||
// Path: src/modules/neuro-marketing/neuro-marketing.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NeuroMarketingService } from './neuro-marketing.service';
|
||||
import { NeuroMarketingController } from './neuro-marketing.controller';
|
||||
import { PsychologyTriggersService } from './services/psychology-triggers.service';
|
||||
import { EmotionalHooksService } from './services/emotional-hooks.service';
|
||||
import { SocialProofService } from './services/social-proof.service';
|
||||
import { UrgencyTacticsService } from './services/urgency-tactics.service';
|
||||
import { EngagementPredictorService } from './services/engagement-predictor.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
NeuroMarketingService,
|
||||
PsychologyTriggersService,
|
||||
EmotionalHooksService,
|
||||
SocialProofService,
|
||||
UrgencyTacticsService,
|
||||
EngagementPredictorService,
|
||||
],
|
||||
controllers: [NeuroMarketingController],
|
||||
exports: [NeuroMarketingService],
|
||||
})
|
||||
export class NeuroMarketingModule { }
|
||||
153
src/modules/neuro-marketing/neuro-marketing.service.ts
Normal file
153
src/modules/neuro-marketing/neuro-marketing.service.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// Neuro Marketing Service - Main orchestration service
|
||||
// Path: src/modules/neuro-marketing/neuro-marketing.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PsychologyTriggersService, PsychologyTrigger } from './services/psychology-triggers.service';
|
||||
import { EmotionalHooksService, EmotionType } from './services/emotional-hooks.service';
|
||||
import { SocialProofService, SocialProofPattern } from './services/social-proof.service';
|
||||
import { UrgencyTacticsService, UrgencyTactic } from './services/urgency-tactics.service';
|
||||
import { EngagementPredictorService, EngagementPrediction } from './services/engagement-predictor.service';
|
||||
|
||||
export interface ContentEnhancementRequest {
|
||||
content: string;
|
||||
platform?: string;
|
||||
targetEmotions?: EmotionType[];
|
||||
urgencyLevel?: 'none' | 'subtle' | 'moderate' | 'aggressive';
|
||||
includeProof?: boolean;
|
||||
triggerCategories?: string[];
|
||||
}
|
||||
|
||||
export interface ContentEnhancementResult {
|
||||
originalContent: string;
|
||||
enhancedContent: string;
|
||||
engagementPrediction: EngagementPrediction;
|
||||
appliedTechniques: {
|
||||
triggers: PsychologyTrigger[];
|
||||
hooks: string[];
|
||||
proofPatterns: SocialProofPattern[];
|
||||
urgencyTactics: UrgencyTactic[];
|
||||
};
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NeuroMarketingService {
|
||||
private readonly logger = new Logger(NeuroMarketingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly triggersService: PsychologyTriggersService,
|
||||
private readonly hooksService: EmotionalHooksService,
|
||||
private readonly proofService: SocialProofService,
|
||||
private readonly urgencyService: UrgencyTacticsService,
|
||||
private readonly predictorService: EngagementPredictorService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Analyze content for neuro marketing elements
|
||||
*/
|
||||
analyze(content: string, platform?: string): {
|
||||
prediction: EngagementPrediction;
|
||||
triggerAnalysis: { used: PsychologyTrigger[]; suggestions: PsychologyTrigger[] };
|
||||
urgencyAnalysis: { score: number; tactics: string[]; recommendations: string[] };
|
||||
} {
|
||||
const prediction = this.predictorService.predict(content, platform);
|
||||
const triggerAnalysis = this.triggersService.analyzeContent(content);
|
||||
const urgencyAnalysis = this.urgencyService.analyzeUrgency(content);
|
||||
|
||||
return {
|
||||
prediction,
|
||||
triggerAnalysis,
|
||||
urgencyAnalysis,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all neuro marketing tools in one response
|
||||
*/
|
||||
getToolkit(): {
|
||||
triggers: PsychologyTrigger[];
|
||||
emotions: ReturnType<EmotionalHooksService['getAll']>;
|
||||
proofPatterns: SocialProofPattern[];
|
||||
urgencyTactics: UrgencyTactic[];
|
||||
} {
|
||||
return {
|
||||
triggers: this.triggersService.getAll(),
|
||||
emotions: this.hooksService.getAll(),
|
||||
proofPatterns: this.proofService.getAll(),
|
||||
urgencyTactics: this.urgencyService.getAll(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hook variations with different emotions
|
||||
*/
|
||||
generateHookVariations(
|
||||
topic: string,
|
||||
emotions: EmotionType[],
|
||||
count: number = 5,
|
||||
) {
|
||||
return this.hooksService.generateVariations(topic, emotions, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-optimized social proof
|
||||
*/
|
||||
getProofForPlatform(platform: string) {
|
||||
return this.proofService.getForPlatform(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ethical urgency suggestions
|
||||
*/
|
||||
getEthicalUrgency() {
|
||||
return this.urgencyService.getEthicalSuggestions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Predict engagement with detailed breakdown
|
||||
*/
|
||||
predictEngagement(content: string, platform?: string): EngagementPrediction {
|
||||
return this.predictorService.predict(content, platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI prompt for neuro-optimized content
|
||||
*/
|
||||
generateNeuroPrompt(options: {
|
||||
topic: string;
|
||||
platform: string;
|
||||
targetEmotions: EmotionType[];
|
||||
includeTriggers: string[];
|
||||
urgencyLevel: string;
|
||||
}): string {
|
||||
const emotions = options.targetEmotions.join(', ');
|
||||
const triggers = options.includeTriggers.length
|
||||
? this.triggersService.getAll()
|
||||
.filter((t) => options.includeTriggers.includes(t.id))
|
||||
.map((t) => `${t.name}: ${t.howToUse}`)
|
||||
.join('\n')
|
||||
: 'Use appropriate psychological triggers naturally';
|
||||
|
||||
return `
|
||||
NEURO-OPTIMIZED CONTENT GENERATION
|
||||
|
||||
TOPIC: ${options.topic}
|
||||
PLATFORM: ${options.platform}
|
||||
TARGET EMOTIONS: ${emotions}
|
||||
URGENCY LEVEL: ${options.urgencyLevel}
|
||||
|
||||
PSYCHOLOGY TECHNIQUES TO APPLY:
|
||||
${triggers}
|
||||
|
||||
ENGAGEMENT OPTIMIZATION:
|
||||
- Start with a powerful hook that creates curiosity
|
||||
- Use emotional language targeting: ${emotions}
|
||||
- Include pattern interrupts for attention
|
||||
- Add social proof elements where appropriate
|
||||
- End with a clear, compelling call-to-action
|
||||
- Optimize for ${options.platform} best practices
|
||||
|
||||
GENERATE content that is psychologically compelling, emotionally engaging, and optimized for maximum engagement.
|
||||
`.trim();
|
||||
}
|
||||
}
|
||||
316
src/modules/neuro-marketing/services/emotional-hooks.service.ts
Normal file
316
src/modules/neuro-marketing/services/emotional-hooks.service.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
// Emotional Hooks Service - Emotion-driven content patterns
|
||||
// Path: src/modules/neuro-marketing/services/emotional-hooks.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface EmotionalHook {
|
||||
id: string;
|
||||
emotion: EmotionType;
|
||||
template: string;
|
||||
variables: string[];
|
||||
intensity: 'soft' | 'medium' | 'strong';
|
||||
bestFor: string[];
|
||||
}
|
||||
|
||||
export type EmotionType =
|
||||
| 'fear'
|
||||
| 'desire'
|
||||
| 'anger'
|
||||
| 'joy'
|
||||
| 'surprise'
|
||||
| 'trust'
|
||||
| 'anticipation'
|
||||
| 'sadness'
|
||||
| 'pride'
|
||||
| 'shame'
|
||||
| 'hope'
|
||||
| 'frustration';
|
||||
|
||||
export const EMOTIONAL_HOOKS: Record<EmotionType, EmotionalHook[]> = {
|
||||
fear: [
|
||||
{
|
||||
id: 'fear-1',
|
||||
emotion: 'fear',
|
||||
template: 'If you keep doing {bad_action}, you\'ll end up {negative_outcome}',
|
||||
variables: ['bad_action', 'negative_outcome'],
|
||||
intensity: 'strong',
|
||||
bestFor: ['health', 'finance', 'career'],
|
||||
},
|
||||
{
|
||||
id: 'fear-2',
|
||||
emotion: 'fear',
|
||||
template: 'The {problem} is getting worse. Here\'s what to do about it.',
|
||||
variables: ['problem'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['awareness', 'education'],
|
||||
},
|
||||
{
|
||||
id: 'fear-3',
|
||||
emotion: 'fear',
|
||||
template: 'I ignored this warning. {negative_result}',
|
||||
variables: ['negative_result'],
|
||||
intensity: 'strong',
|
||||
bestFor: ['storytelling', 'lessons'],
|
||||
},
|
||||
],
|
||||
desire: [
|
||||
{
|
||||
id: 'desire-1',
|
||||
emotion: 'desire',
|
||||
template: 'Imagine waking up to {dream_scenario}',
|
||||
variables: ['dream_scenario'],
|
||||
intensity: 'strong',
|
||||
bestFor: ['lifestyle', 'products', 'services'],
|
||||
},
|
||||
{
|
||||
id: 'desire-2',
|
||||
emotion: 'desire',
|
||||
template: 'What would you do with {benefit}?',
|
||||
variables: ['benefit'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['engagement', 'sales'],
|
||||
},
|
||||
{
|
||||
id: 'desire-3',
|
||||
emotion: 'desire',
|
||||
template: 'This is what {achieved_state} looks like',
|
||||
variables: ['achieved_state'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['inspiration', 'proof'],
|
||||
},
|
||||
],
|
||||
anger: [
|
||||
{
|
||||
id: 'anger-1',
|
||||
emotion: 'anger',
|
||||
template: 'I\'m tired of {injustice}. Here\'s the truth:',
|
||||
variables: ['injustice'],
|
||||
intensity: 'strong',
|
||||
bestFor: ['advocacy', 'opinion'],
|
||||
},
|
||||
{
|
||||
id: 'anger-2',
|
||||
emotion: 'anger',
|
||||
template: '{industry} has been lying to you about {topic}',
|
||||
variables: ['industry', 'topic'],
|
||||
intensity: 'strong',
|
||||
bestFor: ['exposé', 'education'],
|
||||
},
|
||||
],
|
||||
joy: [
|
||||
{
|
||||
id: 'joy-1',
|
||||
emotion: 'joy',
|
||||
template: 'The moment I realized {positive_insight}, everything changed',
|
||||
variables: ['positive_insight'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['storytelling', 'inspiration'],
|
||||
},
|
||||
{
|
||||
id: 'joy-2',
|
||||
emotion: 'joy',
|
||||
template: 'Celebrating {achievement}! Here\'s how:',
|
||||
variables: ['achievement'],
|
||||
intensity: 'soft',
|
||||
bestFor: ['motivation', 'community'],
|
||||
},
|
||||
],
|
||||
surprise: [
|
||||
{
|
||||
id: 'surprise-1',
|
||||
emotion: 'surprise',
|
||||
template: 'I never expected {unexpected_outcome}. Here\'s what happened:',
|
||||
variables: ['unexpected_outcome'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['storytelling', 'case-studies'],
|
||||
},
|
||||
{
|
||||
id: 'surprise-2',
|
||||
emotion: 'surprise',
|
||||
template: 'Plot twist: {revelation}',
|
||||
variables: ['revelation'],
|
||||
intensity: 'strong',
|
||||
bestFor: ['engagement', 'drama'],
|
||||
},
|
||||
],
|
||||
trust: [
|
||||
{
|
||||
id: 'trust-1',
|
||||
emotion: 'trust',
|
||||
template: 'I\'ve been doing this for {years} years. Here\'s what actually works:',
|
||||
variables: ['years'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['authority', 'education'],
|
||||
},
|
||||
{
|
||||
id: 'trust-2',
|
||||
emotion: 'trust',
|
||||
template: 'No BS, no fluff. Just {value}.',
|
||||
variables: ['value'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['direct', 'practical'],
|
||||
},
|
||||
],
|
||||
anticipation: [
|
||||
{
|
||||
id: 'anticipation-1',
|
||||
emotion: 'anticipation',
|
||||
template: 'Something big is coming. Here\'s a sneak peek:',
|
||||
variables: [],
|
||||
intensity: 'medium',
|
||||
bestFor: ['launches', 'teasers'],
|
||||
},
|
||||
{
|
||||
id: 'anticipation-2',
|
||||
emotion: 'anticipation',
|
||||
template: 'In {time_period}, you could {potential_outcome}',
|
||||
variables: ['time_period', 'potential_outcome'],
|
||||
intensity: 'strong',
|
||||
bestFor: ['promises', 'motivation'],
|
||||
},
|
||||
],
|
||||
sadness: [
|
||||
{
|
||||
id: 'sadness-1',
|
||||
emotion: 'sadness',
|
||||
template: 'I lost {loss}. Here\'s what I learned:',
|
||||
variables: ['loss'],
|
||||
intensity: 'strong',
|
||||
bestFor: ['vulnerability', 'connection'],
|
||||
},
|
||||
],
|
||||
pride: [
|
||||
{
|
||||
id: 'pride-1',
|
||||
emotion: 'pride',
|
||||
template: 'From {starting_point} to {achievement}. This is my story.',
|
||||
variables: ['starting_point', 'achievement'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['success-stories', 'inspiration'],
|
||||
},
|
||||
],
|
||||
shame: [
|
||||
{
|
||||
id: 'shame-1',
|
||||
emotion: 'shame',
|
||||
template: 'My biggest mistake was {mistake}. Don\'t repeat it.',
|
||||
variables: ['mistake'],
|
||||
intensity: 'strong',
|
||||
bestFor: ['lessons', 'vulnerability'],
|
||||
},
|
||||
],
|
||||
hope: [
|
||||
{
|
||||
id: 'hope-1',
|
||||
emotion: 'hope',
|
||||
template: 'It\'s not too late to {possibility}',
|
||||
variables: ['possibility'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['motivation', 'encouragement'],
|
||||
},
|
||||
{
|
||||
id: 'hope-2',
|
||||
emotion: 'hope',
|
||||
template: 'What if {optimistic_scenario}?',
|
||||
variables: ['optimistic_scenario'],
|
||||
intensity: 'soft',
|
||||
bestFor: ['vision', 'inspiration'],
|
||||
},
|
||||
],
|
||||
frustration: [
|
||||
{
|
||||
id: 'frustration-1',
|
||||
emotion: 'frustration',
|
||||
template: 'Why is {problem} still a thing in {year}?',
|
||||
variables: ['problem', 'year'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['rants', 'opinion'],
|
||||
},
|
||||
{
|
||||
id: 'frustration-2',
|
||||
emotion: 'frustration',
|
||||
template: 'Tried {attempt} {count} times. Finally figured it out.',
|
||||
variables: ['attempt', 'count'],
|
||||
intensity: 'medium',
|
||||
bestFor: ['relatability', 'solutions'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class EmotionalHooksService {
|
||||
private readonly logger = new Logger(EmotionalHooksService.name);
|
||||
|
||||
/**
|
||||
* Get all emotional hooks
|
||||
*/
|
||||
getAll(): typeof EMOTIONAL_HOOKS {
|
||||
return EMOTIONAL_HOOKS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hooks by emotion type
|
||||
*/
|
||||
getByEmotion(emotion: EmotionType): EmotionalHook[] {
|
||||
return EMOTIONAL_HOOKS[emotion] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hooks by intensity
|
||||
*/
|
||||
getByIntensity(intensity: 'soft' | 'medium' | 'strong'): EmotionalHook[] {
|
||||
const all = Object.values(EMOTIONAL_HOOKS).flat();
|
||||
return all.filter((h) => h.intensity === intensity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate hook with variables filled
|
||||
*/
|
||||
generateHook(hookId: string, variables: Record<string, string>): string {
|
||||
let hook: EmotionalHook | undefined;
|
||||
|
||||
for (const hooks of Object.values(EMOTIONAL_HOOKS)) {
|
||||
hook = hooks.find((h) => h.id === hookId);
|
||||
if (hook) break;
|
||||
}
|
||||
|
||||
if (!hook) return '';
|
||||
|
||||
let result = hook.template;
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
result = result.replace(`{${key}}`, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended hooks for content type
|
||||
*/
|
||||
getRecommendedHooks(contentType: string): EmotionalHook[] {
|
||||
const all = Object.values(EMOTIONAL_HOOKS).flat();
|
||||
return all.filter((h) => h.bestFor.includes(contentType));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate multiple hook variations
|
||||
*/
|
||||
generateVariations(
|
||||
topic: string,
|
||||
emotions: EmotionType[],
|
||||
count: number = 5,
|
||||
): { emotion: EmotionType; hook: string }[] {
|
||||
const results: { emotion: EmotionType; hook: string }[] = [];
|
||||
|
||||
for (const emotion of emotions) {
|
||||
const hooks = EMOTIONAL_HOOKS[emotion] || [];
|
||||
for (const hook of hooks.slice(0, Math.ceil(count / emotions.length))) {
|
||||
const generated = hook.template
|
||||
.replace(/{[^}]+}/g, topic);
|
||||
results.push({ emotion, hook: generated });
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
// Engagement Predictor Service - AI-powered engagement prediction
|
||||
// Path: src/modules/neuro-marketing/services/engagement-predictor.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface EngagementPrediction {
|
||||
overallScore: number; // 0-100
|
||||
categories: {
|
||||
hook: number;
|
||||
emotion: number;
|
||||
clarity: number;
|
||||
actionability: number;
|
||||
shareability: number;
|
||||
controversy: number;
|
||||
};
|
||||
platformScores: Record<string, number>;
|
||||
improvements: string[];
|
||||
viralPotential: 'low' | 'medium' | 'high' | 'viral';
|
||||
}
|
||||
|
||||
export interface ContentAnalysis {
|
||||
wordCount: number;
|
||||
sentenceCount: number;
|
||||
avgSentenceLength: number;
|
||||
readabilityScore: number;
|
||||
emotionIntensity: number;
|
||||
hashtags: string[];
|
||||
mentions: string[];
|
||||
urls: string[];
|
||||
emojis: string[];
|
||||
questions: number;
|
||||
callsToAction: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EngagementPredictorService {
|
||||
private readonly logger = new Logger(EngagementPredictorService.name);
|
||||
|
||||
/**
|
||||
* Predict engagement for content
|
||||
*/
|
||||
predict(content: string, platform?: string): EngagementPrediction {
|
||||
const analysis = this.analyzeContent(content);
|
||||
|
||||
// Calculate category scores
|
||||
const hookScore = this.calculateHookScore(content);
|
||||
const emotionScore = this.calculateEmotionScore(content);
|
||||
const clarityScore = this.calculateClarityScore(analysis);
|
||||
const actionabilityScore = this.calculateActionabilityScore(content, analysis);
|
||||
const shareabilityScore = this.calculateShareabilityScore(content, analysis);
|
||||
const controversyScore = this.calculateControversyScore(content);
|
||||
|
||||
// Weighted overall score
|
||||
const overallScore = Math.round(
|
||||
hookScore * 0.25 +
|
||||
emotionScore * 0.20 +
|
||||
clarityScore * 0.15 +
|
||||
actionabilityScore * 0.15 +
|
||||
shareabilityScore * 0.20 +
|
||||
controversyScore * 0.05
|
||||
);
|
||||
|
||||
// Platform-specific scores
|
||||
const platformScores = this.calculatePlatformScores(content, analysis);
|
||||
|
||||
// Generate improvements
|
||||
const improvements = this.generateImprovements({
|
||||
hook: hookScore,
|
||||
emotion: emotionScore,
|
||||
clarity: clarityScore,
|
||||
actionability: actionabilityScore,
|
||||
shareability: shareabilityScore,
|
||||
controversy: controversyScore,
|
||||
});
|
||||
|
||||
// Determine viral potential
|
||||
const viralPotential = this.getViralPotential(overallScore, analysis);
|
||||
|
||||
return {
|
||||
overallScore,
|
||||
categories: {
|
||||
hook: hookScore,
|
||||
emotion: emotionScore,
|
||||
clarity: clarityScore,
|
||||
actionability: actionabilityScore,
|
||||
shareability: shareabilityScore,
|
||||
controversy: controversyScore,
|
||||
},
|
||||
platformScores,
|
||||
improvements,
|
||||
viralPotential,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze content structure and elements
|
||||
*/
|
||||
analyzeContent(content: string): ContentAnalysis {
|
||||
const sentences = content.split(/[.!?]+/).filter(Boolean);
|
||||
const words = content.split(/\s+/).filter(Boolean);
|
||||
|
||||
return {
|
||||
wordCount: words.length,
|
||||
sentenceCount: sentences.length,
|
||||
avgSentenceLength: sentences.length ? words.length / sentences.length : 0,
|
||||
readabilityScore: this.calculateReadability(content),
|
||||
emotionIntensity: this.measureEmotionIntensity(content),
|
||||
hashtags: content.match(/#\w+/g) || [],
|
||||
mentions: content.match(/@\w+/g) || [],
|
||||
urls: content.match(/https?:\/\/\S+/g) || [],
|
||||
emojis: content.match(/[\u{1F300}-\u{1F9FF}]/gu) || [],
|
||||
questions: (content.match(/\?/g) || []).length,
|
||||
callsToAction: this.extractCTAs(content),
|
||||
};
|
||||
}
|
||||
|
||||
private calculateHookScore(content: string): number {
|
||||
const firstLine = content.split('\n')[0] || content.substring(0, 100);
|
||||
let score = 50;
|
||||
|
||||
// Pattern interrupt
|
||||
const patternInterrupts = ['stop', 'wait', 'hold on', 'plot twist', 'unpopular opinion'];
|
||||
if (patternInterrupts.some((p) => firstLine.toLowerCase().includes(p))) score += 15;
|
||||
|
||||
// Curiosity gap
|
||||
const curiosityWords = ['secret', 'truth', 'never', 'always', 'mistake', 'reveals'];
|
||||
if (curiosityWords.some((w) => firstLine.toLowerCase().includes(w))) score += 15;
|
||||
|
||||
// Numbers
|
||||
if (/\d+/.test(firstLine)) score += 10;
|
||||
|
||||
// Question hook
|
||||
if (firstLine.includes('?')) score += 10;
|
||||
|
||||
// Short and punchy
|
||||
if (firstLine.length < 100) score += 10;
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private calculateEmotionScore(content: string): number {
|
||||
let score = 40;
|
||||
const contentLower = content.toLowerCase();
|
||||
|
||||
const emotionWords: Record<string, number> = {
|
||||
'love': 3, 'hate': 4, 'amazing': 3, 'terrible': 4,
|
||||
'incredible': 3, 'horrible': 4, 'beautiful': 3, 'ugly': 3,
|
||||
'excited': 3, 'angry': 4, 'happy': 2, 'sad': 3,
|
||||
'frustrated': 3, 'thrilled': 3, 'devastated': 4, 'ecstatic': 4,
|
||||
};
|
||||
|
||||
for (const [word, points] of Object.entries(emotionWords)) {
|
||||
if (contentLower.includes(word)) score += points;
|
||||
}
|
||||
|
||||
// Exclamation marks (moderate use is good)
|
||||
const exclamations = (content.match(/!/g) || []).length;
|
||||
if (exclamations >= 1 && exclamations <= 3) score += 10;
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private calculateClarityScore(analysis: ContentAnalysis): number {
|
||||
let score = 50;
|
||||
|
||||
// Readability
|
||||
score += analysis.readabilityScore * 0.5;
|
||||
|
||||
// Sentence length (shorter is clearer for social)
|
||||
if (analysis.avgSentenceLength <= 15) score += 20;
|
||||
else if (analysis.avgSentenceLength <= 20) score += 10;
|
||||
else score -= 10;
|
||||
|
||||
// Not too long
|
||||
if (analysis.wordCount <= 280) score += 10;
|
||||
else if (analysis.wordCount >= 500) score -= 10;
|
||||
|
||||
return Math.min(Math.max(score, 0), 100);
|
||||
}
|
||||
|
||||
private calculateActionabilityScore(content: string, analysis: ContentAnalysis): number {
|
||||
let score = 30;
|
||||
|
||||
// Has CTAs
|
||||
score += analysis.callsToAction.length * 15;
|
||||
|
||||
// Actionable language
|
||||
const actionWords = ['try', 'do', 'start', 'stop', 'get', 'learn', 'discover', 'join'];
|
||||
const contentLower = content.toLowerCase();
|
||||
for (const word of actionWords) {
|
||||
if (contentLower.includes(word)) score += 5;
|
||||
}
|
||||
|
||||
// Questions engage action
|
||||
score += analysis.questions * 5;
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private calculateShareabilityScore(content: string, analysis: ContentAnalysis): number {
|
||||
let score = 40;
|
||||
|
||||
// Relatable content
|
||||
const relatableWords = ['we all', 'everyone', 'nobody tells you', 'most people'];
|
||||
const contentLower = content.toLowerCase();
|
||||
for (const phrase of relatableWords) {
|
||||
if (contentLower.includes(phrase)) score += 10;
|
||||
}
|
||||
|
||||
// Identity/tribe
|
||||
if (contentLower.includes('if you\'re') || contentLower.includes('for those who')) score += 15;
|
||||
|
||||
// Quotable (short sentences)
|
||||
if (analysis.avgSentenceLength <= 12) score += 15;
|
||||
|
||||
// Has hashtags but not too many
|
||||
if (analysis.hashtags.length >= 1 && analysis.hashtags.length <= 5) score += 10;
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private calculateControversyScore(content: string): number {
|
||||
let score = 20;
|
||||
const contentLower = content.toLowerCase();
|
||||
|
||||
const controversyIndicators = [
|
||||
'unpopular opinion', 'hot take', 'controversial', 'fight me',
|
||||
'disagree', 'wrong', 'overrated', 'underrated', 'actually',
|
||||
];
|
||||
|
||||
for (const indicator of controversyIndicators) {
|
||||
if (contentLower.includes(indicator)) score += 15;
|
||||
}
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private calculatePlatformScores(
|
||||
content: string,
|
||||
analysis: ContentAnalysis,
|
||||
): Record<string, number> {
|
||||
const baseScore = 50;
|
||||
|
||||
return {
|
||||
twitter: this.calculateTwitterScore(content, analysis),
|
||||
linkedin: this.calculateLinkedInScore(content, analysis),
|
||||
instagram: this.calculateInstagramScore(content, analysis),
|
||||
tiktok: this.calculateTikTokScore(content, analysis),
|
||||
facebook: this.calculateFacebookScore(content, analysis),
|
||||
};
|
||||
}
|
||||
|
||||
private calculateTwitterScore(content: string, analysis: ContentAnalysis): number {
|
||||
let score = 50;
|
||||
if (analysis.wordCount <= 280) score += 20;
|
||||
if (analysis.hashtags.length <= 3) score += 10;
|
||||
if (analysis.questions >= 1) score += 10;
|
||||
if (content.includes('🧵') || content.toLowerCase().includes('thread')) score += 10;
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private calculateLinkedInScore(content: string, analysis: ContentAnalysis): number {
|
||||
let score = 50;
|
||||
if (analysis.wordCount >= 100 && analysis.wordCount <= 1300) score += 15;
|
||||
if (content.includes('\n\n')) score += 10; // Line breaks
|
||||
if (content.toLowerCase().includes('agree?') || content.includes('?')) score += 10;
|
||||
if (analysis.hashtags.length >= 3 && analysis.hashtags.length <= 5) score += 10;
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private calculateInstagramScore(content: string, analysis: ContentAnalysis): number {
|
||||
let score = 50;
|
||||
if (analysis.emojis.length >= 3) score += 15;
|
||||
if (analysis.hashtags.length >= 5 && analysis.hashtags.length <= 15) score += 15;
|
||||
if (analysis.callsToAction.length >= 1) score += 10;
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private calculateTikTokScore(content: string, analysis: ContentAnalysis): number {
|
||||
let score = 50;
|
||||
if (analysis.wordCount <= 150) score += 20;
|
||||
if (analysis.emojis.length >= 2) score += 10;
|
||||
if (content.toLowerCase().includes('pov') || content.toLowerCase().includes('wait for it')) score += 15;
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private calculateFacebookScore(content: string, analysis: ContentAnalysis): number {
|
||||
let score = 50;
|
||||
if (analysis.questions >= 1) score += 15;
|
||||
if (analysis.wordCount >= 50 && analysis.wordCount <= 500) score += 10;
|
||||
if (analysis.urls.length >= 1) score += 5;
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private calculateReadability(content: string): number {
|
||||
const words = content.split(/\s+/).length;
|
||||
const sentences = content.split(/[.!?]+/).filter(Boolean).length;
|
||||
const syllables = this.countSyllables(content);
|
||||
|
||||
if (sentences === 0 || words === 0) return 50;
|
||||
|
||||
// Flesch Reading Ease (simplified)
|
||||
const score = 206.835 - 1.015 * (words / sentences) - 84.6 * (syllables / words);
|
||||
return Math.min(Math.max(score, 0), 100);
|
||||
}
|
||||
|
||||
private countSyllables(text: string): number {
|
||||
const words = text.toLowerCase().match(/\b[a-z]+\b/g) || [];
|
||||
return (words as string[]).reduce((total: number, word: string) => {
|
||||
const vowelGroups = word.match(/[aeiouy]+/g) || [];
|
||||
return total + Math.max(1, vowelGroups.length);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private measureEmotionIntensity(content: string): number {
|
||||
let intensity = 0;
|
||||
const contentLower = content.toLowerCase();
|
||||
|
||||
const intensifiers = ['very', 'extremely', 'incredibly', 'absolutely', 'completely'];
|
||||
for (const word of intensifiers) {
|
||||
if (contentLower.includes(word)) intensity += 10;
|
||||
}
|
||||
|
||||
intensity += (content.match(/!/g) || []).length * 5;
|
||||
intensity += (content.match(/[\u{1F300}-\u{1F9FF}]/gu) || []).length * 3;
|
||||
|
||||
return Math.min(intensity, 100);
|
||||
}
|
||||
|
||||
private extractCTAs(content: string): string[] {
|
||||
const ctaPatterns = [
|
||||
/follow\s+(?:me|us)/gi,
|
||||
/like\s+(?:this|if)/gi,
|
||||
/share\s+(?:this|with)/gi,
|
||||
/comment\s+(?:below|your)/gi,
|
||||
/subscribe/gi,
|
||||
/click\s+(?:the\s+)?link/gi,
|
||||
/check\s+(?:out|the)/gi,
|
||||
/join\s+(?:us|me|the)/gi,
|
||||
/get\s+(?:your|the)/gi,
|
||||
/sign\s+up/gi,
|
||||
/learn\s+more/gi,
|
||||
/save\s+this/gi,
|
||||
];
|
||||
|
||||
const ctas: string[] = [];
|
||||
for (const pattern of ctaPatterns) {
|
||||
const matches = content.match(pattern);
|
||||
if (matches) ctas.push(...matches);
|
||||
}
|
||||
|
||||
return ctas;
|
||||
}
|
||||
|
||||
private generateImprovements(scores: Record<string, number>): string[] {
|
||||
const improvements: string[] = [];
|
||||
|
||||
if (scores.hook < 60) {
|
||||
improvements.push('Start with a stronger hook - use a pattern interrupt or curiosity gap');
|
||||
}
|
||||
if (scores.emotion < 50) {
|
||||
improvements.push('Add more emotional language to connect with readers');
|
||||
}
|
||||
if (scores.clarity < 50) {
|
||||
improvements.push('Simplify sentences and improve readability');
|
||||
}
|
||||
if (scores.actionability < 50) {
|
||||
improvements.push('Include a clear call-to-action');
|
||||
}
|
||||
if (scores.shareability < 50) {
|
||||
improvements.push('Make content more relatable and quotable');
|
||||
}
|
||||
|
||||
return improvements.slice(0, 3);
|
||||
}
|
||||
|
||||
private getViralPotential(
|
||||
overallScore: number,
|
||||
analysis: ContentAnalysis,
|
||||
): 'low' | 'medium' | 'high' | 'viral' {
|
||||
if (overallScore >= 85 && analysis.emotionIntensity >= 50) return 'viral';
|
||||
if (overallScore >= 70) return 'high';
|
||||
if (overallScore >= 50) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
// Psychology Triggers Service - Library of psychological persuasion patterns
|
||||
// Path: src/modules/neuro-marketing/services/psychology-triggers.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface PsychologyTrigger {
|
||||
id: string;
|
||||
name: string;
|
||||
category: TriggerCategory;
|
||||
description: string;
|
||||
howToUse: string;
|
||||
examples: string[];
|
||||
powerLevel: 1 | 2 | 3 | 4 | 5; // 1=subtle, 5=very powerful
|
||||
ethicalNote?: string;
|
||||
}
|
||||
|
||||
export type TriggerCategory =
|
||||
| 'scarcity'
|
||||
| 'authority'
|
||||
| 'social_proof'
|
||||
| 'reciprocity'
|
||||
| 'commitment'
|
||||
| 'liking'
|
||||
| 'fear'
|
||||
| 'curiosity'
|
||||
| 'belonging'
|
||||
| 'identity'
|
||||
| 'contrast'
|
||||
| 'anchoring';
|
||||
|
||||
export const PSYCHOLOGY_TRIGGERS: PsychologyTrigger[] = [
|
||||
// SCARCITY
|
||||
{
|
||||
id: 'scarcity-limited-time',
|
||||
name: 'Limited Time',
|
||||
category: 'scarcity',
|
||||
description: 'Creates urgency by emphasizing time constraints',
|
||||
howToUse: 'Set a clear deadline for offers or content',
|
||||
examples: [
|
||||
'Only 24 hours left to get this',
|
||||
'This window closes at midnight',
|
||||
'The deadline is approaching fast',
|
||||
],
|
||||
powerLevel: 4,
|
||||
ethicalNote: 'Use genuine deadlines, not fake urgency',
|
||||
},
|
||||
{
|
||||
id: 'scarcity-limited-quantity',
|
||||
name: 'Limited Quantity',
|
||||
category: 'scarcity',
|
||||
description: 'Emphasizes limited availability',
|
||||
howToUse: 'Show actual numbers of remaining items/spots',
|
||||
examples: [
|
||||
'Only 5 spots left',
|
||||
'87% claimed',
|
||||
'Last 3 available',
|
||||
],
|
||||
powerLevel: 5,
|
||||
},
|
||||
{
|
||||
id: 'scarcity-exclusive-access',
|
||||
name: 'Exclusive Access',
|
||||
category: 'scarcity',
|
||||
description: 'Makes the audience feel they have special access',
|
||||
howToUse: 'Create tiers of access or early-bird offers',
|
||||
examples: [
|
||||
'For subscribers only',
|
||||
'Early access for our community',
|
||||
'You\'re one of the first to see this',
|
||||
],
|
||||
powerLevel: 4,
|
||||
},
|
||||
|
||||
// AUTHORITY
|
||||
{
|
||||
id: 'authority-credentials',
|
||||
name: 'Credentials Display',
|
||||
category: 'authority',
|
||||
description: 'Establish expertise through credentials',
|
||||
howToUse: 'Mention relevant qualifications, experience, or achievements',
|
||||
examples: [
|
||||
'After 10 years in the industry...',
|
||||
'As someone who\'s helped 500+ clients...',
|
||||
'Based on my research at [Institution]...',
|
||||
],
|
||||
powerLevel: 4,
|
||||
},
|
||||
{
|
||||
id: 'authority-data',
|
||||
name: 'Data & Research',
|
||||
category: 'authority',
|
||||
description: 'Use statistics and research to build credibility',
|
||||
howToUse: 'Cite relevant studies, statistics, and measurable results',
|
||||
examples: [
|
||||
'Studies show that 73% of...',
|
||||
'According to research from Harvard...',
|
||||
'Data from 10,000 users reveals...',
|
||||
],
|
||||
powerLevel: 5,
|
||||
},
|
||||
|
||||
// SOCIAL PROOF
|
||||
{
|
||||
id: 'social-proof-numbers',
|
||||
name: 'Numbers & Scale',
|
||||
category: 'social_proof',
|
||||
description: 'Show large numbers to indicate popularity',
|
||||
howToUse: 'Display follower counts, user numbers, or engagement stats',
|
||||
examples: [
|
||||
'Join 50,000+ subscribers',
|
||||
'Used by teams at Google, Apple, and Netflix',
|
||||
'1M+ people have tried this',
|
||||
],
|
||||
powerLevel: 4,
|
||||
},
|
||||
{
|
||||
id: 'social-proof-testimonials',
|
||||
name: 'Testimonials',
|
||||
category: 'social_proof',
|
||||
description: 'Show real results from real people',
|
||||
howToUse: 'Include specific testimonials with names and results',
|
||||
examples: [
|
||||
'"This changed everything for me" - Sarah, CEO',
|
||||
'Here\'s what happened to Mike after 30 days...',
|
||||
'Real results from real users...',
|
||||
],
|
||||
powerLevel: 5,
|
||||
},
|
||||
|
||||
// RECIPROCITY
|
||||
{
|
||||
id: 'reciprocity-free-value',
|
||||
name: 'Free Value First',
|
||||
category: 'reciprocity',
|
||||
description: 'Give value before asking for anything',
|
||||
howToUse: 'Lead with free tips, insights, or resources',
|
||||
examples: [
|
||||
'Here\'s my complete framework (free)',
|
||||
'I\'m giving away everything I learned...',
|
||||
'No signup required, just value...',
|
||||
],
|
||||
powerLevel: 4,
|
||||
},
|
||||
|
||||
// CURIOSITY
|
||||
{
|
||||
id: 'curiosity-information-gap',
|
||||
name: 'Information Gap',
|
||||
category: 'curiosity',
|
||||
description: 'Create a gap between what they know and want to know',
|
||||
howToUse: 'Hint at valuable information without revealing it fully',
|
||||
examples: [
|
||||
'The secret nobody talks about...',
|
||||
'What they don\'t want you to know...',
|
||||
'The #1 mistake (it\'s not what you think)',
|
||||
],
|
||||
powerLevel: 5,
|
||||
},
|
||||
{
|
||||
id: 'curiosity-pattern-interrupt',
|
||||
name: 'Pattern Interrupt',
|
||||
category: 'curiosity',
|
||||
description: 'Break expected patterns to grab attention',
|
||||
howToUse: 'Start with unexpected statements or contrarian takes',
|
||||
examples: [
|
||||
'Stop working hard. Here\'s why:',
|
||||
'I was wrong about everything.',
|
||||
'Forget what you learned in school.',
|
||||
],
|
||||
powerLevel: 4,
|
||||
},
|
||||
|
||||
// FEAR (FOMO)
|
||||
{
|
||||
id: 'fear-missing-out',
|
||||
name: 'Fear of Missing Out',
|
||||
category: 'fear',
|
||||
description: 'Highlight what they\'ll miss if they don\'t act',
|
||||
howToUse: 'Paint a picture of the opportunity cost',
|
||||
examples: [
|
||||
'Don\'t be the last to know...',
|
||||
'While you wait, others are...',
|
||||
'This opportunity won\'t come again',
|
||||
],
|
||||
powerLevel: 4,
|
||||
ethicalNote: 'Use sparingly and ethically',
|
||||
},
|
||||
|
||||
// IDENTITY
|
||||
{
|
||||
id: 'identity-tribe',
|
||||
name: 'Tribal Identity',
|
||||
category: 'identity',
|
||||
description: 'Appeal to group identity and belonging',
|
||||
howToUse: 'Reference specific communities, roles, or aspirations',
|
||||
examples: [
|
||||
'For ambitious entrepreneurs who...',
|
||||
'If you\'re the type of person who...',
|
||||
'Smart investors know that...',
|
||||
],
|
||||
powerLevel: 5,
|
||||
},
|
||||
|
||||
// CONTRAST
|
||||
{
|
||||
id: 'contrast-before-after',
|
||||
name: 'Before/After',
|
||||
category: 'contrast',
|
||||
description: 'Show transformation through contrast',
|
||||
howToUse: 'Paint vivid before and after scenarios',
|
||||
examples: [
|
||||
'Before: Struggling. After: Thriving.',
|
||||
'From 0 to 100K in 6 months',
|
||||
'What I did vs. What I do now',
|
||||
],
|
||||
powerLevel: 4,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class PsychologyTriggersService {
|
||||
private readonly logger = new Logger(PsychologyTriggersService.name);
|
||||
|
||||
/**
|
||||
* Get all psychology triggers
|
||||
*/
|
||||
getAll(): PsychologyTrigger[] {
|
||||
return PSYCHOLOGY_TRIGGERS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get triggers by category
|
||||
*/
|
||||
getByCategory(category: TriggerCategory): PsychologyTrigger[] {
|
||||
return PSYCHOLOGY_TRIGGERS.filter((t) => t.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get triggers by power level
|
||||
*/
|
||||
getByPowerLevel(minLevel: number): PsychologyTrigger[] {
|
||||
return PSYCHOLOGY_TRIGGERS.filter((t) => t.powerLevel >= minLevel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get random triggers for content enhancement
|
||||
*/
|
||||
getRandomTriggers(count: number, excludeCategories?: TriggerCategory[]): PsychologyTrigger[] {
|
||||
let available = PSYCHOLOGY_TRIGGERS;
|
||||
|
||||
if (excludeCategories?.length) {
|
||||
available = available.filter((t) => !excludeCategories.includes(t.category));
|
||||
}
|
||||
|
||||
const shuffled = [...available].sort(() => Math.random() - 0.5);
|
||||
return shuffled.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze content for trigger usage
|
||||
*/
|
||||
analyzeContent(content: string): { used: PsychologyTrigger[]; suggestions: PsychologyTrigger[] } {
|
||||
const used: PsychologyTrigger[] = [];
|
||||
const contentLower = content.toLowerCase();
|
||||
|
||||
for (const trigger of PSYCHOLOGY_TRIGGERS) {
|
||||
const isUsed = trigger.examples.some((example) =>
|
||||
contentLower.includes(example.toLowerCase().substring(0, 10)),
|
||||
);
|
||||
if (isUsed) used.push(trigger);
|
||||
}
|
||||
|
||||
// Suggest unused high-power triggers
|
||||
const unusedCategories = new Set(
|
||||
PSYCHOLOGY_TRIGGERS.map((t) => t.category)
|
||||
.filter((cat) => !used.some((u) => u.category === cat)),
|
||||
);
|
||||
|
||||
const suggestions = PSYCHOLOGY_TRIGGERS
|
||||
.filter((t) => unusedCategories.has(t.category) && t.powerLevel >= 4)
|
||||
.slice(0, 3);
|
||||
|
||||
return { used, suggestions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate trigger-enhanced content suggestions
|
||||
*/
|
||||
enhanceWithTriggers(content: string, triggerIds: string[]): string[] {
|
||||
const triggers = PSYCHOLOGY_TRIGGERS.filter((t) => triggerIds.includes(t.id));
|
||||
const suggestions: string[] = [];
|
||||
|
||||
for (const trigger of triggers) {
|
||||
suggestions.push(`APPLY ${trigger.name}: ${trigger.howToUse}\nExample: ${trigger.examples[0]}`);
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
}
|
||||
206
src/modules/neuro-marketing/services/social-proof.service.ts
Normal file
206
src/modules/neuro-marketing/services/social-proof.service.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
// Social Proof Service - Social proof patterns for content
|
||||
// Path: src/modules/neuro-marketing/services/social-proof.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface SocialProofPattern {
|
||||
id: string;
|
||||
type: SocialProofType;
|
||||
template: string;
|
||||
variables: string[];
|
||||
impactLevel: 'low' | 'medium' | 'high';
|
||||
platforms: string[];
|
||||
}
|
||||
|
||||
export type SocialProofType =
|
||||
| 'numbers'
|
||||
| 'testimonial'
|
||||
| 'celebrity'
|
||||
| 'expert'
|
||||
| 'user_generated'
|
||||
| 'media_mention'
|
||||
| 'case_study'
|
||||
| 'certification'
|
||||
| 'awards';
|
||||
|
||||
export const SOCIAL_PROOF_PATTERNS: SocialProofPattern[] = [
|
||||
// NUMBERS
|
||||
{
|
||||
id: 'sp-numbers-users',
|
||||
type: 'numbers',
|
||||
template: 'Join {count}+ {audience} who already {action}',
|
||||
variables: ['count', 'audience', 'action'],
|
||||
impactLevel: 'high',
|
||||
platforms: ['twitter', 'linkedin', 'instagram'],
|
||||
},
|
||||
{
|
||||
id: 'sp-numbers-results',
|
||||
type: 'numbers',
|
||||
template: '{count} {metric} in just {timeframe}',
|
||||
variables: ['count', 'metric', 'timeframe'],
|
||||
impactLevel: 'high',
|
||||
platforms: ['all'],
|
||||
},
|
||||
{
|
||||
id: 'sp-numbers-percentage',
|
||||
type: 'numbers',
|
||||
template: '{percentage}% of our {audience} report {positive_outcome}',
|
||||
variables: ['percentage', 'audience', 'positive_outcome'],
|
||||
impactLevel: 'medium',
|
||||
platforms: ['linkedin', 'twitter'],
|
||||
},
|
||||
|
||||
// TESTIMONIALS
|
||||
{
|
||||
id: 'sp-testimonial-quote',
|
||||
type: 'testimonial',
|
||||
template: '"{quote}" - {name}, {title}',
|
||||
variables: ['quote', 'name', 'title'],
|
||||
impactLevel: 'high',
|
||||
platforms: ['all'],
|
||||
},
|
||||
{
|
||||
id: 'sp-testimonial-result',
|
||||
type: 'testimonial',
|
||||
template: '{name} went from {before} to {after} in {timeframe}',
|
||||
variables: ['name', 'before', 'after', 'timeframe'],
|
||||
impactLevel: 'high',
|
||||
platforms: ['all'],
|
||||
},
|
||||
|
||||
// CELEBRITY/INFLUENCER
|
||||
{
|
||||
id: 'sp-celebrity-used',
|
||||
type: 'celebrity',
|
||||
template: 'Used by {celebrity_name} and {count}+ other industry leaders',
|
||||
variables: ['celebrity_name', 'count'],
|
||||
impactLevel: 'high',
|
||||
platforms: ['instagram', 'twitter', 'tiktok'],
|
||||
},
|
||||
|
||||
// EXPERT
|
||||
{
|
||||
id: 'sp-expert-recommendation',
|
||||
type: 'expert',
|
||||
template: 'Recommended by {expert_count} {expert_type} experts',
|
||||
variables: ['expert_count', 'expert_type'],
|
||||
impactLevel: 'medium',
|
||||
platforms: ['linkedin', 'twitter'],
|
||||
},
|
||||
{
|
||||
id: 'sp-expert-quote',
|
||||
type: 'expert',
|
||||
template: 'According to {expert_name}, "{quote}"',
|
||||
variables: ['expert_name', 'quote'],
|
||||
impactLevel: 'medium',
|
||||
platforms: ['all'],
|
||||
},
|
||||
|
||||
// USER GENERATED
|
||||
{
|
||||
id: 'sp-ugc-share',
|
||||
type: 'user_generated',
|
||||
template: 'See what our community is saying about {topic}',
|
||||
variables: ['topic'],
|
||||
impactLevel: 'medium',
|
||||
platforms: ['instagram', 'tiktok'],
|
||||
},
|
||||
|
||||
// MEDIA MENTION
|
||||
{
|
||||
id: 'sp-media-featured',
|
||||
type: 'media_mention',
|
||||
template: 'As featured in {media_outlet}',
|
||||
variables: ['media_outlet'],
|
||||
impactLevel: 'high',
|
||||
platforms: ['all'],
|
||||
},
|
||||
{
|
||||
id: 'sp-media-multi',
|
||||
type: 'media_mention',
|
||||
template: 'Featured in {outlet1}, {outlet2}, and {outlet3}',
|
||||
variables: ['outlet1', 'outlet2', 'outlet3'],
|
||||
impactLevel: 'high',
|
||||
platforms: ['linkedin', 'twitter'],
|
||||
},
|
||||
|
||||
// CASE STUDY
|
||||
{
|
||||
id: 'sp-case-result',
|
||||
type: 'case_study',
|
||||
template: 'How {company} achieved {result} using {method}',
|
||||
variables: ['company', 'result', 'method'],
|
||||
impactLevel: 'high',
|
||||
platforms: ['linkedin', 'twitter'],
|
||||
},
|
||||
|
||||
// CERTIFICATIONS
|
||||
{
|
||||
id: 'sp-cert-badge',
|
||||
type: 'certification',
|
||||
template: '{certification} Certified | {organization}',
|
||||
variables: ['certification', 'organization'],
|
||||
impactLevel: 'medium',
|
||||
platforms: ['linkedin'],
|
||||
},
|
||||
|
||||
// AWARDS
|
||||
{
|
||||
id: 'sp-award-winner',
|
||||
type: 'awards',
|
||||
template: '{award_name} Winner {year}',
|
||||
variables: ['award_name', 'year'],
|
||||
impactLevel: 'high',
|
||||
platforms: ['all'],
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class SocialProofService {
|
||||
private readonly logger = new Logger(SocialProofService.name);
|
||||
|
||||
/**
|
||||
* Get all social proof patterns
|
||||
*/
|
||||
getAll(): SocialProofPattern[] {
|
||||
return SOCIAL_PROOF_PATTERNS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patterns by type
|
||||
*/
|
||||
getByType(type: SocialProofType): SocialProofPattern[] {
|
||||
return SOCIAL_PROOF_PATTERNS.filter((p) => p.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get patterns for platform
|
||||
*/
|
||||
getForPlatform(platform: string): SocialProofPattern[] {
|
||||
return SOCIAL_PROOF_PATTERNS.filter(
|
||||
(p) => p.platforms.includes('all') || p.platforms.includes(platform),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate social proof text
|
||||
*/
|
||||
generate(patternId: string, variables: Record<string, string>): string {
|
||||
const pattern = SOCIAL_PROOF_PATTERNS.find((p) => p.id === patternId);
|
||||
if (!pattern) return '';
|
||||
|
||||
let result = pattern.template;
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
result = result.replace(`{${key}}`, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get high-impact patterns
|
||||
*/
|
||||
getHighImpact(): SocialProofPattern[] {
|
||||
return SOCIAL_PROOF_PATTERNS.filter((p) => p.impactLevel === 'high');
|
||||
}
|
||||
}
|
||||
271
src/modules/neuro-marketing/services/urgency-tactics.service.ts
Normal file
271
src/modules/neuro-marketing/services/urgency-tactics.service.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// Urgency Tactics Service - Scarcity and urgency patterns
|
||||
// Path: src/modules/neuro-marketing/services/urgency-tactics.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface UrgencyTactic {
|
||||
id: string;
|
||||
type: UrgencyType;
|
||||
name: string;
|
||||
template: string;
|
||||
variables: string[];
|
||||
intensity: 'subtle' | 'moderate' | 'aggressive';
|
||||
ethicalGuideline: string;
|
||||
}
|
||||
|
||||
export type UrgencyType =
|
||||
| 'time_limited'
|
||||
| 'quantity_limited'
|
||||
| 'exclusive_access'
|
||||
| 'price_increase'
|
||||
| 'deadline'
|
||||
| 'countdown'
|
||||
| 'seasonal'
|
||||
| 'one_time';
|
||||
|
||||
export const URGENCY_TACTICS: UrgencyTactic[] = [
|
||||
// TIME LIMITED
|
||||
{
|
||||
id: 'urg-time-24h',
|
||||
type: 'time_limited',
|
||||
name: '24-Hour Window',
|
||||
template: 'Only {hours} hours left to {action}',
|
||||
variables: ['hours', 'action'],
|
||||
intensity: 'moderate',
|
||||
ethicalGuideline: 'Use real deadlines only',
|
||||
},
|
||||
{
|
||||
id: 'urg-time-ending',
|
||||
type: 'time_limited',
|
||||
name: 'Ending Soon',
|
||||
template: 'This {offer} ends {timeframe}',
|
||||
variables: ['offer', 'timeframe'],
|
||||
intensity: 'moderate',
|
||||
ethicalGuideline: 'Specify exact end time when possible',
|
||||
},
|
||||
{
|
||||
id: 'urg-time-today',
|
||||
type: 'time_limited',
|
||||
name: 'Today Only',
|
||||
template: 'Today only: {offer}',
|
||||
variables: ['offer'],
|
||||
intensity: 'aggressive',
|
||||
ethicalGuideline: 'Must genuinely be only available today',
|
||||
},
|
||||
|
||||
// QUANTITY LIMITED
|
||||
{
|
||||
id: 'urg-qty-spots',
|
||||
type: 'quantity_limited',
|
||||
name: 'Limited Spots',
|
||||
template: 'Only {count} spots remaining',
|
||||
variables: ['count'],
|
||||
intensity: 'aggressive',
|
||||
ethicalGuideline: 'Display real-time accurate count',
|
||||
},
|
||||
{
|
||||
id: 'urg-qty-claimed',
|
||||
type: 'quantity_limited',
|
||||
name: 'Percentage Claimed',
|
||||
template: '{percentage}% already claimed. {remaining} spots left.',
|
||||
variables: ['percentage', 'remaining'],
|
||||
intensity: 'moderate',
|
||||
ethicalGuideline: 'Use actual numbers',
|
||||
},
|
||||
{
|
||||
id: 'urg-qty-last',
|
||||
type: 'quantity_limited',
|
||||
name: 'Last Few',
|
||||
template: 'Last {count} available at this price',
|
||||
variables: ['count'],
|
||||
intensity: 'aggressive',
|
||||
ethicalGuideline: 'Be specific and accurate',
|
||||
},
|
||||
|
||||
// EXCLUSIVE ACCESS
|
||||
{
|
||||
id: 'urg-excl-early',
|
||||
type: 'exclusive_access',
|
||||
name: 'Early Access',
|
||||
template: 'Early access for {audience} only',
|
||||
variables: ['audience'],
|
||||
intensity: 'subtle',
|
||||
ethicalGuideline: 'Clearly define who qualifies',
|
||||
},
|
||||
{
|
||||
id: 'urg-excl-invite',
|
||||
type: 'exclusive_access',
|
||||
name: 'Invite Only',
|
||||
template: 'By invitation only. You\'re on the list.',
|
||||
variables: [],
|
||||
intensity: 'subtle',
|
||||
ethicalGuideline: 'Use for genuinely exclusive offers',
|
||||
},
|
||||
|
||||
// PRICE INCREASE
|
||||
{
|
||||
id: 'urg-price-increase',
|
||||
type: 'price_increase',
|
||||
name: 'Price Increase Coming',
|
||||
template: 'Price increases to {new_price} on {date}',
|
||||
variables: ['new_price', 'date'],
|
||||
intensity: 'aggressive',
|
||||
ethicalGuideline: 'Only announce real price increases',
|
||||
},
|
||||
{
|
||||
id: 'urg-price-lock',
|
||||
type: 'price_increase',
|
||||
name: 'Lock In Price',
|
||||
template: 'Lock in {current_price} before it goes up',
|
||||
variables: ['current_price'],
|
||||
intensity: 'moderate',
|
||||
ethicalGuideline: 'Be transparent about pricing changes',
|
||||
},
|
||||
|
||||
// DEADLINE
|
||||
{
|
||||
id: 'urg-deadline-hard',
|
||||
type: 'deadline',
|
||||
name: 'Hard Deadline',
|
||||
template: 'Doors close {date} at {time}',
|
||||
variables: ['date', 'time'],
|
||||
intensity: 'aggressive',
|
||||
ethicalGuideline: 'Use immovable deadlines only',
|
||||
},
|
||||
{
|
||||
id: 'urg-deadline-soft',
|
||||
type: 'deadline',
|
||||
name: 'Soft Deadline',
|
||||
template: 'Registration recommended by {date}',
|
||||
variables: ['date'],
|
||||
intensity: 'subtle',
|
||||
ethicalGuideline: 'Explain why the deadline matters',
|
||||
},
|
||||
|
||||
// COUNTDOWN
|
||||
{
|
||||
id: 'urg-countdown-launch',
|
||||
type: 'countdown',
|
||||
name: 'Launch Countdown',
|
||||
template: '{days} days until launch. Get ready.',
|
||||
variables: ['days'],
|
||||
intensity: 'subtle',
|
||||
ethicalGuideline: 'Build anticipation authentically',
|
||||
},
|
||||
|
||||
// SEASONAL
|
||||
{
|
||||
id: 'urg-seasonal-event',
|
||||
type: 'seasonal',
|
||||
name: 'Seasonal Event',
|
||||
template: '{event} special - ends {end_date}',
|
||||
variables: ['event', 'end_date'],
|
||||
intensity: 'moderate',
|
||||
ethicalGuideline: 'Tie to real seasonal events',
|
||||
},
|
||||
|
||||
// ONE TIME
|
||||
{
|
||||
id: 'urg-onetime-offer',
|
||||
type: 'one_time',
|
||||
name: 'One-Time Offer',
|
||||
template: 'This page will not reload. {offer} available now only.',
|
||||
variables: ['offer'],
|
||||
intensity: 'aggressive',
|
||||
ethicalGuideline: 'Use very sparingly, must be genuine',
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class UrgencyTacticsService {
|
||||
private readonly logger = new Logger(UrgencyTacticsService.name);
|
||||
|
||||
/**
|
||||
* Get all urgency tactics
|
||||
*/
|
||||
getAll(): UrgencyTactic[] {
|
||||
return URGENCY_TACTICS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tactics by type
|
||||
*/
|
||||
getByType(type: UrgencyType): UrgencyTactic[] {
|
||||
return URGENCY_TACTICS.filter((t) => t.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tactics by intensity
|
||||
*/
|
||||
getByIntensity(intensity: 'subtle' | 'moderate' | 'aggressive'): UrgencyTactic[] {
|
||||
return URGENCY_TACTICS.filter((t) => t.intensity === intensity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate urgency text
|
||||
*/
|
||||
generate(tacticId: string, variables: Record<string, string>): string {
|
||||
const tactic = URGENCY_TACTICS.find((t) => t.id === tacticId);
|
||||
if (!tactic) return '';
|
||||
|
||||
let result = tactic.template;
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
result = result.replace(`{${key}}`, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ethical urgency suggestions
|
||||
*/
|
||||
getEthicalSuggestions(): UrgencyTactic[] {
|
||||
return URGENCY_TACTICS.filter((t) => t.intensity !== 'aggressive');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate urgency score for content
|
||||
*/
|
||||
analyzeUrgency(content: string): {
|
||||
score: number;
|
||||
tactics: string[];
|
||||
recommendations: string[];
|
||||
} {
|
||||
const contentLower = content.toLowerCase();
|
||||
const detectedTactics: string[] = [];
|
||||
let score = 0;
|
||||
|
||||
const urgencyKeywords = [
|
||||
{ word: 'limited', points: 2 },
|
||||
{ word: 'ending', points: 2 },
|
||||
{ word: 'only', points: 1 },
|
||||
{ word: 'last chance', points: 3 },
|
||||
{ word: 'deadline', points: 2 },
|
||||
{ word: 'hurry', points: 2 },
|
||||
{ word: 'now', points: 1 },
|
||||
{ word: 'today', points: 2 },
|
||||
{ word: 'expires', points: 2 },
|
||||
];
|
||||
|
||||
for (const kw of urgencyKeywords) {
|
||||
if (contentLower.includes(kw.word)) {
|
||||
score += kw.points;
|
||||
detectedTactics.push(kw.word);
|
||||
}
|
||||
}
|
||||
|
||||
const recommendations: string[] = [];
|
||||
if (score < 3) {
|
||||
recommendations.push('Consider adding a subtle time-based element');
|
||||
recommendations.push('Include a quantity or availability indicator');
|
||||
} else if (score > 8) {
|
||||
recommendations.push('Urgency level is high - ensure all claims are genuine');
|
||||
}
|
||||
|
||||
return {
|
||||
score: Math.min(score, 10),
|
||||
tactics: detectedTactics,
|
||||
recommendations,
|
||||
};
|
||||
}
|
||||
}
|
||||
11
src/modules/scheduling/index.ts
Normal file
11
src/modules/scheduling/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Scheduling Module - Index exports
|
||||
// Path: src/modules/scheduling/index.ts
|
||||
|
||||
export * from './scheduling.module';
|
||||
export * from './scheduling.service';
|
||||
export * from './scheduling.controller';
|
||||
export * from './services/content-calendar.service';
|
||||
export * from './services/workflow-templates.service';
|
||||
export * from './services/optimal-timing.service';
|
||||
export * from './services/queue-manager.service';
|
||||
export * from './services/automation-engine.service';
|
||||
331
src/modules/scheduling/scheduling.controller.ts
Normal file
331
src/modules/scheduling/scheduling.controller.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
// Scheduling Controller - API endpoints
|
||||
// Path: src/modules/scheduling/scheduling.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { SchedulingService } from './scheduling.service';
|
||||
|
||||
@Controller('scheduling')
|
||||
export class SchedulingController {
|
||||
constructor(private readonly service: SchedulingService) { }
|
||||
|
||||
// ========== CALENDAR ==========
|
||||
|
||||
@Post('calendar/event')
|
||||
createCalendarEvent(
|
||||
@Body() body: {
|
||||
userId: string;
|
||||
title: string;
|
||||
content: {
|
||||
type: 'post' | 'story' | 'reel' | 'video' | 'thread' | 'carousel' | 'article';
|
||||
text: string;
|
||||
mediaUrls?: string[];
|
||||
hashtags?: string[];
|
||||
};
|
||||
platforms: string[];
|
||||
scheduledDate: string;
|
||||
scheduledTime: string;
|
||||
timezone?: string;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
},
|
||||
) {
|
||||
return this.service.createCalendarEvent(body.userId, {
|
||||
...body,
|
||||
scheduledDate: new Date(body.scheduledDate),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('calendar/:userId')
|
||||
getCalendarView(
|
||||
@Param('userId') userId: string,
|
||||
@Query('view') view: 'day' | 'week' | 'month' | 'quarter' = 'week',
|
||||
@Query('startDate') startDate?: string,
|
||||
) {
|
||||
return this.service.getCalendarView(userId, view, startDate ? new Date(startDate) : new Date());
|
||||
}
|
||||
|
||||
@Get('calendar/:userId/slots')
|
||||
getAvailableSlots(
|
||||
@Param('userId') userId: string,
|
||||
@Query('date') date: string,
|
||||
@Query('platforms') platforms: string,
|
||||
) {
|
||||
return this.service.getAvailableSlots(userId, new Date(date), platforms.split(','));
|
||||
}
|
||||
|
||||
@Put('calendar/:userId/event/:eventId')
|
||||
updateCalendarEvent(
|
||||
@Param('userId') userId: string,
|
||||
@Param('eventId') eventId: string,
|
||||
@Body() updates: any,
|
||||
) {
|
||||
return this.service.updateCalendarEvent(userId, eventId, updates);
|
||||
}
|
||||
|
||||
@Delete('calendar/:userId/event/:eventId')
|
||||
deleteCalendarEvent(@Param('userId') userId: string, @Param('eventId') eventId: string) {
|
||||
return { success: this.service.deleteCalendarEvent(userId, eventId) };
|
||||
}
|
||||
|
||||
@Post('calendar/:userId/event/:eventId/duplicate')
|
||||
duplicateCalendarEvent(
|
||||
@Param('userId') userId: string,
|
||||
@Param('eventId') eventId: string,
|
||||
@Body() body: { newDate: string },
|
||||
) {
|
||||
return this.service.duplicateCalendarEvent(userId, eventId, new Date(body.newDate));
|
||||
}
|
||||
|
||||
@Get('calendar/:userId/gaps')
|
||||
getContentGaps(
|
||||
@Param('userId') userId: string,
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
@Query('platforms') platforms: string,
|
||||
) {
|
||||
return this.service.getContentGaps(userId, new Date(startDate), new Date(endDate), platforms.split(','));
|
||||
}
|
||||
|
||||
@Get('calendar/:userId/frequency')
|
||||
getPostingFrequency(@Param('userId') userId: string) {
|
||||
return this.service.getPostingFrequency(userId);
|
||||
}
|
||||
|
||||
// ========== WORKFLOWS ==========
|
||||
|
||||
@Get('workflows/system')
|
||||
getSystemWorkflows() {
|
||||
return this.service.getSystemWorkflows();
|
||||
}
|
||||
|
||||
@Get('workflows/:userId')
|
||||
getUserWorkflows(@Param('userId') userId: string) {
|
||||
return this.service.getUserWorkflows(userId);
|
||||
}
|
||||
|
||||
@Post('workflows/:userId')
|
||||
createWorkflow(@Param('userId') userId: string, @Body() body: any) {
|
||||
return this.service.createWorkflow(userId, body);
|
||||
}
|
||||
|
||||
@Post('workflows/:userId/apply/:templateId')
|
||||
applyWorkflow(
|
||||
@Param('userId') userId: string,
|
||||
@Param('templateId') templateId: string,
|
||||
@Body() body: { startDate?: string },
|
||||
) {
|
||||
return this.service.applyWorkflow(userId, templateId, body.startDate ? new Date(body.startDate) : new Date());
|
||||
}
|
||||
|
||||
@Delete('workflows/:userId/:templateId')
|
||||
deleteWorkflow(@Param('userId') userId: string, @Param('templateId') templateId: string) {
|
||||
return { success: this.service.deleteWorkflow(userId, templateId) };
|
||||
}
|
||||
|
||||
@Post('workflows/generate')
|
||||
generateOptimalSchedule(@Body() body: { platforms: string[]; postsPerDay: number; focus: any }) {
|
||||
return this.service.generateOptimalSchedule(body.platforms, body.postsPerDay, body.focus);
|
||||
}
|
||||
|
||||
@Get('content-mix')
|
||||
getContentMixRecommendation(@Query('proven') proven?: string, @Query('experimental') experimental?: string) {
|
||||
const performanceData = proven && experimental
|
||||
? { proven: parseFloat(proven), experimental: parseFloat(experimental) }
|
||||
: undefined;
|
||||
return this.service.getContentMixRecommendation(performanceData);
|
||||
}
|
||||
|
||||
// ========== OPTIMAL TIMING ==========
|
||||
|
||||
@Get('timing/:platform')
|
||||
getOptimalTimes(
|
||||
@Param('platform') platform: string,
|
||||
@Query('contentType') contentType?: string,
|
||||
@Query('timezone') timezone?: string,
|
||||
@Query('limit') limit?: string,
|
||||
) {
|
||||
return this.service.getOptimalTimes(platform, {
|
||||
contentType,
|
||||
timezone,
|
||||
limit: limit ? parseInt(limit, 10) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('timing/:platform/best')
|
||||
getBestTimeForContent(
|
||||
@Param('platform') platform: string,
|
||||
@Query('contentType') contentType: string,
|
||||
@Query('date') date?: string,
|
||||
) {
|
||||
return this.service.getBestTimeForContent(platform, contentType, date ? new Date(date) : undefined);
|
||||
}
|
||||
|
||||
@Post('timing/cross-platform')
|
||||
getCrossplatformSchedule(@Body() body: { platforms: string[]; postsPerPlatform: number }) {
|
||||
return this.service.getCrossplatformSchedule(body.platforms, body.postsPerPlatform);
|
||||
}
|
||||
|
||||
@Post('timing/spaced')
|
||||
getSpacedSchedule(@Body() body: { platforms: string[]; date: string; minGapMinutes?: number }) {
|
||||
return this.service.getSpacedSchedule(body.platforms, new Date(body.date), body.minGapMinutes);
|
||||
}
|
||||
|
||||
// ========== QUEUE ==========
|
||||
|
||||
@Post('queue/:userId')
|
||||
addToQueue(@Param('userId') userId: string, @Body() body: any) {
|
||||
return this.service.addToQueue(userId, {
|
||||
...body,
|
||||
scheduledTime: new Date(body.scheduledTime),
|
||||
});
|
||||
}
|
||||
|
||||
@Post('queue/:userId/bulk')
|
||||
bulkAddToQueue(@Param('userId') userId: string, @Body() body: { items: any[] }) {
|
||||
return this.service.bulkAddToQueue(
|
||||
userId,
|
||||
body.items.map((i) => ({ ...i, scheduledTime: new Date(i.scheduledTime) })),
|
||||
);
|
||||
}
|
||||
|
||||
@Get('queue/:userId')
|
||||
getQueue(@Param('userId') userId: string, @Query() filter: any) {
|
||||
return this.service.getQueue(userId, filter);
|
||||
}
|
||||
|
||||
@Get('queue/:userId/stats')
|
||||
getQueueStats(@Param('userId') userId: string) {
|
||||
return this.service.getQueueStats(userId);
|
||||
}
|
||||
|
||||
@Put('queue/:userId/:itemId')
|
||||
updateQueueItem(
|
||||
@Param('userId') userId: string,
|
||||
@Param('itemId') itemId: string,
|
||||
@Body() updates: any,
|
||||
) {
|
||||
return this.service.updateQueueItem(userId, itemId, updates);
|
||||
}
|
||||
|
||||
@Delete('queue/:userId/:itemId')
|
||||
cancelQueueItem(@Param('userId') userId: string, @Param('itemId') itemId: string) {
|
||||
return { success: this.service.cancelQueueItem(userId, itemId) };
|
||||
}
|
||||
|
||||
@Post('queue/:userId/reorder')
|
||||
reorderQueue(@Param('userId') userId: string, @Body() body: { itemIds: string[] }) {
|
||||
return this.service.reorderQueue(userId, body.itemIds);
|
||||
}
|
||||
|
||||
@Delete('queue/:userId/completed')
|
||||
clearCompletedQueue(@Param('userId') userId: string) {
|
||||
return { cleared: this.service.clearCompletedQueue(userId) };
|
||||
}
|
||||
|
||||
@Post('queue/:userId/retry-failed')
|
||||
retryFailedQueue(@Param('userId') userId: string) {
|
||||
return { retried: this.service.retryFailedQueue(userId) };
|
||||
}
|
||||
|
||||
// ========== AUTOMATION ==========
|
||||
|
||||
@Get('automation/templates')
|
||||
getAutomationTemplates() {
|
||||
return this.service.getAutomationTemplates();
|
||||
}
|
||||
|
||||
@Post('automation/:userId')
|
||||
createAutomation(@Param('userId') userId: string, @Body() body: any) {
|
||||
return this.service.createAutomation(userId, body);
|
||||
}
|
||||
|
||||
@Post('automation/:userId/from-template')
|
||||
createAutomationFromTemplate(
|
||||
@Param('userId') userId: string,
|
||||
@Body() body: { templateName: string },
|
||||
) {
|
||||
return this.service.createAutomationFromTemplate(userId, body.templateName);
|
||||
}
|
||||
|
||||
@Get('automation/:userId')
|
||||
getAutomations(@Param('userId') userId: string, @Query('activeOnly') activeOnly?: string) {
|
||||
return this.service.getAutomations(userId, activeOnly === 'true');
|
||||
}
|
||||
|
||||
@Put('automation/:userId/:ruleId')
|
||||
updateAutomation(
|
||||
@Param('userId') userId: string,
|
||||
@Param('ruleId') ruleId: string,
|
||||
@Body() updates: any,
|
||||
) {
|
||||
return this.service.updateAutomation(userId, ruleId, updates);
|
||||
}
|
||||
|
||||
@Post('automation/:userId/:ruleId/toggle')
|
||||
toggleAutomation(@Param('userId') userId: string, @Param('ruleId') ruleId: string) {
|
||||
return this.service.toggleAutomation(userId, ruleId);
|
||||
}
|
||||
|
||||
@Delete('automation/:userId/:ruleId')
|
||||
deleteAutomation(@Param('userId') userId: string, @Param('ruleId') ruleId: string) {
|
||||
return { success: this.service.deleteAutomation(userId, ruleId) };
|
||||
}
|
||||
|
||||
@Post('automation/:userId/:ruleId/trigger')
|
||||
async triggerAutomation(
|
||||
@Param('userId') userId: string,
|
||||
@Param('ruleId') ruleId: string,
|
||||
@Body() body?: { context?: Record<string, any> },
|
||||
) {
|
||||
return this.service.triggerAutomation(userId, ruleId, body?.context);
|
||||
}
|
||||
|
||||
@Get('automation/:userId/logs')
|
||||
getAutomationLogs(@Param('userId') userId: string, @Query('limit') limit?: string) {
|
||||
return this.service.getAutomationLogs(userId, limit ? parseInt(limit, 10) : undefined);
|
||||
}
|
||||
|
||||
@Get('automation/:userId/stats')
|
||||
getAutomationStats(@Param('userId') userId: string) {
|
||||
return this.service.getAutomationStats(userId);
|
||||
}
|
||||
|
||||
// ========== COMBINED ==========
|
||||
|
||||
@Post('quick-schedule/:userId')
|
||||
quickSchedule(
|
||||
@Param('userId') userId: string,
|
||||
@Body() body: {
|
||||
content: any;
|
||||
platforms: string[];
|
||||
preferredDate?: string;
|
||||
},
|
||||
) {
|
||||
return this.service.quickSchedule(userId, {
|
||||
...body,
|
||||
preferredDate: body.preferredDate ? new Date(body.preferredDate) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('apply-workflow-calendar/:userId')
|
||||
applyWorkflowToCalendar(
|
||||
@Param('userId') userId: string,
|
||||
@Body() body: { templateId: string; weeks?: number },
|
||||
) {
|
||||
return this.service.applyWorkflowToCalendar(userId, body.templateId, body.weeks);
|
||||
}
|
||||
|
||||
@Get('overview/:userId')
|
||||
getSchedulingOverview(@Param('userId') userId: string) {
|
||||
return this.service.getSchedulingOverview(userId);
|
||||
}
|
||||
}
|
||||
27
src/modules/scheduling/scheduling.module.ts
Normal file
27
src/modules/scheduling/scheduling.module.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Scheduling Module - Content calendar and automation
|
||||
// Path: src/modules/scheduling/scheduling.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../database/prisma.module';
|
||||
import { SchedulingService } from './scheduling.service';
|
||||
import { SchedulingController } from './scheduling.controller';
|
||||
import { ContentCalendarService } from './services/content-calendar.service';
|
||||
import { WorkflowTemplatesService } from './services/workflow-templates.service';
|
||||
import { OptimalTimingService } from './services/optimal-timing.service';
|
||||
import { QueueManagerService } from './services/queue-manager.service';
|
||||
import { AutomationEngineService } from './services/automation-engine.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
SchedulingService,
|
||||
ContentCalendarService,
|
||||
WorkflowTemplatesService,
|
||||
OptimalTimingService,
|
||||
QueueManagerService,
|
||||
AutomationEngineService,
|
||||
],
|
||||
controllers: [SchedulingController],
|
||||
exports: [SchedulingService],
|
||||
})
|
||||
export class SchedulingModule { }
|
||||
288
src/modules/scheduling/scheduling.service.ts
Normal file
288
src/modules/scheduling/scheduling.service.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
// Scheduling Service - Main orchestration
|
||||
// Path: src/modules/scheduling/scheduling.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { ContentCalendarService, CalendarEvent, CalendarView, ContentSlot } from './services/content-calendar.service';
|
||||
import { WorkflowTemplatesService, WorkflowTemplate, WeeklySchedule, ContentMix } from './services/workflow-templates.service';
|
||||
import { OptimalTimingService, TimingRecommendation, TimeSlot } from './services/optimal-timing.service';
|
||||
import { QueueManagerService, QueueItem, QueueStats, QueueFilter } from './services/queue-manager.service';
|
||||
import { AutomationEngineService, AutomationRule, AutomationLog } from './services/automation-engine.service';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulingService {
|
||||
private readonly logger = new Logger(SchedulingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly calendarService: ContentCalendarService,
|
||||
private readonly workflowService: WorkflowTemplatesService,
|
||||
private readonly timingService: OptimalTimingService,
|
||||
private readonly queueService: QueueManagerService,
|
||||
private readonly automationService: AutomationEngineService,
|
||||
) { }
|
||||
|
||||
// ========== CALENDAR ==========
|
||||
|
||||
createCalendarEvent(userId: string, input: Parameters<typeof this.calendarService.createEvent>[1]): CalendarEvent {
|
||||
return this.calendarService.createEvent(userId, input);
|
||||
}
|
||||
|
||||
getCalendarView(userId: string, viewType: 'day' | 'week' | 'month' | 'quarter', startDate: Date): CalendarView {
|
||||
return this.calendarService.getCalendarView(userId, viewType, startDate);
|
||||
}
|
||||
|
||||
getAvailableSlots(userId: string, date: Date, platforms: string[]): ContentSlot[] {
|
||||
return this.calendarService.getAvailableSlots(userId, date, platforms);
|
||||
}
|
||||
|
||||
updateCalendarEvent(userId: string, eventId: string, updates: Partial<CalendarEvent>): CalendarEvent | null {
|
||||
return this.calendarService.updateEvent(userId, eventId, updates);
|
||||
}
|
||||
|
||||
deleteCalendarEvent(userId: string, eventId: string): boolean {
|
||||
return this.calendarService.deleteEvent(userId, eventId);
|
||||
}
|
||||
|
||||
duplicateCalendarEvent(userId: string, eventId: string, newDate: Date): CalendarEvent | null {
|
||||
return this.calendarService.duplicateEvent(userId, eventId, newDate);
|
||||
}
|
||||
|
||||
getContentGaps(userId: string, startDate: Date, endDate: Date, platforms: string[]): Date[] {
|
||||
return this.calendarService.getContentGaps(userId, startDate, endDate, platforms);
|
||||
}
|
||||
|
||||
getPostingFrequency(userId: string) {
|
||||
return this.calendarService.getPostingFrequency(userId);
|
||||
}
|
||||
|
||||
// ========== WORKFLOW TEMPLATES ==========
|
||||
|
||||
getSystemWorkflows(): WorkflowTemplate[] {
|
||||
return this.workflowService.getSystemTemplates();
|
||||
}
|
||||
|
||||
getUserWorkflows(userId: string): WorkflowTemplate[] {
|
||||
return this.workflowService.getUserTemplates(userId);
|
||||
}
|
||||
|
||||
createWorkflow(userId: string, input: Parameters<typeof this.workflowService.createCustomTemplate>[1]): WorkflowTemplate {
|
||||
return this.workflowService.createCustomTemplate(userId, input);
|
||||
}
|
||||
|
||||
applyWorkflow(userId: string, templateId: string, startDate: Date) {
|
||||
return this.workflowService.applyTemplate(userId, templateId, startDate);
|
||||
}
|
||||
|
||||
generateOptimalSchedule(platforms: string[], postsPerDay: number, focus: Parameters<typeof this.workflowService.generateOptimalSchedule>[2]): WeeklySchedule {
|
||||
return this.workflowService.generateOptimalSchedule(platforms, postsPerDay, focus);
|
||||
}
|
||||
|
||||
getContentMixRecommendation(performanceData?: { proven: number; experimental: number }): ContentMix {
|
||||
return this.workflowService.getContentMixRecommendation(performanceData);
|
||||
}
|
||||
|
||||
deleteWorkflow(userId: string, templateId: string): boolean {
|
||||
return this.workflowService.deleteTemplate(userId, templateId);
|
||||
}
|
||||
|
||||
// ========== OPTIMAL TIMING ==========
|
||||
|
||||
getOptimalTimes(platform: string, options?: { contentType?: string; timezone?: string; limit?: number }): TimingRecommendation {
|
||||
return this.timingService.getOptimalTimes(platform, options);
|
||||
}
|
||||
|
||||
getBestTimeForContent(platform: string, contentType: string, targetDate?: Date): TimeSlot {
|
||||
return this.timingService.getBestTimeForContent(platform, contentType, targetDate);
|
||||
}
|
||||
|
||||
getCrossplatformSchedule(platforms: string[], postsPerPlatform: number) {
|
||||
return this.timingService.getCrossplatformSchedule(platforms, postsPerPlatform);
|
||||
}
|
||||
|
||||
getSpacedSchedule(platforms: string[], date: Date, minGapMinutes?: number) {
|
||||
return this.timingService.getSpacedSchedule(platforms, date, minGapMinutes);
|
||||
}
|
||||
|
||||
// ========== QUEUE ==========
|
||||
|
||||
addToQueue(userId: string, input: Parameters<typeof this.queueService.addToQueue>[1]): QueueItem {
|
||||
return this.queueService.addToQueue(userId, input);
|
||||
}
|
||||
|
||||
bulkAddToQueue(userId: string, items: Parameters<typeof this.queueService.bulkAddToQueue>[1]): QueueItem[] {
|
||||
return this.queueService.bulkAddToQueue(userId, items);
|
||||
}
|
||||
|
||||
getQueue(userId: string, filter?: QueueFilter): QueueItem[] {
|
||||
return this.queueService.getQueue(userId, filter);
|
||||
}
|
||||
|
||||
getQueueStats(userId: string): QueueStats {
|
||||
return this.queueService.getQueueStats(userId);
|
||||
}
|
||||
|
||||
updateQueueItem(userId: string, itemId: string, updates: Parameters<typeof this.queueService.updateItem>[2]): QueueItem | null {
|
||||
return this.queueService.updateItem(userId, itemId, updates);
|
||||
}
|
||||
|
||||
cancelQueueItem(userId: string, itemId: string): boolean {
|
||||
return this.queueService.cancelItem(userId, itemId);
|
||||
}
|
||||
|
||||
reorderQueue(userId: string, itemIds: string[]): QueueItem[] {
|
||||
return this.queueService.reorderQueue(userId, itemIds);
|
||||
}
|
||||
|
||||
clearCompletedQueue(userId: string): number {
|
||||
return this.queueService.clearCompleted(userId);
|
||||
}
|
||||
|
||||
retryFailedQueue(userId: string): number {
|
||||
return this.queueService.retryFailed(userId);
|
||||
}
|
||||
|
||||
// ========== AUTOMATION ==========
|
||||
|
||||
createAutomation(userId: string, input: Parameters<typeof this.automationService.createRule>[1]): AutomationRule {
|
||||
return this.automationService.createRule(userId, input);
|
||||
}
|
||||
|
||||
createAutomationFromTemplate(userId: string, templateName: string): AutomationRule | null {
|
||||
return this.automationService.createFromTemplate(userId, templateName);
|
||||
}
|
||||
|
||||
getAutomationTemplates() {
|
||||
return this.automationService.getTemplates();
|
||||
}
|
||||
|
||||
getAutomations(userId: string, activeOnly?: boolean): AutomationRule[] {
|
||||
return this.automationService.getRules(userId, activeOnly);
|
||||
}
|
||||
|
||||
updateAutomation(userId: string, ruleId: string, updates: Parameters<typeof this.automationService.updateRule>[2]): AutomationRule | null {
|
||||
return this.automationService.updateRule(userId, ruleId, updates);
|
||||
}
|
||||
|
||||
toggleAutomation(userId: string, ruleId: string): AutomationRule | null {
|
||||
return this.automationService.toggleRule(userId, ruleId);
|
||||
}
|
||||
|
||||
deleteAutomation(userId: string, ruleId: string): boolean {
|
||||
return this.automationService.deleteRule(userId, ruleId);
|
||||
}
|
||||
|
||||
async triggerAutomation(userId: string, ruleId: string, context?: Record<string, any>): Promise<AutomationLog> {
|
||||
return this.automationService.triggerRule(userId, ruleId, context);
|
||||
}
|
||||
|
||||
getAutomationLogs(userId: string, limit?: number): AutomationLog[] {
|
||||
return this.automationService.getLogs(userId, limit);
|
||||
}
|
||||
|
||||
getAutomationStats(userId: string) {
|
||||
return this.automationService.getRuleStats(userId);
|
||||
}
|
||||
|
||||
// ========== COMBINED OPERATIONS ==========
|
||||
|
||||
/**
|
||||
* Quick schedule - combines optimal timing with queue
|
||||
*/
|
||||
quickSchedule(
|
||||
userId: string,
|
||||
input: {
|
||||
content: QueueItem['content'];
|
||||
platforms: string[];
|
||||
preferredDate?: Date;
|
||||
},
|
||||
): QueueItem[] {
|
||||
const date = input.preferredDate || new Date();
|
||||
const schedule = this.getSpacedSchedule(input.platforms, date);
|
||||
|
||||
return this.bulkAddToQueue(
|
||||
userId,
|
||||
schedule.map((slot) => ({
|
||||
content: input.content,
|
||||
platforms: [slot.platform],
|
||||
scheduledTime: new Date(`${date.toISOString().split('T')[0]}T${slot.time}:00`),
|
||||
priority: 'normal' as const,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply workflow and populate calendar
|
||||
*/
|
||||
applyWorkflowToCalendar(
|
||||
userId: string,
|
||||
templateId: string,
|
||||
weeks: number = 4,
|
||||
): {
|
||||
eventsCreated: number;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
} {
|
||||
const startDate = new Date();
|
||||
const endDate = new Date();
|
||||
endDate.setDate(endDate.getDate() + weeks * 7);
|
||||
|
||||
const result = this.applyWorkflow(userId, templateId, startDate);
|
||||
|
||||
return {
|
||||
eventsCreated: result.scheduledEvents * weeks,
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comprehensive scheduling overview
|
||||
*/
|
||||
getSchedulingOverview(userId: string): {
|
||||
calendar: CalendarView;
|
||||
queue: QueueStats;
|
||||
automations: ReturnType<typeof this.automationService.getRuleStats>;
|
||||
contentGaps: Date[];
|
||||
recommendations: string[];
|
||||
} {
|
||||
const now = new Date();
|
||||
const weekLater = new Date();
|
||||
weekLater.setDate(weekLater.getDate() + 7);
|
||||
|
||||
return {
|
||||
calendar: this.getCalendarView(userId, 'week', now),
|
||||
queue: this.getQueueStats(userId),
|
||||
automations: this.getAutomationStats(userId),
|
||||
contentGaps: this.getContentGaps(userId, now, weekLater, ['twitter', 'instagram', 'linkedin']),
|
||||
recommendations: this.generateRecommendations(userId),
|
||||
};
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private generateRecommendations(userId: string): string[] {
|
||||
const recommendations: string[] = [];
|
||||
const queueStats = this.getQueueStats(userId);
|
||||
const frequency = this.getPostingFrequency(userId);
|
||||
|
||||
if (queueStats.pending < 5) {
|
||||
recommendations.push('Your queue is running low - consider adding more content');
|
||||
}
|
||||
|
||||
if (queueStats.failed > 0) {
|
||||
recommendations.push(`You have ${queueStats.failed} failed posts - review and retry`);
|
||||
}
|
||||
|
||||
if (frequency.recommendation) {
|
||||
recommendations.push(frequency.recommendation);
|
||||
}
|
||||
|
||||
const automationStats = this.getAutomationStats(userId);
|
||||
if (automationStats.activeRules === 0) {
|
||||
recommendations.push('Set up automation rules to save time on repetitive tasks');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
}
|
||||
430
src/modules/scheduling/services/automation-engine.service.ts
Normal file
430
src/modules/scheduling/services/automation-engine.service.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
// Automation Engine Service - Automated content workflow execution
|
||||
// Path: src/modules/scheduling/services/automation-engine.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface AutomationRule {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationCondition[];
|
||||
actions: AutomationAction[];
|
||||
isActive: boolean;
|
||||
lastTriggered?: Date;
|
||||
triggerCount: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AutomationTrigger {
|
||||
type: TriggerType;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
export type TriggerType =
|
||||
| 'scheduled_time'
|
||||
| 'content_published'
|
||||
| 'engagement_threshold'
|
||||
| 'new_trend_detected'
|
||||
| 'manual'
|
||||
| 'webhook'
|
||||
| 'content_approved'
|
||||
| 'gold_post_detected';
|
||||
|
||||
export interface AutomationCondition {
|
||||
field: string;
|
||||
operator: 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' | 'contains' | 'not_contains';
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface AutomationAction {
|
||||
type: ActionType;
|
||||
config: Record<string, any>;
|
||||
delay?: string; // e.g., "5m", "1h", "1d"
|
||||
}
|
||||
|
||||
export type ActionType =
|
||||
| 'create_content'
|
||||
| 'schedule_post'
|
||||
| 'cross_post'
|
||||
| 'send_notification'
|
||||
| 'update_status'
|
||||
| 'add_to_queue'
|
||||
| 'generate_variations'
|
||||
| 'mark_as_gold'
|
||||
| 'create_spinoff'
|
||||
| 'send_webhook';
|
||||
|
||||
export interface AutomationLog {
|
||||
id: string;
|
||||
ruleId: string;
|
||||
triggeredAt: Date;
|
||||
trigger: AutomationTrigger;
|
||||
conditionsMet: boolean;
|
||||
actionsExecuted: Array<{
|
||||
action: AutomationAction;
|
||||
success: boolean;
|
||||
result?: any;
|
||||
error?: string;
|
||||
}>;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AutomationEngineService {
|
||||
private readonly logger = new Logger(AutomationEngineService.name);
|
||||
private rules: Map<string, AutomationRule[]> = new Map();
|
||||
private logs: Map<string, AutomationLog[]> = new Map();
|
||||
|
||||
// Pre-built automation templates
|
||||
private readonly automationTemplates: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationCondition[];
|
||||
actions: AutomationAction[];
|
||||
}> = [
|
||||
{
|
||||
name: 'Cross-Post to All Platforms',
|
||||
description: 'Automatically cross-post content to all connected platforms',
|
||||
trigger: { type: 'content_published', config: { platform: 'any' } },
|
||||
conditions: [],
|
||||
actions: [
|
||||
{ type: 'cross_post', config: { platforms: ['twitter', 'linkedin', 'facebook'] } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Gold Post Detection & Spinoff',
|
||||
description: 'When a post gets 10x engagement, mark as gold and create spinoffs',
|
||||
trigger: { type: 'gold_post_detected', config: { threshold: 10 } },
|
||||
conditions: [{ field: 'engagementMultiplier', operator: 'gte', value: 10 }],
|
||||
actions: [
|
||||
{ type: 'mark_as_gold', config: {} },
|
||||
{ type: 'create_spinoff', config: { variations: 3 } },
|
||||
{ type: 'send_notification', config: { message: 'New Gold Post detected!' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Trend-Based Content Creation',
|
||||
description: 'Create content when a relevant trend is detected',
|
||||
trigger: { type: 'new_trend_detected', config: { minScore: 80 } },
|
||||
conditions: [{ field: 'trendScore', operator: 'gte', value: 80 }],
|
||||
actions: [
|
||||
{ type: 'create_content', config: { useTemplate: true } },
|
||||
{ type: 'add_to_queue', config: { priority: 'high' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Auto-Approve & Schedule',
|
||||
description: 'Automatically schedule approved content',
|
||||
trigger: { type: 'content_approved', config: {} },
|
||||
conditions: [],
|
||||
actions: [
|
||||
{ type: 'schedule_post', config: { useOptimalTime: true } },
|
||||
{ type: 'send_notification', config: { message: 'Content scheduled' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Engagement Booster',
|
||||
description: 'Boost high-performing content with variations',
|
||||
trigger: { type: 'engagement_threshold', config: { threshold: 100 } },
|
||||
conditions: [{ field: 'likes', operator: 'gte', value: 100 }],
|
||||
actions: [
|
||||
{ type: 'generate_variations', config: { count: 2 } },
|
||||
{ type: 'add_to_queue', config: { delay: '24h' } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Create automation rule
|
||||
*/
|
||||
createRule(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
description: string;
|
||||
trigger: AutomationTrigger;
|
||||
conditions?: AutomationCondition[];
|
||||
actions: AutomationAction[];
|
||||
},
|
||||
): AutomationRule {
|
||||
const rule: AutomationRule = {
|
||||
id: `rule-${Date.now()}`,
|
||||
userId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
trigger: input.trigger,
|
||||
conditions: input.conditions || [],
|
||||
actions: input.actions,
|
||||
isActive: true,
|
||||
triggerCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const userRules = this.rules.get(userId) || [];
|
||||
userRules.push(rule);
|
||||
this.rules.set(userId, userRules);
|
||||
|
||||
this.logger.log(`Created automation rule: ${rule.id} for user ${userId}`);
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rule from template
|
||||
*/
|
||||
createFromTemplate(userId: string, templateName: string): AutomationRule | null {
|
||||
const template = this.automationTemplates.find((t) => t.name === templateName);
|
||||
if (!template) return null;
|
||||
|
||||
return this.createRule(userId, {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
trigger: template.trigger,
|
||||
conditions: template.conditions,
|
||||
actions: template.actions,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available templates
|
||||
*/
|
||||
getTemplates(): typeof this.automationTemplates {
|
||||
return this.automationTemplates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's automation rules
|
||||
*/
|
||||
getRules(userId: string, activeOnly?: boolean): AutomationRule[] {
|
||||
let rules = this.rules.get(userId) || [];
|
||||
if (activeOnly) {
|
||||
rules = rules.filter((r) => r.isActive);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update automation rule
|
||||
*/
|
||||
updateRule(
|
||||
userId: string,
|
||||
ruleId: string,
|
||||
updates: Partial<Pick<AutomationRule, 'name' | 'description' | 'trigger' | 'conditions' | 'actions' | 'isActive'>>,
|
||||
): AutomationRule | null {
|
||||
const userRules = this.rules.get(userId) || [];
|
||||
const index = userRules.findIndex((r) => r.id === ruleId);
|
||||
|
||||
if (index === -1) return null;
|
||||
|
||||
userRules[index] = {
|
||||
...userRules[index],
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.rules.set(userId, userRules);
|
||||
return userRules[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle rule active state
|
||||
*/
|
||||
toggleRule(userId: string, ruleId: string): AutomationRule | null {
|
||||
const userRules = this.rules.get(userId) || [];
|
||||
const rule = userRules.find((r) => r.id === ruleId);
|
||||
|
||||
if (!rule) return null;
|
||||
|
||||
return this.updateRule(userId, ruleId, { isActive: !rule.isActive });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete rule
|
||||
*/
|
||||
deleteRule(userId: string, ruleId: string): boolean {
|
||||
const userRules = this.rules.get(userId) || [];
|
||||
const filtered = userRules.filter((r) => r.id !== ruleId);
|
||||
|
||||
if (filtered.length === userRules.length) return false;
|
||||
|
||||
this.rules.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute automation rule
|
||||
*/
|
||||
async executeRule(userId: string, ruleId: string, context: Record<string, any>): Promise<AutomationLog> {
|
||||
const rule = this.getRules(userId).find((r) => r.id === ruleId);
|
||||
if (!rule) {
|
||||
throw new Error(`Rule ${ruleId} not found`);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const log: AutomationLog = {
|
||||
id: `log-${Date.now()}`,
|
||||
ruleId,
|
||||
triggeredAt: new Date(),
|
||||
trigger: rule.trigger,
|
||||
conditionsMet: true,
|
||||
actionsExecuted: [],
|
||||
duration: 0,
|
||||
};
|
||||
|
||||
// Check conditions
|
||||
for (const condition of rule.conditions) {
|
||||
if (!this.evaluateCondition(condition, context)) {
|
||||
log.conditionsMet = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Execute actions if conditions met
|
||||
if (log.conditionsMet) {
|
||||
for (const action of rule.actions) {
|
||||
try {
|
||||
const result = await this.executeAction(action, context);
|
||||
log.actionsExecuted.push({
|
||||
action,
|
||||
success: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
log.actionsExecuted.push({
|
||||
action,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update rule stats
|
||||
rule.lastTriggered = new Date();
|
||||
rule.triggerCount++;
|
||||
}
|
||||
|
||||
log.duration = Date.now() - startTime;
|
||||
|
||||
// Store log
|
||||
const userLogs = this.logs.get(userId) || [];
|
||||
userLogs.push(log);
|
||||
this.logs.set(userId, userLogs);
|
||||
|
||||
return log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a rule
|
||||
*/
|
||||
async triggerRule(userId: string, ruleId: string, context?: Record<string, any>): Promise<AutomationLog> {
|
||||
return this.executeRule(userId, ruleId, context || {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get automation logs
|
||||
*/
|
||||
getLogs(userId: string, limit?: number): AutomationLog[] {
|
||||
let logs = this.logs.get(userId) || [];
|
||||
logs = logs.sort((a, b) => b.triggeredAt.getTime() - a.triggeredAt.getTime());
|
||||
|
||||
if (limit) {
|
||||
logs = logs.slice(0, limit);
|
||||
}
|
||||
|
||||
return logs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rule statistics
|
||||
*/
|
||||
getRuleStats(userId: string): {
|
||||
totalRules: number;
|
||||
activeRules: number;
|
||||
totalExecutions: number;
|
||||
successRate: number;
|
||||
mostTriggered?: AutomationRule;
|
||||
} {
|
||||
const rules = this.getRules(userId);
|
||||
const logs = this.logs.get(userId) || [];
|
||||
|
||||
const successfulLogs = logs.filter((l) =>
|
||||
l.conditionsMet && l.actionsExecuted.every((a) => a.success)
|
||||
);
|
||||
|
||||
const mostTriggered = rules.reduce((max, rule) =>
|
||||
rule.triggerCount > (max?.triggerCount || 0) ? rule : max,
|
||||
undefined as AutomationRule | undefined
|
||||
);
|
||||
|
||||
return {
|
||||
totalRules: rules.length,
|
||||
activeRules: rules.filter((r) => r.isActive).length,
|
||||
totalExecutions: logs.length,
|
||||
successRate: logs.length > 0 ? (successfulLogs.length / logs.length) * 100 : 0,
|
||||
mostTriggered,
|
||||
};
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private evaluateCondition(condition: AutomationCondition, context: Record<string, any>): boolean {
|
||||
const fieldValue = context[condition.field];
|
||||
const condValue = condition.value;
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'eq':
|
||||
return fieldValue === condValue;
|
||||
case 'neq':
|
||||
return fieldValue !== condValue;
|
||||
case 'gt':
|
||||
return fieldValue > condValue;
|
||||
case 'lt':
|
||||
return fieldValue < condValue;
|
||||
case 'gte':
|
||||
return fieldValue >= condValue;
|
||||
case 'lte':
|
||||
return fieldValue <= condValue;
|
||||
case 'contains':
|
||||
return String(fieldValue).includes(String(condValue));
|
||||
case 'not_contains':
|
||||
return !String(fieldValue).includes(String(condValue));
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAction(action: AutomationAction, context: Record<string, any>): Promise<any> {
|
||||
// Simulate action execution delay if specified
|
||||
if (action.delay) {
|
||||
this.logger.log(`Action delayed by ${action.delay}`);
|
||||
}
|
||||
|
||||
// Mock action execution
|
||||
switch (action.type) {
|
||||
case 'send_notification':
|
||||
return { sent: true, message: action.config.message };
|
||||
case 'create_content':
|
||||
return { contentId: `content-${Date.now()}` };
|
||||
case 'schedule_post':
|
||||
return { scheduled: true, time: new Date() };
|
||||
case 'cross_post':
|
||||
return { platforms: action.config.platforms, success: true };
|
||||
case 'generate_variations':
|
||||
return { variations: action.config.count };
|
||||
case 'mark_as_gold':
|
||||
return { marked: true };
|
||||
case 'create_spinoff':
|
||||
return { spinoffs: action.config.variations };
|
||||
case 'add_to_queue':
|
||||
return { queued: true, priority: action.config.priority };
|
||||
case 'send_webhook':
|
||||
return { sent: true, url: action.config.url };
|
||||
default:
|
||||
return { executed: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
451
src/modules/scheduling/services/content-calendar.service.ts
Normal file
451
src/modules/scheduling/services/content-calendar.service.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
// Content Calendar Service - Visual calendar for content planning
|
||||
// Path: src/modules/scheduling/services/content-calendar.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
userId: string;
|
||||
title: string;
|
||||
content: CalendarContent;
|
||||
platforms: string[];
|
||||
scheduledDate: Date;
|
||||
scheduledTime: string;
|
||||
timezone: string;
|
||||
status: CalendarEventStatus;
|
||||
color: string;
|
||||
tags: string[];
|
||||
recurrence?: RecurrencePattern;
|
||||
reminder?: string;
|
||||
notes?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface CalendarContent {
|
||||
type: 'post' | 'story' | 'reel' | 'video' | 'thread' | 'carousel' | 'article';
|
||||
text: string;
|
||||
mediaUrls?: string[];
|
||||
hashtags?: string[];
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export type CalendarEventStatus =
|
||||
| 'draft'
|
||||
| 'scheduled'
|
||||
| 'publishing'
|
||||
| 'published'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface RecurrencePattern {
|
||||
type: 'daily' | 'weekly' | 'biweekly' | 'monthly' | 'custom';
|
||||
interval: number;
|
||||
daysOfWeek?: number[]; // 0 = Sunday
|
||||
endDate?: Date;
|
||||
occurrences?: number;
|
||||
}
|
||||
|
||||
export interface CalendarView {
|
||||
type: 'day' | 'week' | 'month' | 'quarter';
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
events: CalendarEvent[];
|
||||
stats: CalendarStats;
|
||||
}
|
||||
|
||||
export interface CalendarStats {
|
||||
totalScheduled: number;
|
||||
totalPublished: number;
|
||||
totalDrafts: number;
|
||||
totalFailed: number;
|
||||
platformBreakdown: Record<string, number>;
|
||||
contentTypeBreakdown: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ContentSlot {
|
||||
date: Date;
|
||||
time: string;
|
||||
platform: string;
|
||||
contentType: string;
|
||||
isFilled: boolean;
|
||||
event?: CalendarEvent;
|
||||
suggestedContent?: {
|
||||
type: string;
|
||||
reason: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentCalendarService {
|
||||
private readonly logger = new Logger(ContentCalendarService.name);
|
||||
private events: Map<string, CalendarEvent[]> = new Map();
|
||||
|
||||
// Color palette for different content types
|
||||
private readonly contentColors: Record<string, string> = {
|
||||
post: '#3B82F6', // Blue
|
||||
story: '#EC4899', // Pink
|
||||
reel: '#8B5CF6', // Purple
|
||||
video: '#EF4444', // Red
|
||||
thread: '#10B981', // Green
|
||||
carousel: '#F59E0B', // Amber
|
||||
article: '#6366F1', // Indigo
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a calendar event
|
||||
*/
|
||||
createEvent(
|
||||
userId: string,
|
||||
input: {
|
||||
title: string;
|
||||
content: CalendarContent;
|
||||
platforms: string[];
|
||||
scheduledDate: Date;
|
||||
scheduledTime: string;
|
||||
timezone?: string;
|
||||
tags?: string[];
|
||||
recurrence?: RecurrencePattern;
|
||||
reminder?: string;
|
||||
notes?: string;
|
||||
},
|
||||
): CalendarEvent {
|
||||
const event: CalendarEvent = {
|
||||
id: `event-${Date.now()}`,
|
||||
userId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
platforms: input.platforms,
|
||||
scheduledDate: input.scheduledDate,
|
||||
scheduledTime: input.scheduledTime,
|
||||
timezone: input.timezone || 'UTC',
|
||||
status: 'scheduled',
|
||||
color: this.contentColors[input.content.type] || '#6B7280',
|
||||
tags: input.tags || [],
|
||||
recurrence: input.recurrence,
|
||||
reminder: input.reminder,
|
||||
notes: input.notes,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const userEvents = this.events.get(userId) || [];
|
||||
userEvents.push(event);
|
||||
this.events.set(userId, userEvents);
|
||||
|
||||
// Create recurring events if pattern specified
|
||||
if (input.recurrence) {
|
||||
this.createRecurringEvents(userId, event);
|
||||
}
|
||||
|
||||
this.logger.log(`Created calendar event: ${event.id}`);
|
||||
return event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get calendar view
|
||||
*/
|
||||
getCalendarView(
|
||||
userId: string,
|
||||
viewType: 'day' | 'week' | 'month' | 'quarter',
|
||||
startDate: Date,
|
||||
): CalendarView {
|
||||
const endDate = this.calculateEndDate(viewType, startDate);
|
||||
const userEvents = this.events.get(userId) || [];
|
||||
|
||||
const eventsInRange = userEvents.filter((e) => {
|
||||
const eventDate = new Date(e.scheduledDate);
|
||||
return eventDate >= startDate && eventDate <= endDate;
|
||||
});
|
||||
|
||||
return {
|
||||
type: viewType,
|
||||
startDate,
|
||||
endDate,
|
||||
events: eventsInRange,
|
||||
stats: this.calculateStats(eventsInRange),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available slots
|
||||
*/
|
||||
getAvailableSlots(
|
||||
userId: string,
|
||||
date: Date,
|
||||
platforms: string[],
|
||||
): ContentSlot[] {
|
||||
const userEvents = this.events.get(userId) || [];
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
const optimalTimes = ['09:00', '12:00', '15:00', '18:00', '21:00'];
|
||||
const slots: ContentSlot[] = [];
|
||||
|
||||
for (const platform of platforms) {
|
||||
for (const time of optimalTimes) {
|
||||
const existingEvent = userEvents.find(
|
||||
(e) =>
|
||||
e.scheduledDate.toISOString().split('T')[0] === dateStr &&
|
||||
e.scheduledTime === time &&
|
||||
e.platforms.includes(platform),
|
||||
);
|
||||
|
||||
slots.push({
|
||||
date,
|
||||
time,
|
||||
platform,
|
||||
contentType: 'post',
|
||||
isFilled: !!existingEvent,
|
||||
event: existingEvent,
|
||||
suggestedContent: !existingEvent ? this.suggestContent(platform, time) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update event
|
||||
*/
|
||||
updateEvent(
|
||||
userId: string,
|
||||
eventId: string,
|
||||
updates: Partial<CalendarEvent>,
|
||||
): CalendarEvent | null {
|
||||
const userEvents = this.events.get(userId) || [];
|
||||
const index = userEvents.findIndex((e) => e.id === eventId);
|
||||
|
||||
if (index === -1) return null;
|
||||
|
||||
userEvents[index] = {
|
||||
...userEvents[index],
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.events.set(userId, userEvents);
|
||||
return userEvents[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete event
|
||||
*/
|
||||
deleteEvent(userId: string, eventId: string): boolean {
|
||||
const userEvents = this.events.get(userId) || [];
|
||||
const filtered = userEvents.filter((e) => e.id !== eventId);
|
||||
|
||||
if (filtered.length === userEvents.length) return false;
|
||||
|
||||
this.events.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate event
|
||||
*/
|
||||
duplicateEvent(
|
||||
userId: string,
|
||||
eventId: string,
|
||||
newDate: Date,
|
||||
): CalendarEvent | null {
|
||||
const userEvents = this.events.get(userId) || [];
|
||||
const original = userEvents.find((e) => e.id === eventId);
|
||||
|
||||
if (!original) return null;
|
||||
|
||||
return this.createEvent(userId, {
|
||||
title: `${original.title} (Copy)`,
|
||||
content: original.content,
|
||||
platforms: original.platforms,
|
||||
scheduledDate: newDate,
|
||||
scheduledTime: original.scheduledTime,
|
||||
timezone: original.timezone,
|
||||
tags: original.tags,
|
||||
notes: original.notes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk schedule events
|
||||
*/
|
||||
bulkSchedule(
|
||||
userId: string,
|
||||
events: Array<{
|
||||
title: string;
|
||||
content: CalendarContent;
|
||||
platforms: string[];
|
||||
scheduledDate: Date;
|
||||
scheduledTime: string;
|
||||
}>,
|
||||
): CalendarEvent[] {
|
||||
return events.map((e) => this.createEvent(userId, e));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content gaps (days without scheduled content)
|
||||
*/
|
||||
getContentGaps(
|
||||
userId: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
platforms: string[],
|
||||
): Date[] {
|
||||
const userEvents = this.events.get(userId) || [];
|
||||
const gaps: Date[] = [];
|
||||
const currentDate = new Date(startDate);
|
||||
|
||||
while (currentDate <= endDate) {
|
||||
const dateStr = currentDate.toISOString().split('T')[0];
|
||||
const hasContent = userEvents.some(
|
||||
(e) =>
|
||||
e.scheduledDate.toISOString().split('T')[0] === dateStr &&
|
||||
platforms.some((p) => e.platforms.includes(p)),
|
||||
);
|
||||
|
||||
if (!hasContent) {
|
||||
gaps.push(new Date(currentDate));
|
||||
}
|
||||
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
|
||||
return gaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get posting frequency analysis
|
||||
*/
|
||||
getPostingFrequency(userId: string): {
|
||||
daily: Record<string, number>;
|
||||
weekly: Record<string, number>;
|
||||
byPlatform: Record<string, number>;
|
||||
recommendation: string;
|
||||
} {
|
||||
const userEvents = this.events.get(userId) || [];
|
||||
const daily: Record<string, number> = {};
|
||||
const weekly: Record<string, number> = {};
|
||||
const byPlatform: Record<string, number> = {};
|
||||
|
||||
const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
days.forEach((d) => (daily[d] = 0));
|
||||
|
||||
for (const event of userEvents) {
|
||||
const day = days[event.scheduledDate.getDay()];
|
||||
daily[day]++;
|
||||
|
||||
for (const platform of event.platforms) {
|
||||
byPlatform[platform] = (byPlatform[platform] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
daily,
|
||||
weekly,
|
||||
byPlatform,
|
||||
recommendation: this.generateFrequencyRecommendation(daily),
|
||||
};
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private calculateEndDate(viewType: string, startDate: Date): Date {
|
||||
const end = new Date(startDate);
|
||||
switch (viewType) {
|
||||
case 'day':
|
||||
end.setDate(end.getDate() + 1);
|
||||
break;
|
||||
case 'week':
|
||||
end.setDate(end.getDate() + 7);
|
||||
break;
|
||||
case 'month':
|
||||
end.setMonth(end.getMonth() + 1);
|
||||
break;
|
||||
case 'quarter':
|
||||
end.setMonth(end.getMonth() + 3);
|
||||
break;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
private calculateStats(events: CalendarEvent[]): CalendarStats {
|
||||
const platformBreakdown: Record<string, number> = {};
|
||||
const contentTypeBreakdown: Record<string, number> = {};
|
||||
|
||||
for (const event of events) {
|
||||
contentTypeBreakdown[event.content.type] = (contentTypeBreakdown[event.content.type] || 0) + 1;
|
||||
for (const platform of event.platforms) {
|
||||
platformBreakdown[platform] = (platformBreakdown[platform] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalScheduled: events.filter((e) => e.status === 'scheduled').length,
|
||||
totalPublished: events.filter((e) => e.status === 'published').length,
|
||||
totalDrafts: events.filter((e) => e.status === 'draft').length,
|
||||
totalFailed: events.filter((e) => e.status === 'failed').length,
|
||||
platformBreakdown,
|
||||
contentTypeBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
private createRecurringEvents(userId: string, template: CalendarEvent): void {
|
||||
if (!template.recurrence) return;
|
||||
|
||||
const { type, interval, endDate, occurrences } = template.recurrence;
|
||||
const maxOccurrences = occurrences || 10;
|
||||
let currentDate = new Date(template.scheduledDate);
|
||||
|
||||
for (let i = 0; i < maxOccurrences - 1; i++) {
|
||||
switch (type) {
|
||||
case 'daily':
|
||||
currentDate.setDate(currentDate.getDate() + interval);
|
||||
break;
|
||||
case 'weekly':
|
||||
currentDate.setDate(currentDate.getDate() + 7 * interval);
|
||||
break;
|
||||
case 'biweekly':
|
||||
currentDate.setDate(currentDate.getDate() + 14);
|
||||
break;
|
||||
case 'monthly':
|
||||
currentDate.setMonth(currentDate.getMonth() + interval);
|
||||
break;
|
||||
}
|
||||
|
||||
if (endDate && currentDate > endDate) break;
|
||||
|
||||
this.createEvent(userId, {
|
||||
title: template.title,
|
||||
content: template.content,
|
||||
platforms: template.platforms,
|
||||
scheduledDate: new Date(currentDate),
|
||||
scheduledTime: template.scheduledTime,
|
||||
timezone: template.timezone,
|
||||
tags: template.tags,
|
||||
notes: template.notes,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private suggestContent(platform: string, time: string): { type: string; reason: string } {
|
||||
const suggestions: Record<string, { type: string; reason: string }> = {
|
||||
'09:00': { type: 'post', reason: 'Morning engagement peak' },
|
||||
'12:00': { type: 'carousel', reason: 'Lunch break browsing' },
|
||||
'15:00': { type: 'reel', reason: 'Afternoon break - short form content' },
|
||||
'18:00': { type: 'story', reason: 'Evening casual browsing' },
|
||||
'21:00': { type: 'video', reason: 'Prime time for long-form content' },
|
||||
};
|
||||
return suggestions[time] || { type: 'post', reason: 'General engagement' };
|
||||
}
|
||||
|
||||
private generateFrequencyRecommendation(daily: Record<string, number>): string {
|
||||
const total = Object.values(daily).reduce((a, b) => a + b, 0);
|
||||
if (total < 7) {
|
||||
return 'Consider posting at least once daily for better reach';
|
||||
}
|
||||
if (total > 21) {
|
||||
return 'High frequency detected - ensure content quality is maintained';
|
||||
}
|
||||
return 'Good posting frequency - maintain consistency';
|
||||
}
|
||||
}
|
||||
365
src/modules/scheduling/services/optimal-timing.service.ts
Normal file
365
src/modules/scheduling/services/optimal-timing.service.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
// Optimal Timing Service - AI-powered posting time optimization
|
||||
// Path: src/modules/scheduling/services/optimal-timing.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface TimingRecommendation {
|
||||
platform: string;
|
||||
optimalSlots: TimeSlot[];
|
||||
audience: AudienceInsights;
|
||||
factors: TimingFactors;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface TimeSlot {
|
||||
dayOfWeek: number;
|
||||
dayName: string;
|
||||
time: string;
|
||||
score: number;
|
||||
engagement: 'low' | 'medium' | 'high' | 'peak';
|
||||
reasoning: string;
|
||||
}
|
||||
|
||||
export interface AudienceInsights {
|
||||
timezone: string;
|
||||
activeHours: string[];
|
||||
peakDays: string[];
|
||||
demographics?: {
|
||||
ageRange: string;
|
||||
primaryLocation: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TimingFactors {
|
||||
historicalPerformance: number;
|
||||
competitorAnalysis: number;
|
||||
industryTrends: number;
|
||||
audienceActivity: number;
|
||||
contentType: number;
|
||||
}
|
||||
|
||||
export interface PerformanceData {
|
||||
postId: string;
|
||||
platform: string;
|
||||
postedAt: Date;
|
||||
engagement: {
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
saves: number;
|
||||
};
|
||||
reach: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OptimalTimingService {
|
||||
private readonly logger = new Logger(OptimalTimingService.name);
|
||||
|
||||
// Industry-standard optimal times by platform
|
||||
private readonly platformOptimalTimes: Record<string, TimeSlot[]> = {
|
||||
twitter: [
|
||||
{ dayOfWeek: 1, dayName: 'Monday', time: '09:00', score: 85, engagement: 'high', reasoning: 'Start of work week, high mobile usage' },
|
||||
{ dayOfWeek: 2, dayName: 'Tuesday', time: '09:00', score: 90, engagement: 'peak', reasoning: 'Peak B2B engagement day' },
|
||||
{ dayOfWeek: 3, dayName: 'Wednesday', time: '12:00', score: 88, engagement: 'high', reasoning: 'Midweek lunch browsing' },
|
||||
{ dayOfWeek: 4, dayName: 'Thursday', time: '10:00', score: 85, engagement: 'high', reasoning: 'High professional activity' },
|
||||
{ dayOfWeek: 5, dayName: 'Friday', time: '09:00', score: 75, engagement: 'medium', reasoning: 'End of week wind down' },
|
||||
],
|
||||
instagram: [
|
||||
{ dayOfWeek: 1, dayName: 'Monday', time: '11:00', score: 82, engagement: 'high', reasoning: 'Lunch break scrolling' },
|
||||
{ dayOfWeek: 2, dayName: 'Tuesday', time: '14:00', score: 88, engagement: 'high', reasoning: 'Afternoon engagement peak' },
|
||||
{ dayOfWeek: 3, dayName: 'Wednesday', time: '11:00', score: 90, engagement: 'peak', reasoning: 'Best day for Instagram' },
|
||||
{ dayOfWeek: 5, dayName: 'Friday', time: '10:00', score: 85, engagement: 'high', reasoning: 'Pre-weekend browsing' },
|
||||
{ dayOfWeek: 6, dayName: 'Saturday', time: '10:00', score: 80, engagement: 'high', reasoning: 'Weekend leisure time' },
|
||||
],
|
||||
linkedin: [
|
||||
{ dayOfWeek: 2, dayName: 'Tuesday', time: '07:45', score: 92, engagement: 'peak', reasoning: 'Early morning professional check-in' },
|
||||
{ dayOfWeek: 2, dayName: 'Tuesday', time: '10:00', score: 90, engagement: 'peak', reasoning: 'Mid-morning work break' },
|
||||
{ dayOfWeek: 3, dayName: 'Wednesday', time: '12:00', score: 88, engagement: 'high', reasoning: 'Lunch networking' },
|
||||
{ dayOfWeek: 4, dayName: 'Thursday', time: '09:00', score: 85, engagement: 'high', reasoning: 'Pre-weekend planning' },
|
||||
{ dayOfWeek: 3, dayName: 'Wednesday', time: '08:00', score: 87, engagement: 'high', reasoning: 'Morning commute time' },
|
||||
],
|
||||
facebook: [
|
||||
{ dayOfWeek: 1, dayName: 'Monday', time: '09:00', score: 78, engagement: 'medium', reasoning: 'Start of week updates' },
|
||||
{ dayOfWeek: 3, dayName: 'Wednesday', time: '11:00', score: 85, engagement: 'high', reasoning: 'Midweek engagement' },
|
||||
{ dayOfWeek: 4, dayName: 'Thursday', time: '14:00', score: 82, engagement: 'high', reasoning: 'Afternoon browsing' },
|
||||
{ dayOfWeek: 5, dayName: 'Friday', time: '12:00', score: 80, engagement: 'high', reasoning: 'End of week leisure' },
|
||||
],
|
||||
tiktok: [
|
||||
{ dayOfWeek: 2, dayName: 'Tuesday', time: '09:00', score: 85, engagement: 'high', reasoning: 'Morning algorithm boost' },
|
||||
{ dayOfWeek: 4, dayName: 'Thursday', time: '12:00', score: 90, engagement: 'peak', reasoning: 'Lunch break peak' },
|
||||
{ dayOfWeek: 5, dayName: 'Friday', time: '17:00', score: 92, engagement: 'peak', reasoning: 'After school/work prime time' },
|
||||
{ dayOfWeek: 6, dayName: 'Saturday', time: '19:00', score: 88, engagement: 'high', reasoning: 'Weekend evening viewing' },
|
||||
{ dayOfWeek: 0, dayName: 'Sunday', time: '14:00', score: 82, engagement: 'high', reasoning: 'Weekend afternoon leisure' },
|
||||
],
|
||||
youtube: [
|
||||
{ dayOfWeek: 4, dayName: 'Thursday', time: '14:00', score: 90, engagement: 'peak', reasoning: 'Pre-weekend discovery' },
|
||||
{ dayOfWeek: 5, dayName: 'Friday', time: '15:00', score: 88, engagement: 'high', reasoning: 'Pre-weekend watching' },
|
||||
{ dayOfWeek: 6, dayName: 'Saturday', time: '09:00', score: 85, engagement: 'high', reasoning: 'Weekend morning viewing' },
|
||||
{ dayOfWeek: 0, dayName: 'Sunday', time: '11:00', score: 82, engagement: 'high', reasoning: 'Weekend leisure time' },
|
||||
],
|
||||
};
|
||||
|
||||
// Content type timing modifiers
|
||||
private readonly contentTypeModifiers: Record<string, Record<string, number>> = {
|
||||
video: { '08:00': 0.8, '12:00': 1.2, '18:00': 1.4, '21:00': 1.5 },
|
||||
reel: { '09:00': 1.3, '12:00': 1.4, '17:00': 1.5, '21:00': 1.3 },
|
||||
post: { '09:00': 1.2, '12:00': 1.3, '15:00': 1.1, '18:00': 1.0 },
|
||||
thread: { '08:00': 1.4, '10:00': 1.3, '14:00': 1.1 },
|
||||
carousel: { '10:00': 1.3, '14:00': 1.2, '18:00': 1.1 },
|
||||
story: { '08:00': 1.1, '12:00': 1.2, '18:00': 1.4, '22:00': 1.3 },
|
||||
article: { '07:00': 1.4, '09:00': 1.3, '12:00': 1.2 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Get optimal times for a platform
|
||||
*/
|
||||
getOptimalTimes(
|
||||
platform: string,
|
||||
options?: {
|
||||
contentType?: string;
|
||||
timezone?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): TimingRecommendation {
|
||||
const slots = this.platformOptimalTimes[platform] || this.platformOptimalTimes.twitter;
|
||||
let sortedSlots = [...slots].sort((a, b) => b.score - a.score);
|
||||
|
||||
// Apply content type modifiers
|
||||
if (options?.contentType) {
|
||||
sortedSlots = this.applyContentTypeModifiers(sortedSlots, options.contentType);
|
||||
}
|
||||
|
||||
// Limit results
|
||||
if (options?.limit) {
|
||||
sortedSlots = sortedSlots.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return {
|
||||
platform,
|
||||
optimalSlots: sortedSlots,
|
||||
audience: {
|
||||
timezone: options?.timezone || 'UTC',
|
||||
activeHours: ['09:00-12:00', '14:00-17:00', '19:00-22:00'],
|
||||
peakDays: this.getPeakDays(platform),
|
||||
},
|
||||
factors: {
|
||||
historicalPerformance: 0.85,
|
||||
competitorAnalysis: 0.78,
|
||||
industryTrends: 0.82,
|
||||
audienceActivity: 0.90,
|
||||
contentType: 0.88,
|
||||
},
|
||||
confidence: 0.85,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get best posting time for specific content
|
||||
*/
|
||||
getBestTimeForContent(
|
||||
platform: string,
|
||||
contentType: string,
|
||||
targetDate?: Date,
|
||||
): TimeSlot {
|
||||
const slots = this.platformOptimalTimes[platform] || [];
|
||||
const modifiedSlots = this.applyContentTypeModifiers(slots, contentType);
|
||||
|
||||
// Filter by day if target date provided
|
||||
if (targetDate) {
|
||||
const targetDay = targetDate.getDay();
|
||||
const daySlots = modifiedSlots.filter((s) => s.dayOfWeek === targetDay);
|
||||
if (daySlots.length > 0) {
|
||||
return daySlots.sort((a, b) => b.score - a.score)[0];
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedSlots.sort((a, b) => b.score - a.score)[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze user's historical performance to personalize timing
|
||||
*/
|
||||
analyzeHistoricalPerformance(
|
||||
performanceData: PerformanceData[],
|
||||
): {
|
||||
bestTimes: TimeSlot[];
|
||||
worstTimes: TimeSlot[];
|
||||
insights: string[];
|
||||
} {
|
||||
// Group by hour and calculate average engagement
|
||||
const hourlyEngagement: Record<string, { total: number; count: number }> = {};
|
||||
|
||||
for (const data of performanceData) {
|
||||
const hour = new Date(data.postedAt).getHours();
|
||||
const key = `${hour}:00`;
|
||||
|
||||
if (!hourlyEngagement[key]) {
|
||||
hourlyEngagement[key] = { total: 0, count: 0 };
|
||||
}
|
||||
|
||||
const engagement = data.engagement.likes + data.engagement.comments * 2 + data.engagement.shares * 3;
|
||||
hourlyEngagement[key].total += engagement;
|
||||
hourlyEngagement[key].count++;
|
||||
}
|
||||
|
||||
// Calculate averages and create slots
|
||||
const timeSlots: TimeSlot[] = Object.entries(hourlyEngagement).map(([time, data]) => ({
|
||||
dayOfWeek: 0,
|
||||
dayName: 'Average',
|
||||
time,
|
||||
score: Math.round((data.total / data.count) / 10),
|
||||
engagement: this.scoreToEngagement(data.total / data.count),
|
||||
reasoning: 'Based on your historical performance',
|
||||
}));
|
||||
|
||||
const sorted = timeSlots.sort((a, b) => b.score - a.score);
|
||||
|
||||
return {
|
||||
bestTimes: sorted.slice(0, 5),
|
||||
worstTimes: sorted.slice(-3).reverse(),
|
||||
insights: this.generateTimingInsights(sorted),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cross-platform optimal schedule
|
||||
*/
|
||||
getCrossplatformSchedule(
|
||||
platforms: string[],
|
||||
postsPerPlatform: number,
|
||||
): Record<string, TimeSlot[]> {
|
||||
const schedule: Record<string, TimeSlot[]> = {};
|
||||
|
||||
for (const platform of platforms) {
|
||||
const recommendation = this.getOptimalTimes(platform, { limit: postsPerPlatform });
|
||||
schedule[platform] = recommendation.optimalSlots;
|
||||
}
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Avoid overlapping posts across platforms
|
||||
*/
|
||||
getSpacedSchedule(
|
||||
platforms: string[],
|
||||
date: Date,
|
||||
minGapMinutes: number = 30,
|
||||
): Array<{
|
||||
platform: string;
|
||||
time: string;
|
||||
slot: TimeSlot;
|
||||
}> {
|
||||
const allSlots: Array<{ platform: string; slot: TimeSlot }> = [];
|
||||
|
||||
for (const platform of platforms) {
|
||||
const slots = this.platformOptimalTimes[platform] || [];
|
||||
const daySlots = slots.filter((s) => s.dayOfWeek === date.getDay());
|
||||
for (const slot of daySlots) {
|
||||
allSlots.push({ platform, slot });
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score
|
||||
allSlots.sort((a, b) => b.slot.score - a.slot.score);
|
||||
|
||||
// Remove overlapping times
|
||||
const selected: Array<{ platform: string; time: string; slot: TimeSlot }> = [];
|
||||
const usedTimes: Set<number> = new Set();
|
||||
|
||||
for (const { platform, slot } of allSlots) {
|
||||
const [hours, mins] = slot.time.split(':').map(Number);
|
||||
const timeInMinutes = hours * 60 + mins;
|
||||
|
||||
// Check if this time conflicts with any selected time
|
||||
let hasConflict = false;
|
||||
for (const usedTime of usedTimes) {
|
||||
if (Math.abs(usedTime - timeInMinutes) < minGapMinutes) {
|
||||
hasConflict = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasConflict) {
|
||||
selected.push({ platform, time: slot.time, slot });
|
||||
usedTimes.add(timeInMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
return selected.sort((a, b) => {
|
||||
const [aH, aM] = a.time.split(':').map(Number);
|
||||
const [bH, bM] = b.time.split(':').map(Number);
|
||||
return aH * 60 + aM - (bH * 60 + bM);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timezone-adjusted times
|
||||
*/
|
||||
adjustForTimezone(
|
||||
slots: TimeSlot[],
|
||||
targetTimezone: string,
|
||||
): TimeSlot[] {
|
||||
// In production, would use a proper timezone library
|
||||
return slots.map((slot) => ({
|
||||
...slot,
|
||||
time: slot.time, // Would adjust here
|
||||
reasoning: `${slot.reasoning} (adjusted for ${targetTimezone})`,
|
||||
}));
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private applyContentTypeModifiers(slots: TimeSlot[], contentType: string): TimeSlot[] {
|
||||
const modifiers = this.contentTypeModifiers[contentType] || {};
|
||||
|
||||
return slots.map((slot) => {
|
||||
const modifier = modifiers[slot.time] || 1.0;
|
||||
return {
|
||||
...slot,
|
||||
score: Math.round(slot.score * modifier),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getPeakDays(platform: string): string[] {
|
||||
const slots = this.platformOptimalTimes[platform] || [];
|
||||
const dayScores: Record<string, number> = {};
|
||||
|
||||
for (const slot of slots) {
|
||||
dayScores[slot.dayName] = (dayScores[slot.dayName] || 0) + slot.score;
|
||||
}
|
||||
|
||||
return Object.entries(dayScores)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([day]) => day);
|
||||
}
|
||||
|
||||
private scoreToEngagement(score: number): 'low' | 'medium' | 'high' | 'peak' {
|
||||
if (score >= 90) return 'peak';
|
||||
if (score >= 75) return 'high';
|
||||
if (score >= 50) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
private generateTimingInsights(slots: TimeSlot[]): string[] {
|
||||
const insights: string[] = [];
|
||||
|
||||
if (slots.length > 0) {
|
||||
insights.push(`Your best performing time is ${slots[0].time}`);
|
||||
}
|
||||
|
||||
const morningSlots = slots.filter((s) => parseInt(s.time) < 12);
|
||||
const eveningSlots = slots.filter((s) => parseInt(s.time) >= 17);
|
||||
|
||||
if (morningSlots.length > eveningSlots.length) {
|
||||
insights.push('Your audience is more active in the morning');
|
||||
} else if (eveningSlots.length > morningSlots.length) {
|
||||
insights.push('Your audience is more active in the evening');
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
}
|
||||
370
src/modules/scheduling/services/queue-manager.service.ts
Normal file
370
src/modules/scheduling/services/queue-manager.service.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// Queue Manager Service - Post queue management
|
||||
// Path: src/modules/scheduling/services/queue-manager.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
userId: string;
|
||||
content: QueueContent;
|
||||
platforms: string[];
|
||||
scheduledTime: Date;
|
||||
priority: QueuePriority;
|
||||
status: QueueStatus;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
error?: string;
|
||||
publishedIds?: Record<string, string>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface QueueContent {
|
||||
type: 'post' | 'story' | 'reel' | 'video' | 'thread' | 'carousel';
|
||||
text: string;
|
||||
mediaUrls?: string[];
|
||||
hashtags?: string[];
|
||||
link?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export type QueuePriority = 'urgent' | 'high' | 'normal' | 'low';
|
||||
export type QueueStatus = 'pending' | 'processing' | 'published' | 'failed' | 'cancelled' | 'paused';
|
||||
|
||||
export interface QueueStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
processing: number;
|
||||
published: number;
|
||||
failed: number;
|
||||
byPlatform: Record<string, number>;
|
||||
byPriority: Record<QueuePriority, number>;
|
||||
nextScheduled?: QueueItem;
|
||||
}
|
||||
|
||||
export interface QueueFilter {
|
||||
status?: QueueStatus[];
|
||||
platforms?: string[];
|
||||
priority?: QueuePriority[];
|
||||
dateRange?: { from: Date; to: Date };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QueueManagerService {
|
||||
private readonly logger = new Logger(QueueManagerService.name);
|
||||
private queues: Map<string, QueueItem[]> = new Map();
|
||||
|
||||
/**
|
||||
* Add item to queue
|
||||
*/
|
||||
addToQueue(
|
||||
userId: string,
|
||||
input: {
|
||||
content: QueueContent;
|
||||
platforms: string[];
|
||||
scheduledTime: Date;
|
||||
priority?: QueuePriority;
|
||||
},
|
||||
): QueueItem {
|
||||
const item: QueueItem = {
|
||||
id: `queue-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
userId,
|
||||
content: input.content,
|
||||
platforms: input.platforms,
|
||||
scheduledTime: input.scheduledTime,
|
||||
priority: input.priority || 'normal',
|
||||
status: 'pending',
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const userQueue = this.queues.get(userId) || [];
|
||||
userQueue.push(item);
|
||||
|
||||
// Sort by scheduled time and priority
|
||||
userQueue.sort((a, b) => {
|
||||
if (a.scheduledTime.getTime() !== b.scheduledTime.getTime()) {
|
||||
return a.scheduledTime.getTime() - b.scheduledTime.getTime();
|
||||
}
|
||||
return this.priorityWeight(a.priority) - this.priorityWeight(b.priority);
|
||||
});
|
||||
|
||||
this.queues.set(userId, userQueue);
|
||||
this.logger.log(`Added item ${item.id} to queue for user ${userId}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple items to queue
|
||||
*/
|
||||
bulkAddToQueue(
|
||||
userId: string,
|
||||
items: Array<{
|
||||
content: QueueContent;
|
||||
platforms: string[];
|
||||
scheduledTime: Date;
|
||||
priority?: QueuePriority;
|
||||
}>,
|
||||
): QueueItem[] {
|
||||
return items.map((item) => this.addToQueue(userId, item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue items
|
||||
*/
|
||||
getQueue(userId: string, filter?: QueueFilter): QueueItem[] {
|
||||
let items = this.queues.get(userId) || [];
|
||||
|
||||
if (filter) {
|
||||
if (filter.status?.length) {
|
||||
items = items.filter((i) => filter.status!.includes(i.status));
|
||||
}
|
||||
if (filter.platforms?.length) {
|
||||
items = items.filter((i) =>
|
||||
i.platforms.some((p) => filter.platforms!.includes(p))
|
||||
);
|
||||
}
|
||||
if (filter.priority?.length) {
|
||||
items = items.filter((i) => filter.priority!.includes(i.priority));
|
||||
}
|
||||
if (filter.dateRange) {
|
||||
items = items.filter(
|
||||
(i) =>
|
||||
i.scheduledTime >= filter.dateRange!.from &&
|
||||
i.scheduledTime <= filter.dateRange!.to,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next item due for publishing
|
||||
*/
|
||||
getNextDueItem(userId: string): QueueItem | null {
|
||||
const items = this.getQueue(userId, { status: ['pending'] });
|
||||
const now = new Date();
|
||||
|
||||
return items.find((i) => i.scheduledTime <= now) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
getQueueStats(userId: string): QueueStats {
|
||||
const items = this.queues.get(userId) || [];
|
||||
|
||||
const stats: QueueStats = {
|
||||
total: items.length,
|
||||
pending: 0,
|
||||
processing: 0,
|
||||
published: 0,
|
||||
failed: 0,
|
||||
byPlatform: {},
|
||||
byPriority: { urgent: 0, high: 0, normal: 0, low: 0 },
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
stats[item.status as keyof Pick<QueueStats, 'pending' | 'processing' | 'published' | 'failed'>]++;
|
||||
stats.byPriority[item.priority]++;
|
||||
|
||||
for (const platform of item.platforms) {
|
||||
stats.byPlatform[platform] = (stats.byPlatform[platform] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Find next scheduled
|
||||
const pending = items.filter((i) => i.status === 'pending');
|
||||
if (pending.length > 0) {
|
||||
stats.nextScheduled = pending[0];
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update queue item
|
||||
*/
|
||||
updateItem(
|
||||
userId: string,
|
||||
itemId: string,
|
||||
updates: Partial<Pick<QueueItem, 'content' | 'platforms' | 'scheduledTime' | 'priority' | 'status'>>,
|
||||
): QueueItem | null {
|
||||
const userQueue = this.queues.get(userId) || [];
|
||||
const index = userQueue.findIndex((i) => i.id === itemId);
|
||||
|
||||
if (index === -1) return null;
|
||||
|
||||
userQueue[index] = {
|
||||
...userQueue[index],
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.queues.set(userId, userQueue);
|
||||
return userQueue[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark item as processing
|
||||
*/
|
||||
markProcessing(userId: string, itemId: string): QueueItem | null {
|
||||
return this.updateItem(userId, itemId, { status: 'processing' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark item as published
|
||||
*/
|
||||
markPublished(userId: string, itemId: string, publishedIds: Record<string, string>): QueueItem | null {
|
||||
const userQueue = this.queues.get(userId) || [];
|
||||
const index = userQueue.findIndex((i) => i.id === itemId);
|
||||
|
||||
if (index === -1) return null;
|
||||
|
||||
userQueue[index] = {
|
||||
...userQueue[index],
|
||||
status: 'published',
|
||||
publishedIds,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.queues.set(userId, userQueue);
|
||||
return userQueue[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark item as failed
|
||||
*/
|
||||
markFailed(userId: string, itemId: string, error: string): QueueItem | null {
|
||||
const userQueue = this.queues.get(userId) || [];
|
||||
const index = userQueue.findIndex((i) => i.id === itemId);
|
||||
|
||||
if (index === -1) return null;
|
||||
|
||||
userQueue[index].attempts++;
|
||||
userQueue[index].error = error;
|
||||
userQueue[index].updatedAt = new Date();
|
||||
|
||||
// Check if should retry
|
||||
if (userQueue[index].attempts < userQueue[index].maxAttempts) {
|
||||
userQueue[index].status = 'pending';
|
||||
// Reschedule for 5 minutes later
|
||||
userQueue[index].scheduledTime = new Date(Date.now() + 5 * 60 * 1000);
|
||||
} else {
|
||||
userQueue[index].status = 'failed';
|
||||
}
|
||||
|
||||
this.queues.set(userId, userQueue);
|
||||
return userQueue[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel queue item
|
||||
*/
|
||||
cancelItem(userId: string, itemId: string): boolean {
|
||||
const result = this.updateItem(userId, itemId, { status: 'cancelled' });
|
||||
return result !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause/unpause queue item
|
||||
*/
|
||||
togglePause(userId: string, itemId: string): QueueItem | null {
|
||||
const userQueue = this.queues.get(userId) || [];
|
||||
const item = userQueue.find((i) => i.id === itemId);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const newStatus: QueueStatus = item.status === 'paused' ? 'pending' : 'paused';
|
||||
return this.updateItem(userId, itemId, { status: newStatus });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete queue item
|
||||
*/
|
||||
deleteItem(userId: string, itemId: string): boolean {
|
||||
const userQueue = this.queues.get(userId) || [];
|
||||
const filtered = userQueue.filter((i) => i.id !== itemId);
|
||||
|
||||
if (filtered.length === userQueue.length) return false;
|
||||
|
||||
this.queues.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder queue items
|
||||
*/
|
||||
reorderQueue(userId: string, itemIds: string[]): QueueItem[] {
|
||||
const userQueue = this.queues.get(userId) || [];
|
||||
const reordered: QueueItem[] = [];
|
||||
|
||||
for (const id of itemIds) {
|
||||
const item = userQueue.find((i) => i.id === id);
|
||||
if (item) {
|
||||
reordered.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any items not in the new order to the end
|
||||
for (const item of userQueue) {
|
||||
if (!itemIds.includes(item.id)) {
|
||||
reordered.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
this.queues.set(userId, reordered);
|
||||
return reordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear completed/failed items
|
||||
*/
|
||||
clearCompleted(userId: string): number {
|
||||
const userQueue = this.queues.get(userId) || [];
|
||||
const active = userQueue.filter(
|
||||
(i) => !['published', 'failed', 'cancelled'].includes(i.status),
|
||||
);
|
||||
const cleared = userQueue.length - active.length;
|
||||
|
||||
this.queues.set(userId, active);
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed items
|
||||
*/
|
||||
retryFailed(userId: string): number {
|
||||
const userQueue = this.queues.get(userId) || [];
|
||||
let retried = 0;
|
||||
|
||||
for (const item of userQueue) {
|
||||
if (item.status === 'failed') {
|
||||
item.status = 'pending';
|
||||
item.attempts = 0;
|
||||
item.error = undefined;
|
||||
item.scheduledTime = new Date(Date.now() + 60 * 1000); // 1 minute from now
|
||||
item.updatedAt = new Date();
|
||||
retried++;
|
||||
}
|
||||
}
|
||||
|
||||
this.queues.set(userId, userQueue);
|
||||
return retried;
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private priorityWeight(priority: QueuePriority): number {
|
||||
const weights: Record<QueuePriority, number> = {
|
||||
urgent: 0,
|
||||
high: 1,
|
||||
normal: 2,
|
||||
low: 3,
|
||||
};
|
||||
return weights[priority];
|
||||
}
|
||||
}
|
||||
507
src/modules/scheduling/services/workflow-templates.service.ts
Normal file
507
src/modules/scheduling/services/workflow-templates.service.ts
Normal file
@@ -0,0 +1,507 @@
|
||||
// Workflow Templates Service - Predefined and custom workflows
|
||||
// Path: src/modules/scheduling/services/workflow-templates.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface WorkflowTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
type: 'system' | 'user';
|
||||
category: WorkflowCategory;
|
||||
steps: WorkflowStep[];
|
||||
schedule: WeeklySchedule;
|
||||
contentMix: ContentMix;
|
||||
platforms: string[];
|
||||
isActive: boolean;
|
||||
createdBy?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type WorkflowCategory =
|
||||
| 'growth'
|
||||
| 'engagement'
|
||||
| 'brand_awareness'
|
||||
| 'lead_generation'
|
||||
| 'community_building'
|
||||
| 'product_launch'
|
||||
| 'custom';
|
||||
|
||||
export interface WorkflowStep {
|
||||
id: string;
|
||||
order: number;
|
||||
action: WorkflowAction;
|
||||
platform: string;
|
||||
contentType: string;
|
||||
timing: StepTiming;
|
||||
dependencies?: string[];
|
||||
conditions?: WorkflowCondition[];
|
||||
}
|
||||
|
||||
export type WorkflowAction =
|
||||
| 'create_content'
|
||||
| 'schedule_post'
|
||||
| 'publish'
|
||||
| 'engage_comments'
|
||||
| 'cross_post'
|
||||
| 'analyze_performance'
|
||||
| 'send_notification'
|
||||
| 'generate_report';
|
||||
|
||||
export interface StepTiming {
|
||||
dayOfWeek: number; // 0-6
|
||||
time: string;
|
||||
isRelative?: boolean;
|
||||
delay?: string; // e.g., "2h", "1d"
|
||||
}
|
||||
|
||||
export interface WorkflowCondition {
|
||||
type: 'performance' | 'engagement' | 'time' | 'custom';
|
||||
operator: 'gt' | 'lt' | 'eq' | 'gte' | 'lte';
|
||||
value: number | string;
|
||||
action: 'skip' | 'proceed' | 'retry';
|
||||
}
|
||||
|
||||
export interface WeeklySchedule {
|
||||
monday: DaySlot[];
|
||||
tuesday: DaySlot[];
|
||||
wednesday: DaySlot[];
|
||||
thursday: DaySlot[];
|
||||
friday: DaySlot[];
|
||||
saturday: DaySlot[];
|
||||
sunday: DaySlot[];
|
||||
}
|
||||
|
||||
export interface DaySlot {
|
||||
time: string;
|
||||
platform: string;
|
||||
contentType: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ContentMix {
|
||||
proven: number; // Percentage of proven content (33%)
|
||||
experimental: number; // Percentage of experimental content (67%)
|
||||
contentTypes: Record<string, number>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkflowTemplatesService {
|
||||
private readonly logger = new Logger(WorkflowTemplatesService.name);
|
||||
|
||||
// System templates (pre-built workflows)
|
||||
private readonly systemTemplates: WorkflowTemplate[] = [
|
||||
{
|
||||
id: 'wf-growth-accelerator',
|
||||
name: 'Growth Accelerator',
|
||||
description: 'Aggressive growth strategy with high-frequency posting and engagement',
|
||||
type: 'system',
|
||||
category: 'growth',
|
||||
platforms: ['twitter', 'instagram', 'linkedin'],
|
||||
steps: this.createGrowthWorkflowSteps(),
|
||||
schedule: this.createGrowthSchedule(),
|
||||
contentMix: { proven: 40, experimental: 60, contentTypes: { post: 40, reel: 30, carousel: 20, story: 10 } },
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'wf-engagement-booster',
|
||||
name: 'Engagement Booster',
|
||||
description: 'Focus on community engagement and conversation',
|
||||
type: 'system',
|
||||
category: 'engagement',
|
||||
platforms: ['twitter', 'instagram', 'linkedin', 'facebook'],
|
||||
steps: this.createEngagementWorkflowSteps(),
|
||||
schedule: this.createEngagementSchedule(),
|
||||
contentMix: { proven: 50, experimental: 50, contentTypes: { post: 50, story: 30, thread: 20 } },
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'wf-brand-builder',
|
||||
name: 'Brand Builder',
|
||||
description: 'Consistent brand messaging across platforms',
|
||||
type: 'system',
|
||||
category: 'brand_awareness',
|
||||
platforms: ['instagram', 'linkedin', 'youtube'],
|
||||
steps: this.createBrandWorkflowSteps(),
|
||||
schedule: this.createBrandSchedule(),
|
||||
contentMix: { proven: 60, experimental: 40, contentTypes: { carousel: 40, video: 30, article: 20, post: 10 } },
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'wf-content-machine',
|
||||
name: 'Content Machine',
|
||||
description: 'High-volume content production with automation',
|
||||
type: 'system',
|
||||
category: 'growth',
|
||||
platforms: ['twitter', 'instagram', 'tiktok', 'linkedin'],
|
||||
steps: this.createContentMachineSteps(),
|
||||
schedule: this.createContentMachineSchedule(),
|
||||
contentMix: { proven: 33, experimental: 67, contentTypes: { post: 30, reel: 25, thread: 25, story: 20 } },
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: 'wf-lead-gen-funnel',
|
||||
name: 'Lead Generation Funnel',
|
||||
description: 'Content funnel designed to capture leads',
|
||||
type: 'system',
|
||||
category: 'lead_generation',
|
||||
platforms: ['linkedin', 'twitter', 'facebook'],
|
||||
steps: this.createLeadGenSteps(),
|
||||
schedule: this.createLeadGenSchedule(),
|
||||
contentMix: { proven: 70, experimental: 30, contentTypes: { article: 40, post: 30, carousel: 20, video: 10 } },
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
private userTemplates: Map<string, WorkflowTemplate[]> = new Map();
|
||||
|
||||
/**
|
||||
* Get all system templates
|
||||
*/
|
||||
getSystemTemplates(): WorkflowTemplate[] {
|
||||
return this.systemTemplates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get templates by category
|
||||
*/
|
||||
getTemplatesByCategory(category: WorkflowCategory): WorkflowTemplate[] {
|
||||
return this.systemTemplates.filter((t) => t.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's custom templates
|
||||
*/
|
||||
getUserTemplates(userId: string): WorkflowTemplate[] {
|
||||
return this.userTemplates.get(userId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create custom workflow template
|
||||
*/
|
||||
createCustomTemplate(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
description: string;
|
||||
category: WorkflowCategory;
|
||||
platforms: string[];
|
||||
schedule: WeeklySchedule;
|
||||
contentMix: ContentMix;
|
||||
},
|
||||
): WorkflowTemplate {
|
||||
const template: WorkflowTemplate = {
|
||||
id: `wf-custom-${Date.now()}`,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
type: 'user',
|
||||
category: input.category,
|
||||
platforms: input.platforms,
|
||||
steps: this.generateStepsFromSchedule(input.schedule),
|
||||
schedule: input.schedule,
|
||||
contentMix: input.contentMix,
|
||||
isActive: true,
|
||||
createdBy: userId,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const userTemplates = this.userTemplates.get(userId) || [];
|
||||
userTemplates.push(template);
|
||||
this.userTemplates.set(userId, userTemplates);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply workflow template to user
|
||||
*/
|
||||
applyTemplate(
|
||||
userId: string,
|
||||
templateId: string,
|
||||
startDate: Date,
|
||||
): {
|
||||
success: boolean;
|
||||
scheduledEvents: number;
|
||||
weeklySlots: DaySlot[][];
|
||||
} {
|
||||
const template =
|
||||
this.systemTemplates.find((t) => t.id === templateId) ||
|
||||
(this.userTemplates.get(userId) || []).find((t) => t.id === templateId);
|
||||
|
||||
if (!template) {
|
||||
return { success: false, scheduledEvents: 0, weeklySlots: [] };
|
||||
}
|
||||
|
||||
const weeklySlots = [
|
||||
template.schedule.monday,
|
||||
template.schedule.tuesday,
|
||||
template.schedule.wednesday,
|
||||
template.schedule.thursday,
|
||||
template.schedule.friday,
|
||||
template.schedule.saturday,
|
||||
template.schedule.sunday,
|
||||
];
|
||||
|
||||
const totalSlots = weeklySlots.reduce((sum, day) => sum + day.length, 0);
|
||||
|
||||
this.logger.log(`Applied template ${template.name} for user ${userId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
scheduledEvents: totalSlots,
|
||||
weeklySlots,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate optimal weekly schedule
|
||||
*/
|
||||
generateOptimalSchedule(
|
||||
platforms: string[],
|
||||
postsPerDay: number,
|
||||
focus: WorkflowCategory,
|
||||
): WeeklySchedule {
|
||||
const schedule: WeeklySchedule = {
|
||||
monday: [],
|
||||
tuesday: [],
|
||||
wednesday: [],
|
||||
thursday: [],
|
||||
friday: [],
|
||||
saturday: [],
|
||||
sunday: [],
|
||||
};
|
||||
|
||||
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||
const optimalTimes = this.getOptimalTimesForFocus(focus);
|
||||
|
||||
for (const day of days) {
|
||||
for (let i = 0; i < Math.min(postsPerDay, optimalTimes.length); i++) {
|
||||
const platform = platforms[i % platforms.length];
|
||||
schedule[day as keyof WeeklySchedule].push({
|
||||
time: optimalTimes[i],
|
||||
platform,
|
||||
contentType: this.suggestContentType(platform, day, focus),
|
||||
priority: this.determinePriority(day, optimalTimes[i]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content mix recommendation
|
||||
*/
|
||||
getContentMixRecommendation(
|
||||
performanceData?: { proven: number; experimental: number },
|
||||
): ContentMix {
|
||||
// Default 33/67 split as per system design
|
||||
const baseMix: ContentMix = {
|
||||
proven: 33,
|
||||
experimental: 67,
|
||||
contentTypes: {
|
||||
post: 30,
|
||||
carousel: 20,
|
||||
reel: 20,
|
||||
thread: 15,
|
||||
story: 10,
|
||||
video: 5,
|
||||
},
|
||||
};
|
||||
|
||||
if (performanceData) {
|
||||
// Adjust based on what's working
|
||||
if (performanceData.proven > performanceData.experimental) {
|
||||
baseMix.proven = 40;
|
||||
baseMix.experimental = 60;
|
||||
} else {
|
||||
baseMix.proven = 25;
|
||||
baseMix.experimental = 75;
|
||||
}
|
||||
}
|
||||
|
||||
return baseMix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user template
|
||||
*/
|
||||
deleteTemplate(userId: string, templateId: string): boolean {
|
||||
const userTemplates = this.userTemplates.get(userId) || [];
|
||||
const filtered = userTemplates.filter((t) => t.id !== templateId);
|
||||
|
||||
if (filtered.length === userTemplates.length) return false;
|
||||
|
||||
this.userTemplates.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private createGrowthWorkflowSteps(): WorkflowStep[] {
|
||||
return [
|
||||
{ id: 'gs1', order: 1, action: 'create_content', platform: 'twitter', contentType: 'thread', timing: { dayOfWeek: 1, time: '09:00' } },
|
||||
{ id: 'gs2', order: 2, action: 'schedule_post', platform: 'instagram', contentType: 'reel', timing: { dayOfWeek: 1, time: '12:00' } },
|
||||
{ id: 'gs3', order: 3, action: 'cross_post', platform: 'linkedin', contentType: 'post', timing: { dayOfWeek: 1, time: '15:00' } },
|
||||
];
|
||||
}
|
||||
|
||||
private createEngagementWorkflowSteps(): WorkflowStep[] {
|
||||
return [
|
||||
{ id: 'es1', order: 1, action: 'create_content', platform: 'twitter', contentType: 'post', timing: { dayOfWeek: 1, time: '10:00' } },
|
||||
{ id: 'es2', order: 2, action: 'engage_comments', platform: 'instagram', contentType: 'story', timing: { dayOfWeek: 1, time: '14:00' } },
|
||||
{ id: 'es3', order: 3, action: 'analyze_performance', platform: 'all', contentType: 'report', timing: { dayOfWeek: 5, time: '18:00' } },
|
||||
];
|
||||
}
|
||||
|
||||
private createBrandWorkflowSteps(): WorkflowStep[] {
|
||||
return [
|
||||
{ id: 'bs1', order: 1, action: 'create_content', platform: 'instagram', contentType: 'carousel', timing: { dayOfWeek: 2, time: '10:00' } },
|
||||
{ id: 'bs2', order: 2, action: 'schedule_post', platform: 'linkedin', contentType: 'article', timing: { dayOfWeek: 3, time: '08:00' } },
|
||||
{ id: 'bs3', order: 3, action: 'publish', platform: 'youtube', contentType: 'video', timing: { dayOfWeek: 4, time: '16:00' } },
|
||||
];
|
||||
}
|
||||
|
||||
private createContentMachineSteps(): WorkflowStep[] {
|
||||
return [
|
||||
{ id: 'cm1', order: 1, action: 'create_content', platform: 'twitter', contentType: 'thread', timing: { dayOfWeek: 1, time: '08:00' } },
|
||||
{ id: 'cm2', order: 2, action: 'create_content', platform: 'instagram', contentType: 'reel', timing: { dayOfWeek: 1, time: '11:00' } },
|
||||
{ id: 'cm3', order: 3, action: 'cross_post', platform: 'tiktok', contentType: 'reel', timing: { dayOfWeek: 1, time: '14:00' } },
|
||||
{ id: 'cm4', order: 4, action: 'schedule_post', platform: 'linkedin', contentType: 'post', timing: { dayOfWeek: 1, time: '17:00' } },
|
||||
];
|
||||
}
|
||||
|
||||
private createLeadGenSteps(): WorkflowStep[] {
|
||||
return [
|
||||
{ id: 'lg1', order: 1, action: 'create_content', platform: 'linkedin', contentType: 'article', timing: { dayOfWeek: 2, time: '09:00' } },
|
||||
{ id: 'lg2', order: 2, action: 'schedule_post', platform: 'twitter', contentType: 'thread', timing: { dayOfWeek: 3, time: '10:00' } },
|
||||
{ id: 'lg3', order: 3, action: 'generate_report', platform: 'all', contentType: 'report', timing: { dayOfWeek: 5, time: '17:00' } },
|
||||
];
|
||||
}
|
||||
|
||||
private createGrowthSchedule(): WeeklySchedule {
|
||||
return {
|
||||
monday: [{ time: '09:00', platform: 'twitter', contentType: 'thread', priority: 'high' }, { time: '12:00', platform: 'instagram', contentType: 'reel', priority: 'high' }, { time: '18:00', platform: 'linkedin', contentType: 'post', priority: 'medium' }],
|
||||
tuesday: [{ time: '10:00', platform: 'twitter', contentType: 'post', priority: 'medium' }, { time: '15:00', platform: 'instagram', contentType: 'story', priority: 'low' }],
|
||||
wednesday: [{ time: '09:00', platform: 'linkedin', contentType: 'article', priority: 'high' }, { time: '14:00', platform: 'twitter', contentType: 'thread', priority: 'high' }],
|
||||
thursday: [{ time: '11:00', platform: 'instagram', contentType: 'carousel', priority: 'high' }, { time: '17:00', platform: 'twitter', contentType: 'post', priority: 'medium' }],
|
||||
friday: [{ time: '10:00', platform: 'twitter', contentType: 'thread', priority: 'high' }, { time: '16:00', platform: 'instagram', contentType: 'reel', priority: 'high' }],
|
||||
saturday: [{ time: '12:00', platform: 'instagram', contentType: 'story', priority: 'low' }],
|
||||
sunday: [{ time: '18:00', platform: 'twitter', contentType: 'post', priority: 'medium' }],
|
||||
};
|
||||
}
|
||||
|
||||
private createEngagementSchedule(): WeeklySchedule {
|
||||
return {
|
||||
monday: [{ time: '10:00', platform: 'twitter', contentType: 'post', priority: 'high' }],
|
||||
tuesday: [{ time: '11:00', platform: 'instagram', contentType: 'story', priority: 'medium' }],
|
||||
wednesday: [{ time: '09:00', platform: 'linkedin', contentType: 'post', priority: 'high' }],
|
||||
thursday: [{ time: '14:00', platform: 'facebook', contentType: 'post', priority: 'medium' }],
|
||||
friday: [{ time: '16:00', platform: 'twitter', contentType: 'thread', priority: 'high' }],
|
||||
saturday: [{ time: '11:00', platform: 'instagram', contentType: 'story', priority: 'low' }],
|
||||
sunday: [],
|
||||
};
|
||||
}
|
||||
|
||||
private createBrandSchedule(): WeeklySchedule {
|
||||
return {
|
||||
monday: [{ time: '10:00', platform: 'instagram', contentType: 'carousel', priority: 'high' }],
|
||||
tuesday: [{ time: '08:00', platform: 'linkedin', contentType: 'article', priority: 'high' }],
|
||||
wednesday: [{ time: '14:00', platform: 'instagram', contentType: 'reel', priority: 'high' }],
|
||||
thursday: [{ time: '16:00', platform: 'youtube', contentType: 'video', priority: 'high' }],
|
||||
friday: [{ time: '11:00', platform: 'linkedin', contentType: 'post', priority: 'medium' }],
|
||||
saturday: [{ time: '10:00', platform: 'instagram', contentType: 'story', priority: 'low' }],
|
||||
sunday: [],
|
||||
};
|
||||
}
|
||||
|
||||
private createContentMachineSchedule(): WeeklySchedule {
|
||||
return {
|
||||
monday: [{ time: '08:00', platform: 'twitter', contentType: 'thread', priority: 'high' }, { time: '11:00', platform: 'instagram', contentType: 'reel', priority: 'high' }, { time: '14:00', platform: 'tiktok', contentType: 'reel', priority: 'high' }, { time: '17:00', platform: 'linkedin', contentType: 'post', priority: 'medium' }],
|
||||
tuesday: [{ time: '09:00', platform: 'twitter', contentType: 'post', priority: 'high' }, { time: '12:00', platform: 'instagram', contentType: 'carousel', priority: 'high' }, { time: '15:00', platform: 'tiktok', contentType: 'reel', priority: 'medium' }],
|
||||
wednesday: [{ time: '08:00', platform: 'linkedin', contentType: 'article', priority: 'high' }, { time: '11:00', platform: 'twitter', contentType: 'thread', priority: 'high' }, { time: '14:00', platform: 'instagram', contentType: 'reel', priority: 'high' }],
|
||||
thursday: [{ time: '09:00', platform: 'twitter', contentType: 'post', priority: 'medium' }, { time: '12:00', platform: 'instagram', contentType: 'story', priority: 'low' }, { time: '16:00', platform: 'tiktok', contentType: 'reel', priority: 'high' }],
|
||||
friday: [{ time: '10:00', platform: 'twitter', contentType: 'thread', priority: 'high' }, { time: '13:00', platform: 'instagram', contentType: 'carousel', priority: 'high' }, { time: '18:00', platform: 'linkedin', contentType: 'post', priority: 'medium' }],
|
||||
saturday: [{ time: '11:00', platform: 'instagram', contentType: 'reel', priority: 'high' }, { time: '15:00', platform: 'tiktok', contentType: 'reel', priority: 'high' }],
|
||||
sunday: [{ time: '12:00', platform: 'twitter', contentType: 'thread', priority: 'medium' }, { time: '17:00', platform: 'instagram', contentType: 'story', priority: 'low' }],
|
||||
};
|
||||
}
|
||||
|
||||
private createLeadGenSchedule(): WeeklySchedule {
|
||||
return {
|
||||
monday: [{ time: '08:00', platform: 'linkedin', contentType: 'post', priority: 'high' }],
|
||||
tuesday: [{ time: '09:00', platform: 'linkedin', contentType: 'article', priority: 'high' }],
|
||||
wednesday: [{ time: '10:00', platform: 'twitter', contentType: 'thread', priority: 'high' }],
|
||||
thursday: [{ time: '09:00', platform: 'facebook', contentType: 'post', priority: 'medium' }],
|
||||
friday: [{ time: '11:00', platform: 'linkedin', contentType: 'carousel', priority: 'high' }],
|
||||
saturday: [],
|
||||
sunday: [],
|
||||
};
|
||||
}
|
||||
|
||||
private generateStepsFromSchedule(schedule: WeeklySchedule): WorkflowStep[] {
|
||||
const steps: WorkflowStep[] = [];
|
||||
const days = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
||||
|
||||
let order = 1;
|
||||
for (let dayIndex = 0; dayIndex < days.length; dayIndex++) {
|
||||
const daySlots = schedule[days[dayIndex] as keyof WeeklySchedule];
|
||||
for (const slot of daySlots) {
|
||||
steps.push({
|
||||
id: `step-${order}`,
|
||||
order,
|
||||
action: 'schedule_post',
|
||||
platform: slot.platform,
|
||||
contentType: slot.contentType,
|
||||
timing: { dayOfWeek: dayIndex, time: slot.time },
|
||||
});
|
||||
order++;
|
||||
}
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
private getOptimalTimesForFocus(focus: WorkflowCategory): string[] {
|
||||
switch (focus) {
|
||||
case 'growth':
|
||||
return ['08:00', '11:00', '14:00', '17:00', '20:00'];
|
||||
case 'engagement':
|
||||
return ['10:00', '14:00', '19:00'];
|
||||
case 'brand_awareness':
|
||||
return ['09:00', '12:00', '18:00'];
|
||||
case 'lead_generation':
|
||||
return ['08:00', '10:00', '14:00'];
|
||||
default:
|
||||
return ['09:00', '12:00', '15:00', '18:00'];
|
||||
}
|
||||
}
|
||||
|
||||
private suggestContentType(_platform: string, _day: string, focus: WorkflowCategory): string {
|
||||
const suggestions: Record<WorkflowCategory, string[]> = {
|
||||
growth: ['thread', 'reel', 'carousel', 'post'],
|
||||
engagement: ['story', 'post', 'thread'],
|
||||
brand_awareness: ['carousel', 'video', 'article'],
|
||||
lead_generation: ['article', 'carousel', 'post'],
|
||||
community_building: ['story', 'thread', 'post'],
|
||||
product_launch: ['reel', 'carousel', 'video'],
|
||||
custom: ['post'],
|
||||
};
|
||||
const types = suggestions[focus] || ['post'];
|
||||
return types[Math.floor(Math.random() * types.length)];
|
||||
}
|
||||
|
||||
private determinePriority(_day: string, time: string): 'high' | 'medium' | 'low' {
|
||||
const hour = parseInt(time.split(':')[0], 10);
|
||||
if (hour >= 9 && hour <= 11) return 'high';
|
||||
if (hour >= 14 && hour <= 17) return 'high';
|
||||
if (hour >= 19 && hour <= 21) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
}
|
||||
9
src/modules/seo/index.ts
Normal file
9
src/modules/seo/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// SEO Module - Index exports
|
||||
// Path: src/modules/seo/index.ts
|
||||
|
||||
export * from './seo.module';
|
||||
export * from './seo.service';
|
||||
export * from './seo.controller';
|
||||
export * from './services/keyword-research.service';
|
||||
export * from './services/content-optimization.service';
|
||||
export * from './services/competitor-analysis.service';
|
||||
198
src/modules/seo/seo.controller.ts
Normal file
198
src/modules/seo/seo.controller.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// SEO Controller - API endpoints
|
||||
// Path: src/modules/seo/seo.controller.ts
|
||||
|
||||
import { Controller, Get, Post, Body, Query, Param } from '@nestjs/common';
|
||||
import { SeoService } from './seo.service';
|
||||
import { KeywordResearchService } from './services/keyword-research.service';
|
||||
import { ContentOptimizationService } from './services/content-optimization.service';
|
||||
import { CompetitorAnalysisService } from './services/competitor-analysis.service';
|
||||
|
||||
@Controller('seo')
|
||||
export class SeoController {
|
||||
constructor(
|
||||
private readonly seoService: SeoService,
|
||||
private readonly keywordService: KeywordResearchService,
|
||||
private readonly optimizationService: ContentOptimizationService,
|
||||
private readonly competitorService: CompetitorAnalysisService,
|
||||
) { }
|
||||
|
||||
// ========== FULL ANALYSIS ==========
|
||||
|
||||
@Post('analyze')
|
||||
analyzeFull(
|
||||
@Body() body: {
|
||||
content: string;
|
||||
targetKeyword: string;
|
||||
title?: string;
|
||||
metaDescription?: string;
|
||||
url?: string;
|
||||
competitorDomains?: string[];
|
||||
},
|
||||
) {
|
||||
return this.seoService.analyzeFull(body.content, body.targetKeyword, {
|
||||
title: body.title,
|
||||
metaDescription: body.metaDescription,
|
||||
url: body.url,
|
||||
competitorDomains: body.competitorDomains,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('quick-score')
|
||||
quickScore(
|
||||
@Body() body: { content: string; targetKeyword?: string },
|
||||
) {
|
||||
return { score: this.seoService.quickScore(body.content, body.targetKeyword) };
|
||||
}
|
||||
|
||||
// ========== KEYWORDS ==========
|
||||
|
||||
@Get('keywords/suggest/:topic')
|
||||
suggestKeywords(
|
||||
@Param('topic') topic: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.keywordService.suggestKeywords(topic, {
|
||||
count: count ? parseInt(count, 10) : 20,
|
||||
includeQuestions: true,
|
||||
includeLongTail: true,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('keywords/long-tail/:keyword')
|
||||
getLongTailKeywords(
|
||||
@Param('keyword') keyword: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.keywordService.generateLongTail(keyword, count ? parseInt(count, 10) : 20);
|
||||
}
|
||||
|
||||
@Get('keywords/lsi/:keyword')
|
||||
getLSIKeywords(
|
||||
@Param('keyword') keyword: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.seoService.getLSIKeywords(keyword, count ? parseInt(count, 10) : 10);
|
||||
}
|
||||
|
||||
@Post('keywords/cluster')
|
||||
clusterKeywords(@Body() body: { keywords: string[] }) {
|
||||
return this.keywordService.clusterKeywords(body.keywords);
|
||||
}
|
||||
|
||||
@Get('keywords/difficulty/:keyword')
|
||||
analyzeKeywordDifficulty(@Param('keyword') keyword: string) {
|
||||
return this.seoService.analyzeKeywordDifficulty(keyword);
|
||||
}
|
||||
|
||||
@Post('keywords/cannibalization')
|
||||
checkCannibalization(
|
||||
@Body() body: { newKeyword: string; existingKeywords: string[] },
|
||||
) {
|
||||
return this.seoService.checkCannibalization(body.newKeyword, body.existingKeywords);
|
||||
}
|
||||
|
||||
// ========== CONTENT OPTIMIZATION ==========
|
||||
|
||||
@Post('optimize')
|
||||
optimizeContent(
|
||||
@Body() body: {
|
||||
content: string;
|
||||
targetKeyword: string;
|
||||
title?: string;
|
||||
metaDescription?: string;
|
||||
url?: string;
|
||||
},
|
||||
) {
|
||||
const score = this.optimizationService.analyze(body.content, {
|
||||
targetKeyword: body.targetKeyword,
|
||||
title: body.title,
|
||||
metaDescription: body.metaDescription,
|
||||
url: body.url,
|
||||
});
|
||||
const optimized = this.optimizationService.optimize(body.content, body.targetKeyword);
|
||||
return { score, optimized };
|
||||
}
|
||||
|
||||
@Post('meta/generate')
|
||||
generateMeta(
|
||||
@Body() body: { content: string; targetKeyword: string; brandName?: string },
|
||||
) {
|
||||
return this.optimizationService.generateMeta(body.content, body.targetKeyword, {
|
||||
brandName: body.brandName,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('titles/:keyword')
|
||||
generateTitles(
|
||||
@Param('keyword') keyword: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.optimizationService.generateTitleVariations(
|
||||
keyword,
|
||||
count ? parseInt(count, 10) : 5,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('descriptions')
|
||||
generateDescriptions(
|
||||
@Body() body: { keyword: string; content: string; count?: number },
|
||||
) {
|
||||
return this.optimizationService.generateDescriptionVariations(
|
||||
body.keyword,
|
||||
body.content,
|
||||
body.count || 3,
|
||||
);
|
||||
}
|
||||
|
||||
// ========== COMPETITORS ==========
|
||||
|
||||
@Get('competitors/:domain')
|
||||
analyzeCompetitor(@Param('domain') domain: string) {
|
||||
return this.competitorService.analyzeCompetitor(domain);
|
||||
}
|
||||
|
||||
@Post('competitors/gaps')
|
||||
findContentGaps(
|
||||
@Body() body: { yourKeywords: string[]; competitorDomains: string[] },
|
||||
) {
|
||||
return this.competitorService.findContentGaps(body.yourKeywords, body.competitorDomains);
|
||||
}
|
||||
|
||||
@Get('competitors/content/:keyword')
|
||||
analyzeTopContent(
|
||||
@Param('keyword') keyword: string,
|
||||
@Query('count') count?: string,
|
||||
) {
|
||||
return this.competitorService.analyzeTopContent(keyword, count ? parseInt(count, 10) : 10);
|
||||
}
|
||||
|
||||
@Post('competitors/blueprint')
|
||||
generateBlueprint(
|
||||
@Body() body: { keyword: string; competitorDomains: string[] },
|
||||
) {
|
||||
return this.competitorService.analyzeTopContent(body.keyword, 5).then((insights) =>
|
||||
this.competitorService.generateContentBlueprint(body.keyword, insights),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('competitors/patterns')
|
||||
getPublishingPatterns(@Body() body: { domains: string[] }) {
|
||||
return this.competitorService.getPublishingPatterns(body.domains);
|
||||
}
|
||||
|
||||
// ========== OUTLINE GENERATION ==========
|
||||
|
||||
@Post('outline')
|
||||
generateOutline(
|
||||
@Body() body: {
|
||||
keyword: string;
|
||||
competitorDomains?: string[];
|
||||
contentType?: string;
|
||||
},
|
||||
) {
|
||||
return this.seoService.generateOutline(body.keyword, {
|
||||
competitorDomains: body.competitorDomains,
|
||||
contentType: body.contentType,
|
||||
});
|
||||
}
|
||||
}
|
||||
21
src/modules/seo/seo.module.ts
Normal file
21
src/modules/seo/seo.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// SEO Intelligence Module - Keyword research and content optimization
|
||||
// Path: src/modules/seo/seo.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SeoService } from './seo.service';
|
||||
import { SeoController } from './seo.controller';
|
||||
import { KeywordResearchService } from './services/keyword-research.service';
|
||||
import { ContentOptimizationService } from './services/content-optimization.service';
|
||||
import { CompetitorAnalysisService } from './services/competitor-analysis.service';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
SeoService,
|
||||
KeywordResearchService,
|
||||
ContentOptimizationService,
|
||||
CompetitorAnalysisService,
|
||||
],
|
||||
controllers: [SeoController],
|
||||
exports: [SeoService],
|
||||
})
|
||||
export class SeoModule { }
|
||||
190
src/modules/seo/seo.service.ts
Normal file
190
src/modules/seo/seo.service.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// SEO Service - Main orchestration service
|
||||
// Path: src/modules/seo/seo.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { KeywordResearchService, Keyword, KeywordCluster } from './services/keyword-research.service';
|
||||
import { ContentOptimizationService, SeoScore, OptimizedMeta } from './services/content-optimization.service';
|
||||
import { CompetitorAnalysisService, ContentGap, CompetitorProfile } from './services/competitor-analysis.service';
|
||||
|
||||
export interface FullSeoAnalysis {
|
||||
content: {
|
||||
score: SeoScore;
|
||||
meta: OptimizedMeta;
|
||||
};
|
||||
keywords: {
|
||||
main: Keyword;
|
||||
related: Keyword[];
|
||||
clusters: KeywordCluster[];
|
||||
longTail: ReturnType<KeywordResearchService['generateLongTail']>;
|
||||
};
|
||||
competitors: {
|
||||
gaps: ContentGap[];
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SeoService {
|
||||
private readonly logger = new Logger(SeoService.name);
|
||||
|
||||
constructor(
|
||||
private readonly keywordService: KeywordResearchService,
|
||||
private readonly optimizationService: ContentOptimizationService,
|
||||
private readonly competitorService: CompetitorAnalysisService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Full SEO analysis for content
|
||||
*/
|
||||
async analyzeFull(
|
||||
content: string,
|
||||
targetKeyword: string,
|
||||
options?: {
|
||||
title?: string;
|
||||
metaDescription?: string;
|
||||
url?: string;
|
||||
competitorDomains?: string[];
|
||||
},
|
||||
): Promise<FullSeoAnalysis> {
|
||||
// Analyze content
|
||||
const score = this.optimizationService.analyze(content, {
|
||||
targetKeyword,
|
||||
title: options?.title,
|
||||
metaDescription: options?.metaDescription,
|
||||
url: options?.url,
|
||||
});
|
||||
|
||||
// Generate optimized meta
|
||||
const meta = this.optimizationService.generateMeta(content, targetKeyword);
|
||||
|
||||
// Research keywords
|
||||
const keywordData = await this.keywordService.suggestKeywords(targetKeyword, {
|
||||
count: 20,
|
||||
includeQuestions: true,
|
||||
includeLongTail: true,
|
||||
});
|
||||
|
||||
// Cluster keywords
|
||||
const allKeywords = [targetKeyword, ...keywordData.related.map((k) => k.term)];
|
||||
const clusters = this.keywordService.clusterKeywords(allKeywords);
|
||||
|
||||
// Long-tail variations
|
||||
const longTail = this.keywordService.generateLongTail(targetKeyword, 15);
|
||||
|
||||
// Content gaps (if competitors provided)
|
||||
let gaps: ContentGap[] = [];
|
||||
if (options?.competitorDomains?.length) {
|
||||
gaps = await this.competitorService.findContentGaps(
|
||||
allKeywords,
|
||||
options.competitorDomains,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: { score, meta },
|
||||
keywords: {
|
||||
main: keywordData.main,
|
||||
related: keywordData.related,
|
||||
clusters,
|
||||
longTail,
|
||||
},
|
||||
competitors: { gaps },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick SEO score check
|
||||
*/
|
||||
quickScore(content: string, targetKeyword?: string): number {
|
||||
const analysis = this.optimizationService.analyze(content, { targetKeyword });
|
||||
return analysis.overall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SEO-optimized content outline
|
||||
*/
|
||||
async generateOutline(
|
||||
keyword: string,
|
||||
options?: {
|
||||
competitorDomains?: string[];
|
||||
contentType?: string;
|
||||
},
|
||||
): Promise<{
|
||||
title: string;
|
||||
description: string;
|
||||
headings: string[];
|
||||
keywords: string[];
|
||||
estimatedWordCount: number;
|
||||
differentiators: string[];
|
||||
}> {
|
||||
// Get keyword data
|
||||
const keywordData = await this.keywordService.suggestKeywords(keyword);
|
||||
|
||||
// Get title variations
|
||||
const titles = this.optimizationService.generateTitleVariations(keyword, 1);
|
||||
const descriptions = this.optimizationService.generateDescriptionVariations(keyword, '', 1);
|
||||
|
||||
// Generate outline if competitors provided
|
||||
let headings: string[] = [];
|
||||
let differentiators: string[] = [];
|
||||
|
||||
if (options?.competitorDomains?.length) {
|
||||
const competitorContent = await this.competitorService.analyzeTopContent(keyword, 5);
|
||||
const blueprint = this.competitorService.generateContentBlueprint(keyword, competitorContent);
|
||||
headings = blueprint.suggestedHeadings;
|
||||
differentiators = blueprint.differentiators;
|
||||
} else {
|
||||
headings = [
|
||||
`What is ${keyword}?`,
|
||||
`Why ${keyword} is Important`,
|
||||
`How to Use ${keyword}`,
|
||||
`${keyword} Best Practices`,
|
||||
`Common ${keyword} Mistakes`,
|
||||
`${keyword} Tools & Resources`,
|
||||
`FAQs about ${keyword}`,
|
||||
];
|
||||
differentiators = [
|
||||
'Include original research or data',
|
||||
'Add expert insights',
|
||||
'Provide actionable steps',
|
||||
'Include real examples',
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
title: titles[0] || `Complete Guide to ${keyword}`,
|
||||
description: descriptions[0] || `Learn everything about ${keyword} in this comprehensive guide.`,
|
||||
headings,
|
||||
keywords: [keyword, ...keywordData.related.slice(0, 5).map((k) => k.term)],
|
||||
estimatedWordCount: 1500,
|
||||
differentiators,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LSI keywords for semantic SEO
|
||||
*/
|
||||
getLSIKeywords(keyword: string, count: number = 10): string[] {
|
||||
return this.keywordService.generateLSIKeywords(keyword, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze keyword difficulty
|
||||
*/
|
||||
analyzeKeywordDifficulty(keyword: string) {
|
||||
return this.keywordService.analyzeKeywordDifficulty(keyword);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for keyword cannibalization
|
||||
*/
|
||||
checkCannibalization(newKeyword: string, existingKeywords: string[]) {
|
||||
return this.optimizationService.checkCannibalization(newKeyword, existingKeywords);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get competitor insights
|
||||
*/
|
||||
async getCompetitorInsights(domain: string): Promise<CompetitorProfile> {
|
||||
return this.competitorService.analyzeCompetitor(domain);
|
||||
}
|
||||
}
|
||||
264
src/modules/seo/services/competitor-analysis.service.ts
Normal file
264
src/modules/seo/services/competitor-analysis.service.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
// Competitor Analysis Service - Analyze competitor content for SEO insights
|
||||
// Path: src/modules/seo/services/competitor-analysis.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface CompetitorProfile {
|
||||
url: string;
|
||||
domain: string;
|
||||
estimatedAuthority: number; // 0-100
|
||||
contentFrequency: 'daily' | 'weekly' | 'monthly' | 'rarely';
|
||||
topKeywords: string[];
|
||||
contentTypes: string[];
|
||||
avgContentLength: number;
|
||||
socialPresence: {
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
instagram?: string;
|
||||
youtube?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentGap {
|
||||
keyword: string;
|
||||
competitorsCovering: number;
|
||||
difficulty: number;
|
||||
opportunity: 'high' | 'medium' | 'low';
|
||||
suggestedContentType: string;
|
||||
}
|
||||
|
||||
export interface CompetitorContentInsight {
|
||||
title: string;
|
||||
url: string;
|
||||
estimatedTraffic: number;
|
||||
keywords: string[];
|
||||
contentLength: number;
|
||||
backlinks: number;
|
||||
publishedDate?: string;
|
||||
strengths: string[];
|
||||
weaknesses: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CompetitorAnalysisService {
|
||||
private readonly logger = new Logger(CompetitorAnalysisService.name);
|
||||
|
||||
/**
|
||||
* Analyze competitor for SEO insights
|
||||
*/
|
||||
async analyzeCompetitor(domain: string): Promise<CompetitorProfile> {
|
||||
// In production, this would scrape/analyze the competitor
|
||||
// For now, return mock data structure
|
||||
return {
|
||||
url: `https://${domain}`,
|
||||
domain,
|
||||
estimatedAuthority: Math.floor(Math.random() * 50 + 30),
|
||||
contentFrequency: 'weekly',
|
||||
topKeywords: [
|
||||
`${domain} tips`,
|
||||
`best ${domain} practices`,
|
||||
`${domain} guide`,
|
||||
`how to use ${domain}`,
|
||||
`${domain} alternatives`,
|
||||
],
|
||||
contentTypes: ['blog', 'guides', 'case-studies', 'videos'],
|
||||
avgContentLength: Math.floor(Math.random() * 1000 + 1500),
|
||||
socialPresence: {
|
||||
twitter: `@${domain.split('.')[0]}`,
|
||||
linkedin: domain.split('.')[0],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify content gaps between you and competitors
|
||||
*/
|
||||
async findContentGaps(
|
||||
yourKeywords: string[],
|
||||
competitorDomains: string[],
|
||||
): Promise<ContentGap[]> {
|
||||
const gaps: ContentGap[] = [];
|
||||
|
||||
// Mock gap analysis
|
||||
const potentialGaps = [
|
||||
'beginner guide',
|
||||
'advanced strategies',
|
||||
'case studies',
|
||||
'tool comparison',
|
||||
'industry trends',
|
||||
'best practices 2024',
|
||||
'common mistakes',
|
||||
'step by step tutorial',
|
||||
'video walkthrough',
|
||||
'templates',
|
||||
];
|
||||
|
||||
for (const gap of potentialGaps) {
|
||||
const covering = Math.floor(Math.random() * competitorDomains.length);
|
||||
|
||||
if (!yourKeywords.some((k) => k.toLowerCase().includes(gap))) {
|
||||
gaps.push({
|
||||
keyword: gap,
|
||||
competitorsCovering: covering,
|
||||
difficulty: Math.floor(Math.random() * 60 + 20),
|
||||
opportunity: covering >= 2 ? 'high' : covering === 1 ? 'medium' : 'low',
|
||||
suggestedContentType: this.suggestContentType(gap),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return gaps.sort((a, b) => {
|
||||
const priorityMap = { high: 3, medium: 2, low: 1 };
|
||||
return priorityMap[b.opportunity] - priorityMap[a.opportunity];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze top competitor content for a keyword
|
||||
*/
|
||||
async analyzeTopContent(
|
||||
keyword: string,
|
||||
count: number = 10,
|
||||
): Promise<CompetitorContentInsight[]> {
|
||||
// Mock competitor content analysis
|
||||
const insights: CompetitorContentInsight[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
insights.push({
|
||||
title: `${keyword.charAt(0).toUpperCase() + keyword.slice(1)} - Complete Guide ${i + 1}`,
|
||||
url: `https://example${i + 1}.com/${keyword.replace(/\s+/g, '-')}`,
|
||||
estimatedTraffic: Math.floor(Math.random() * 50000 + 5000),
|
||||
keywords: [
|
||||
keyword,
|
||||
`${keyword} tips`,
|
||||
`best ${keyword}`,
|
||||
`${keyword} examples`,
|
||||
],
|
||||
contentLength: Math.floor(Math.random() * 2000 + 1000),
|
||||
backlinks: Math.floor(Math.random() * 500 + 50),
|
||||
strengths: this.identifyStrengths(i),
|
||||
weaknesses: this.identifyWeaknesses(i),
|
||||
});
|
||||
}
|
||||
|
||||
return insights.sort((a, b) => b.estimatedTraffic - a.estimatedTraffic);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content blueprint based on competitor analysis
|
||||
*/
|
||||
generateContentBlueprint(
|
||||
keyword: string,
|
||||
competitorInsights: CompetitorContentInsight[],
|
||||
): {
|
||||
recommendedLength: number;
|
||||
suggestedHeadings: string[];
|
||||
keyPoints: string[];
|
||||
differentiators: string[];
|
||||
callToActions: string[];
|
||||
} {
|
||||
const avgLength = competitorInsights.reduce((sum, c) => sum + c.contentLength, 0) / competitorInsights.length;
|
||||
|
||||
return {
|
||||
recommendedLength: Math.floor(avgLength * 1.2), // 20% longer than competitors
|
||||
suggestedHeadings: [
|
||||
`What is ${keyword}?`,
|
||||
`Why ${keyword} Matters`,
|
||||
`How to Get Started with ${keyword}`,
|
||||
`${keyword} Best Practices`,
|
||||
`Common ${keyword} Mistakes to Avoid`,
|
||||
`${keyword} Tools and Resources`,
|
||||
`${keyword} Case Studies`,
|
||||
`Frequently Asked Questions about ${keyword}`,
|
||||
],
|
||||
keyPoints: [
|
||||
'Comprehensive coverage of all subtopics',
|
||||
'Include original data or research',
|
||||
'Add expert quotes or interviews',
|
||||
'Provide actionable steps',
|
||||
'Include visual elements (images, infographics)',
|
||||
],
|
||||
differentiators: [
|
||||
'Include original case studies',
|
||||
'Add interactive elements or tools',
|
||||
'Provide downloadable resources',
|
||||
'Include video content',
|
||||
'Offer a unique angle or perspective',
|
||||
],
|
||||
callToActions: [
|
||||
`Download our free ${keyword} checklist`,
|
||||
`Try our ${keyword} tool free`,
|
||||
`Subscribe for more ${keyword} tips`,
|
||||
`Get a personalized ${keyword} strategy`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Track competitor content publishing
|
||||
*/
|
||||
getPublishingPatterns(
|
||||
competitorDomains: string[],
|
||||
): {
|
||||
domain: string;
|
||||
avgPostsPerWeek: number;
|
||||
bestPublishingDay: string;
|
||||
topContentTypes: string[];
|
||||
engagementLevel: 'low' | 'medium' | 'high';
|
||||
}[] {
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'];
|
||||
|
||||
return competitorDomains.map((domain) => ({
|
||||
domain,
|
||||
avgPostsPerWeek: Math.floor(Math.random() * 5 + 1),
|
||||
bestPublishingDay: days[Math.floor(Math.random() * days.length)],
|
||||
topContentTypes: ['blog', 'guides', 'case-studies'].slice(0, Math.floor(Math.random() * 3 + 1)),
|
||||
engagementLevel: ['low', 'medium', 'high'][Math.floor(Math.random() * 3)] as 'low' | 'medium' | 'high',
|
||||
}));
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private suggestContentType(gap: string): string {
|
||||
if (gap.includes('guide') || gap.includes('tutorial')) return 'long-form guide';
|
||||
if (gap.includes('video')) return 'video content';
|
||||
if (gap.includes('case')) return 'case study';
|
||||
if (gap.includes('comparison') || gap.includes('vs')) return 'comparison article';
|
||||
if (gap.includes('template')) return 'template/resource';
|
||||
return 'blog post';
|
||||
}
|
||||
|
||||
private identifyStrengths(index: number): string[] {
|
||||
const allStrengths = [
|
||||
'Comprehensive coverage',
|
||||
'Well-structured headings',
|
||||
'Good use of visuals',
|
||||
'Strong call-to-actions',
|
||||
'Includes original data',
|
||||
'Expert quotes included',
|
||||
'Mobile-optimized',
|
||||
'Fast page load',
|
||||
'Interactive elements',
|
||||
'Downloadable resources',
|
||||
];
|
||||
|
||||
return allStrengths.slice(index % 3, (index % 3) + 3);
|
||||
}
|
||||
|
||||
private identifyWeaknesses(index: number): string[] {
|
||||
const allWeaknesses = [
|
||||
'Thin content',
|
||||
'Outdated information',
|
||||
'No visual elements',
|
||||
'Poor readability',
|
||||
'Missing key subtopics',
|
||||
'Slow page load',
|
||||
'No internal links',
|
||||
'Weak call-to-actions',
|
||||
'No social proof',
|
||||
'Generic content',
|
||||
];
|
||||
|
||||
return allWeaknesses.slice(index % 3, (index % 3) + 2);
|
||||
}
|
||||
}
|
||||
454
src/modules/seo/services/content-optimization.service.ts
Normal file
454
src/modules/seo/services/content-optimization.service.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
// Content Optimization Service - SEO scoring and optimization
|
||||
// Path: src/modules/seo/services/content-optimization.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface SeoScore {
|
||||
overall: number; // 0-100
|
||||
breakdown: {
|
||||
titleOptimization: number;
|
||||
metaDescription: number;
|
||||
headingStructure: number;
|
||||
keywordDensity: number;
|
||||
contentLength: number;
|
||||
readability: number;
|
||||
internalLinks: number;
|
||||
externalLinks: number;
|
||||
imageOptimization: number;
|
||||
urlStructure: number;
|
||||
};
|
||||
issues: SeoIssue[];
|
||||
suggestions: SeoSuggestion[];
|
||||
}
|
||||
|
||||
export interface SeoIssue {
|
||||
type: 'error' | 'warning' | 'info';
|
||||
category: string;
|
||||
message: string;
|
||||
fix: string;
|
||||
}
|
||||
|
||||
export interface SeoSuggestion {
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
category: string;
|
||||
suggestion: string;
|
||||
impact: string;
|
||||
}
|
||||
|
||||
export interface OptimizedMeta {
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string[];
|
||||
ogTitle: string;
|
||||
ogDescription: string;
|
||||
twitterTitle: string;
|
||||
twitterDescription: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentOptimizationService {
|
||||
private readonly logger = new Logger(ContentOptimizationService.name);
|
||||
|
||||
// Optimal content parameters
|
||||
private readonly optimalParams = {
|
||||
titleLength: { min: 50, max: 60 },
|
||||
metaDescLength: { min: 150, max: 160 },
|
||||
contentWords: { min: 1000, max: 2500 },
|
||||
keywordDensity: { min: 1, max: 3 }, // percentage
|
||||
paragraphLength: { max: 150 }, // words
|
||||
sentenceLength: { max: 20 }, // words
|
||||
h1Count: 1,
|
||||
h2MinCount: 2,
|
||||
imageAltMinPercent: 100,
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze content for SEO optimization
|
||||
*/
|
||||
analyze(content: string, options?: {
|
||||
targetKeyword?: string;
|
||||
title?: string;
|
||||
metaDescription?: string;
|
||||
url?: string;
|
||||
}): SeoScore {
|
||||
const breakdown = {
|
||||
titleOptimization: this.scoreTitleOptimization(options?.title, options?.targetKeyword),
|
||||
metaDescription: this.scoreMetaDescription(options?.metaDescription, options?.targetKeyword),
|
||||
headingStructure: this.scoreHeadingStructure(content),
|
||||
keywordDensity: this.scoreKeywordDensity(content, options?.targetKeyword),
|
||||
contentLength: this.scoreContentLength(content),
|
||||
readability: this.scoreReadability(content),
|
||||
internalLinks: this.scoreInternalLinks(content),
|
||||
externalLinks: this.scoreExternalLinks(content),
|
||||
imageOptimization: this.scoreImageOptimization(content),
|
||||
urlStructure: this.scoreUrlStructure(options?.url),
|
||||
};
|
||||
|
||||
const overall = Math.round(
|
||||
Object.values(breakdown).reduce((sum, score) => sum + score, 0) / 10
|
||||
);
|
||||
|
||||
const issues = this.identifyIssues(content, options);
|
||||
const suggestions = this.generateSuggestions(breakdown, options);
|
||||
|
||||
return { overall, breakdown, issues, suggestions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate optimized meta tags
|
||||
*/
|
||||
generateMeta(
|
||||
content: string,
|
||||
targetKeyword: string,
|
||||
options?: { brandName?: string }
|
||||
): OptimizedMeta {
|
||||
const firstParagraph = content.split('\n\n')[0] || content.substring(0, 200);
|
||||
const brand = options?.brandName || '';
|
||||
|
||||
// Generate title variations
|
||||
const title = this.generateTitle(targetKeyword, brand);
|
||||
const description = this.generateDescription(firstParagraph, targetKeyword);
|
||||
const keywords = this.extractKeywords(content, targetKeyword);
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
ogTitle: title,
|
||||
ogDescription: description,
|
||||
twitterTitle: this.truncate(title, 70),
|
||||
twitterDescription: this.truncate(description, 200),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize content for target keyword
|
||||
*/
|
||||
optimize(
|
||||
content: string,
|
||||
targetKeyword: string,
|
||||
): {
|
||||
optimizedContent: string;
|
||||
changes: { type: string; before: string; after: string }[];
|
||||
newScore: number;
|
||||
} {
|
||||
let optimizedContent = content;
|
||||
const changes: { type: string; before: string; after: string }[] = [];
|
||||
|
||||
// Check keyword in first paragraph
|
||||
const firstPara = content.split('\n\n')[0];
|
||||
if (firstPara && !firstPara.toLowerCase().includes(targetKeyword.toLowerCase())) {
|
||||
const newFirstPara = `${targetKeyword} is essential. ${firstPara}`;
|
||||
optimizedContent = optimizedContent.replace(firstPara, newFirstPara);
|
||||
changes.push({
|
||||
type: 'keyword_placement',
|
||||
before: firstPara.substring(0, 50),
|
||||
after: newFirstPara.substring(0, 50),
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate new score
|
||||
const newScore = this.analyze(optimizedContent, { targetKeyword }).overall;
|
||||
|
||||
return { optimizedContent, changes, newScore };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate SEO-optimized title variations
|
||||
*/
|
||||
generateTitleVariations(keyword: string, count: number = 5): string[] {
|
||||
const templates = [
|
||||
`${keyword}: The Complete Guide for 2024`,
|
||||
`How to Master ${keyword} in 7 Steps`,
|
||||
`${keyword} 101: Everything You Need to Know`,
|
||||
`The Ultimate ${keyword} Guide (Updated 2024)`,
|
||||
`${keyword}: Tips, Tricks & Best Practices`,
|
||||
`Why ${keyword} Matters + How to Get Started`,
|
||||
`${keyword} Secrets: What Experts Don't Tell You`,
|
||||
`${keyword} Made Simple: A Beginner's Guide`,
|
||||
`10 ${keyword} Strategies That Actually Work`,
|
||||
`The Science Behind Effective ${keyword}`,
|
||||
];
|
||||
|
||||
return templates.slice(0, count).map((t) => this.truncate(t, 60));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate meta description variations
|
||||
*/
|
||||
generateDescriptionVariations(
|
||||
keyword: string,
|
||||
content: string,
|
||||
count: number = 3,
|
||||
): string[] {
|
||||
const templates = [
|
||||
`Discover how ${keyword} can transform your approach. Learn proven strategies, tips, and best practices in this comprehensive guide.`,
|
||||
`Looking to improve your ${keyword}? This guide covers everything from basics to advanced techniques. Start mastering ${keyword} today.`,
|
||||
`${keyword} doesn't have to be complicated. Our step-by-step guide breaks down everything you need to know for success.`,
|
||||
];
|
||||
|
||||
return templates.slice(0, count).map((t) => this.truncate(t, 160));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check content for keyword cannibalization risk
|
||||
*/
|
||||
checkCannibalization(
|
||||
newKeyword: string,
|
||||
existingKeywords: string[],
|
||||
): { risk: 'low' | 'medium' | 'high'; conflictingKeywords: string[] } {
|
||||
const conflicts: string[] = [];
|
||||
const newWords = new Set(newKeyword.toLowerCase().split(' '));
|
||||
|
||||
for (const existing of existingKeywords) {
|
||||
const existingWords = new Set(existing.toLowerCase().split(' '));
|
||||
const overlap = [...newWords].filter((w) => existingWords.has(w));
|
||||
|
||||
if (overlap.length / newWords.size > 0.5) {
|
||||
conflicts.push(existing);
|
||||
}
|
||||
}
|
||||
|
||||
let risk: 'low' | 'medium' | 'high' = 'low';
|
||||
if (conflicts.length > 2) risk = 'high';
|
||||
else if (conflicts.length > 0) risk = 'medium';
|
||||
|
||||
return { risk, conflictingKeywords: conflicts };
|
||||
}
|
||||
|
||||
// Private scoring methods
|
||||
|
||||
private scoreTitleOptimization(title?: string, keyword?: string): number {
|
||||
if (!title) return 0;
|
||||
let score = 50;
|
||||
|
||||
const length = title.length;
|
||||
if (length >= this.optimalParams.titleLength.min &&
|
||||
length <= this.optimalParams.titleLength.max) {
|
||||
score += 25;
|
||||
} else if (length < this.optimalParams.titleLength.min) {
|
||||
score += 10;
|
||||
}
|
||||
|
||||
if (keyword && title.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
score += 25;
|
||||
}
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private scoreMetaDescription(description?: string, keyword?: string): number {
|
||||
if (!description) return 0;
|
||||
let score = 50;
|
||||
|
||||
const length = description.length;
|
||||
if (length >= this.optimalParams.metaDescLength.min &&
|
||||
length <= this.optimalParams.metaDescLength.max) {
|
||||
score += 25;
|
||||
}
|
||||
|
||||
if (keyword && description.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
score += 25;
|
||||
}
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private scoreHeadingStructure(content: string): number {
|
||||
let score = 50;
|
||||
const h1Count = (content.match(/^#\s/gm) || []).length;
|
||||
const h2Count = (content.match(/^##\s/gm) || []).length;
|
||||
|
||||
if (h1Count === 1) score += 25;
|
||||
if (h2Count >= 2) score += 25;
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private scoreKeywordDensity(content: string, keyword?: string): number {
|
||||
if (!keyword) return 50;
|
||||
|
||||
const words = content.split(/\s+/).length;
|
||||
const kwCount = (content.toLowerCase().match(new RegExp(keyword.toLowerCase(), 'g')) || []).length;
|
||||
const density = (kwCount / words) * 100;
|
||||
|
||||
if (density >= this.optimalParams.keywordDensity.min &&
|
||||
density <= this.optimalParams.keywordDensity.max) {
|
||||
return 100;
|
||||
}
|
||||
if (density < this.optimalParams.keywordDensity.min) {
|
||||
return 40;
|
||||
}
|
||||
return 60; // Over-optimized
|
||||
}
|
||||
|
||||
private scoreContentLength(content: string): number {
|
||||
const words = content.split(/\s+/).length;
|
||||
|
||||
if (words >= this.optimalParams.contentWords.min &&
|
||||
words <= this.optimalParams.contentWords.max) {
|
||||
return 100;
|
||||
}
|
||||
if (words < this.optimalParams.contentWords.min) {
|
||||
return Math.floor((words / this.optimalParams.contentWords.min) * 80);
|
||||
}
|
||||
return 80; // Very long content
|
||||
}
|
||||
|
||||
private scoreReadability(content: string): number {
|
||||
const sentences = content.split(/[.!?]+/).filter(Boolean);
|
||||
const avgLength = sentences.reduce((sum, s) => sum + s.split(/\s+/).length, 0) / sentences.length;
|
||||
|
||||
if (avgLength <= this.optimalParams.sentenceLength.max) {
|
||||
return 100;
|
||||
}
|
||||
return Math.max(50, 100 - (avgLength - this.optimalParams.sentenceLength.max) * 3);
|
||||
}
|
||||
|
||||
private scoreInternalLinks(content: string): number {
|
||||
const internalLinks = (content.match(/\[.*?\]\(\/.*?\)/g) || []).length;
|
||||
if (internalLinks >= 3) return 100;
|
||||
if (internalLinks >= 1) return 70;
|
||||
return 30;
|
||||
}
|
||||
|
||||
private scoreExternalLinks(content: string): number {
|
||||
const externalLinks = (content.match(/\[.*?\]\(https?:\/\/.*?\)/g) || []).length;
|
||||
if (externalLinks >= 2) return 100;
|
||||
if (externalLinks >= 1) return 70;
|
||||
return 50;
|
||||
}
|
||||
|
||||
private scoreImageOptimization(content: string): number {
|
||||
const images = content.match(/!\[.*?\]\(.*?\)/g) || [];
|
||||
if (images.length === 0) return 50;
|
||||
|
||||
const withAlt = images.filter((img) => !/!\[\]/.test(img)).length;
|
||||
return Math.floor((withAlt / images.length) * 100);
|
||||
}
|
||||
|
||||
private scoreUrlStructure(url?: string): number {
|
||||
if (!url) return 50;
|
||||
let score = 50;
|
||||
|
||||
if (url.length < 75) score += 20;
|
||||
if (!url.includes('?')) score += 15;
|
||||
if (url === url.toLowerCase()) score += 15;
|
||||
|
||||
return Math.min(score, 100);
|
||||
}
|
||||
|
||||
private identifyIssues(content: string, options?: {
|
||||
targetKeyword?: string;
|
||||
title?: string;
|
||||
metaDescription?: string;
|
||||
}): SeoIssue[] {
|
||||
const issues: SeoIssue[] = [];
|
||||
|
||||
if (!options?.title) {
|
||||
issues.push({
|
||||
type: 'error',
|
||||
category: 'title',
|
||||
message: 'Missing title tag',
|
||||
fix: 'Add a compelling title with your target keyword',
|
||||
});
|
||||
}
|
||||
|
||||
if (!options?.metaDescription) {
|
||||
issues.push({
|
||||
type: 'error',
|
||||
category: 'meta',
|
||||
message: 'Missing meta description',
|
||||
fix: 'Add a meta description between 150-160 characters',
|
||||
});
|
||||
}
|
||||
|
||||
const words = content.split(/\s+/).length;
|
||||
if (words < 300) {
|
||||
issues.push({
|
||||
type: 'warning',
|
||||
category: 'content',
|
||||
message: 'Content is too short',
|
||||
fix: 'Aim for at least 1000 words for better SEO',
|
||||
});
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
private generateSuggestions(
|
||||
breakdown: SeoScore['breakdown'],
|
||||
options?: { targetKeyword?: string },
|
||||
): SeoSuggestion[] {
|
||||
const suggestions: SeoSuggestion[] = [];
|
||||
|
||||
if (breakdown.keywordDensity < 70 && options?.targetKeyword) {
|
||||
suggestions.push({
|
||||
priority: 'high',
|
||||
category: 'keywords',
|
||||
suggestion: `Increase usage of "${options.targetKeyword}" in your content`,
|
||||
impact: 'Better keyword relevance signals to search engines',
|
||||
});
|
||||
}
|
||||
|
||||
if (breakdown.contentLength < 70) {
|
||||
suggestions.push({
|
||||
priority: 'medium',
|
||||
category: 'content',
|
||||
suggestion: 'Expand your content with more detailed information',
|
||||
impact: 'Longer, comprehensive content typically ranks better',
|
||||
});
|
||||
}
|
||||
|
||||
if (breakdown.internalLinks < 70) {
|
||||
suggestions.push({
|
||||
priority: 'medium',
|
||||
category: 'links',
|
||||
suggestion: 'Add more internal links to related content',
|
||||
impact: 'Improves site structure and helps with crawling',
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
private generateTitle(keyword: string, brand: string): string {
|
||||
const title = `${keyword}: Complete Guide & Best Practices${brand ? ` | ${brand}` : ''}`;
|
||||
return this.truncate(title, 60);
|
||||
}
|
||||
|
||||
private generateDescription(firstParagraph: string, keyword: string): string {
|
||||
let desc = firstParagraph.substring(0, 150);
|
||||
if (!desc.toLowerCase().includes(keyword.toLowerCase())) {
|
||||
desc = `${keyword} - ${desc}`;
|
||||
}
|
||||
return this.truncate(desc, 160);
|
||||
}
|
||||
|
||||
private extractKeywords(content: string, targetKeyword: string): string[] {
|
||||
const words = content.toLowerCase().split(/\s+/);
|
||||
const frequency: Map<string, number> = new Map();
|
||||
const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'to', 'of', 'in', 'for', 'on', 'with']);
|
||||
|
||||
for (const word of words) {
|
||||
const clean = word.replace(/[^a-z]/g, '');
|
||||
if (clean.length > 3 && !stopWords.has(clean)) {
|
||||
frequency.set(clean, (frequency.get(clean) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = [...frequency.entries()].sort((a, b) => b[1] - a[1]);
|
||||
const keywords = sorted.slice(0, 10).map(([word]) => word);
|
||||
|
||||
if (!keywords.includes(targetKeyword.toLowerCase())) {
|
||||
keywords.unshift(targetKeyword.toLowerCase());
|
||||
}
|
||||
|
||||
return keywords;
|
||||
}
|
||||
|
||||
private truncate(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength - 3) + '...';
|
||||
}
|
||||
}
|
||||
379
src/modules/seo/services/keyword-research.service.ts
Normal file
379
src/modules/seo/services/keyword-research.service.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
// Keyword Research Service - Keyword discovery and analysis
|
||||
// Path: src/modules/seo/services/keyword-research.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface Keyword {
|
||||
term: string;
|
||||
volume: number; // Monthly search volume (estimated)
|
||||
difficulty: number; // 0-100
|
||||
cpc: number; // Cost per click estimate
|
||||
trend: 'rising' | 'stable' | 'declining';
|
||||
intent: 'informational' | 'navigational' | 'transactional' | 'commercial';
|
||||
related: string[];
|
||||
questions: string[];
|
||||
}
|
||||
|
||||
export interface KeywordCluster {
|
||||
mainKeyword: string;
|
||||
keywords: Keyword[];
|
||||
totalVolume: number;
|
||||
avgDifficulty: number;
|
||||
contentSuggestions: string[];
|
||||
}
|
||||
|
||||
export interface LongTailSuggestion {
|
||||
keyword: string;
|
||||
baseKeyword: string;
|
||||
modifier: string;
|
||||
estimatedVolume: number;
|
||||
competitionLevel: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class KeywordResearchService {
|
||||
private readonly logger = new Logger(KeywordResearchService.name);
|
||||
|
||||
// Common keyword modifiers for long-tail generation
|
||||
private readonly modifiers = {
|
||||
prefix: [
|
||||
'how to', 'what is', 'why', 'when to', 'where to', 'who uses',
|
||||
'best', 'top', 'free', 'cheap', 'affordable', 'premium',
|
||||
'easy', 'quick', 'simple', 'ultimate', 'complete', 'beginner',
|
||||
'advanced', 'professional', 'expert', 'step by step',
|
||||
],
|
||||
suffix: [
|
||||
'guide', 'tutorial', 'tips', 'tricks', 'examples', 'templates',
|
||||
'tools', 'software', 'apps', 'services', 'strategies', 'techniques',
|
||||
'for beginners', 'for experts', 'in 2024', 'vs', 'alternatives',
|
||||
'review', 'comparison', 'checklist', 'resources', 'ideas',
|
||||
],
|
||||
questions: [
|
||||
'what is', 'how to', 'why should', 'when to use', 'where can I find',
|
||||
'who needs', 'which is best', 'can you', 'should I', 'does',
|
||||
'is it worth', 'how much does', 'how long does', 'what are the benefits',
|
||||
],
|
||||
};
|
||||
|
||||
// Content type keywords
|
||||
private readonly contentTypeKeywords = {
|
||||
blog: ['guide', 'how to', 'tips', 'best practices', 'examples'],
|
||||
video: ['tutorial', 'walkthrough', 'demo', 'explained', 'in action'],
|
||||
product: ['buy', 'price', 'discount', 'coupon', 'review'],
|
||||
local: ['near me', 'in [city]', 'local', 'best [city]'],
|
||||
comparison: ['vs', 'comparison', 'alternatives', 'versus', 'or'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate keyword suggestions for a topic
|
||||
*/
|
||||
async suggestKeywords(topic: string, options?: {
|
||||
count?: number;
|
||||
includeQuestions?: boolean;
|
||||
includeLongTail?: boolean;
|
||||
}): Promise<{
|
||||
main: Keyword;
|
||||
related: Keyword[];
|
||||
questions: string[];
|
||||
longTail: LongTailSuggestion[];
|
||||
}> {
|
||||
const count = options?.count || 20;
|
||||
|
||||
// Generate main keyword data
|
||||
const main: Keyword = {
|
||||
term: topic.toLowerCase(),
|
||||
volume: this.estimateVolume(topic),
|
||||
difficulty: this.estimateDifficulty(topic),
|
||||
cpc: this.estimateCPC(topic),
|
||||
trend: 'stable',
|
||||
intent: this.detectIntent(topic),
|
||||
related: this.generateRelatedTerms(topic, 5),
|
||||
questions: this.generateQuestions(topic, 5),
|
||||
};
|
||||
|
||||
// Generate related keywords
|
||||
const related = this.generateRelatedKeywords(topic, count);
|
||||
|
||||
// Generate question-based keywords
|
||||
const questions = options?.includeQuestions !== false
|
||||
? this.generateQuestions(topic, 10)
|
||||
: [];
|
||||
|
||||
// Generate long-tail variations
|
||||
const longTail = options?.includeLongTail !== false
|
||||
? this.generateLongTail(topic, 15)
|
||||
: [];
|
||||
|
||||
return { main, related, questions, longTail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate long-tail keyword variations
|
||||
*/
|
||||
generateLongTail(baseKeyword: string, count: number = 20): LongTailSuggestion[] {
|
||||
const suggestions: LongTailSuggestion[] = [];
|
||||
const base = baseKeyword.toLowerCase();
|
||||
|
||||
// Add prefix modifiers
|
||||
for (const prefix of this.modifiers.prefix.slice(0, Math.ceil(count / 3))) {
|
||||
suggestions.push({
|
||||
keyword: `${prefix} ${base}`,
|
||||
baseKeyword: base,
|
||||
modifier: prefix,
|
||||
estimatedVolume: Math.floor(this.estimateVolume(base) * 0.1 * Math.random()),
|
||||
competitionLevel: this.estimateCompetition(),
|
||||
});
|
||||
}
|
||||
|
||||
// Add suffix modifiers
|
||||
for (const suffix of this.modifiers.suffix.slice(0, Math.ceil(count / 3))) {
|
||||
suggestions.push({
|
||||
keyword: `${base} ${suffix}`,
|
||||
baseKeyword: base,
|
||||
modifier: suffix,
|
||||
estimatedVolume: Math.floor(this.estimateVolume(base) * 0.1 * Math.random()),
|
||||
competitionLevel: this.estimateCompetition(),
|
||||
});
|
||||
}
|
||||
|
||||
// Add question modifiers
|
||||
for (const question of this.modifiers.questions.slice(0, Math.ceil(count / 3))) {
|
||||
suggestions.push({
|
||||
keyword: `${question} ${base}`,
|
||||
baseKeyword: base,
|
||||
modifier: question,
|
||||
estimatedVolume: Math.floor(this.estimateVolume(base) * 0.15 * Math.random()),
|
||||
competitionLevel: 'low',
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster keywords by topic similarity
|
||||
*/
|
||||
clusterKeywords(keywords: string[]): KeywordCluster[] {
|
||||
// Group similar keywords into clusters
|
||||
const clusters: Map<string, Keyword[]> = new Map();
|
||||
|
||||
for (const kw of keywords) {
|
||||
const mainWord = this.extractMainWord(kw);
|
||||
const existing = clusters.get(mainWord) || [];
|
||||
existing.push({
|
||||
term: kw,
|
||||
volume: this.estimateVolume(kw),
|
||||
difficulty: this.estimateDifficulty(kw),
|
||||
cpc: this.estimateCPC(kw),
|
||||
trend: 'stable',
|
||||
intent: this.detectIntent(kw),
|
||||
related: [],
|
||||
questions: [],
|
||||
});
|
||||
clusters.set(mainWord, existing);
|
||||
}
|
||||
|
||||
return Array.from(clusters.entries()).map(([mainKeyword, kws]) => ({
|
||||
mainKeyword,
|
||||
keywords: kws,
|
||||
totalVolume: kws.reduce((sum, k) => sum + k.volume, 0),
|
||||
avgDifficulty: kws.reduce((sum, k) => sum + k.difficulty, 0) / kws.length,
|
||||
contentSuggestions: this.generateContentSuggestions(mainKeyword),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate LSI (Latent Semantic Indexing) keywords
|
||||
*/
|
||||
generateLSIKeywords(keyword: string, count: number = 10): string[] {
|
||||
const words = keyword.toLowerCase().split(' ');
|
||||
const lsiTerms: string[] = [];
|
||||
|
||||
// Add synonyms and related concepts
|
||||
const synonymMap: Record<string, string[]> = {
|
||||
content: ['posts', 'articles', 'material', 'copy', 'text'],
|
||||
marketing: ['promotion', 'advertising', 'branding', 'outreach'],
|
||||
social: ['community', 'network', 'platform', 'engagement'],
|
||||
media: ['content', 'posts', 'videos', 'images', 'stories'],
|
||||
strategy: ['plan', 'approach', 'tactics', 'framework', 'method'],
|
||||
growth: ['scaling', 'expansion', 'increase', 'improvement'],
|
||||
engagement: ['interaction', 'response', 'participation', 'activity'],
|
||||
audience: ['followers', 'community', 'readers', 'subscribers'],
|
||||
};
|
||||
|
||||
for (const word of words) {
|
||||
const synonyms = synonymMap[word] || [];
|
||||
lsiTerms.push(...synonyms);
|
||||
}
|
||||
|
||||
// Add related terms
|
||||
lsiTerms.push(...this.generateRelatedTerms(keyword, 5));
|
||||
|
||||
return [...new Set(lsiTerms)].slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze keyword difficulty
|
||||
*/
|
||||
analyzeKeywordDifficulty(keyword: string): {
|
||||
score: number;
|
||||
factors: { name: string; impact: 'positive' | 'negative'; description: string }[];
|
||||
recommendation: string;
|
||||
} {
|
||||
const difficulty = this.estimateDifficulty(keyword);
|
||||
const factors: { name: string; impact: 'positive' | 'negative'; description: string }[] = [];
|
||||
|
||||
// Word count factor
|
||||
const wordCount = keyword.split(' ').length;
|
||||
if (wordCount >= 4) {
|
||||
factors.push({
|
||||
name: 'Long-tail keyword',
|
||||
impact: 'positive',
|
||||
description: 'Longer keywords typically have lower competition',
|
||||
});
|
||||
} else if (wordCount === 1) {
|
||||
factors.push({
|
||||
name: 'Single word',
|
||||
impact: 'negative',
|
||||
description: 'Single-word keywords are highly competitive',
|
||||
});
|
||||
}
|
||||
|
||||
// Question format
|
||||
if (this.modifiers.questions.some((q) => keyword.toLowerCase().startsWith(q))) {
|
||||
factors.push({
|
||||
name: 'Question format',
|
||||
impact: 'positive',
|
||||
description: 'Question-based keywords often have lower competition',
|
||||
});
|
||||
}
|
||||
|
||||
// Commercial intent
|
||||
const commercialTerms = ['buy', 'price', 'discount', 'deal', 'sale'];
|
||||
if (commercialTerms.some((t) => keyword.toLowerCase().includes(t))) {
|
||||
factors.push({
|
||||
name: 'Commercial intent',
|
||||
impact: 'negative',
|
||||
description: 'Commercial keywords have higher competition',
|
||||
});
|
||||
}
|
||||
|
||||
let recommendation: string;
|
||||
if (difficulty < 30) {
|
||||
recommendation = 'Great opportunity! This keyword has low competition.';
|
||||
} else if (difficulty < 60) {
|
||||
recommendation = 'Moderate competition. Focus on high-quality content.';
|
||||
} else {
|
||||
recommendation = 'High competition. Consider targeting long-tail variations.';
|
||||
}
|
||||
|
||||
return { score: difficulty, factors, recommendation };
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private generateRelatedKeywords(topic: string, count: number): Keyword[] {
|
||||
const related = this.generateRelatedTerms(topic, count);
|
||||
return related.map((term) => ({
|
||||
term,
|
||||
volume: this.estimateVolume(term),
|
||||
difficulty: this.estimateDifficulty(term),
|
||||
cpc: this.estimateCPC(term),
|
||||
trend: 'stable' as const,
|
||||
intent: this.detectIntent(term),
|
||||
related: [],
|
||||
questions: [],
|
||||
}));
|
||||
}
|
||||
|
||||
private generateRelatedTerms(topic: string, count: number): string[] {
|
||||
const base = topic.toLowerCase();
|
||||
const related: string[] = [];
|
||||
|
||||
// Add common variations
|
||||
related.push(`${base} tips`);
|
||||
related.push(`${base} guide`);
|
||||
related.push(`best ${base}`);
|
||||
related.push(`${base} strategies`);
|
||||
related.push(`${base} examples`);
|
||||
related.push(`${base} tools`);
|
||||
related.push(`${base} for beginners`);
|
||||
related.push(`advanced ${base}`);
|
||||
related.push(`${base} best practices`);
|
||||
related.push(`${base} trends`);
|
||||
|
||||
return related.slice(0, count);
|
||||
}
|
||||
|
||||
private generateQuestions(topic: string, count: number): string[] {
|
||||
const base = topic.toLowerCase();
|
||||
return [
|
||||
`What is ${base}?`,
|
||||
`How to use ${base}?`,
|
||||
`Why is ${base} important?`,
|
||||
`When should I use ${base}?`,
|
||||
`Where can I learn ${base}?`,
|
||||
`Who needs ${base}?`,
|
||||
`What are the benefits of ${base}?`,
|
||||
`How does ${base} work?`,
|
||||
`Is ${base} worth it?`,
|
||||
`What are ${base} best practices?`,
|
||||
].slice(0, count);
|
||||
}
|
||||
|
||||
private generateContentSuggestions(keyword: string): string[] {
|
||||
return [
|
||||
`Ultimate Guide to ${keyword}`,
|
||||
`${keyword}: Everything You Need to Know`,
|
||||
`10 ${keyword} Tips for Beginners`,
|
||||
`How to Master ${keyword} in 2024`,
|
||||
`${keyword} vs Alternatives: Complete Comparison`,
|
||||
];
|
||||
}
|
||||
|
||||
private estimateVolume(keyword: string): number {
|
||||
// Simple estimation based on keyword characteristics
|
||||
const wordCount = keyword.split(' ').length;
|
||||
const baseVolume = 10000;
|
||||
return Math.floor(baseVolume / Math.pow(wordCount, 1.5) * (Math.random() + 0.5));
|
||||
}
|
||||
|
||||
private estimateDifficulty(keyword: string): number {
|
||||
const wordCount = keyword.split(' ').length;
|
||||
const base = 70 - (wordCount * 10);
|
||||
return Math.min(95, Math.max(5, base + Math.floor(Math.random() * 20)));
|
||||
}
|
||||
|
||||
private estimateCPC(keyword: string): number {
|
||||
return Math.round((Math.random() * 5 + 0.5) * 100) / 100;
|
||||
}
|
||||
|
||||
private estimateCompetition(): 'low' | 'medium' | 'high' {
|
||||
const rand = Math.random();
|
||||
if (rand < 0.4) return 'low';
|
||||
if (rand < 0.7) return 'medium';
|
||||
return 'high';
|
||||
}
|
||||
|
||||
private detectIntent(keyword: string): 'informational' | 'navigational' | 'transactional' | 'commercial' {
|
||||
const kw = keyword.toLowerCase();
|
||||
|
||||
if (['buy', 'price', 'discount', 'order', 'purchase'].some((t) => kw.includes(t))) {
|
||||
return 'transactional';
|
||||
}
|
||||
if (['best', 'top', 'review', 'comparison', 'vs'].some((t) => kw.includes(t))) {
|
||||
return 'commercial';
|
||||
}
|
||||
if (['login', 'sign in', 'website', 'official'].some((t) => kw.includes(t))) {
|
||||
return 'navigational';
|
||||
}
|
||||
return 'informational';
|
||||
}
|
||||
|
||||
private extractMainWord(keyword: string): string {
|
||||
const stopWords = ['how', 'to', 'what', 'is', 'the', 'a', 'an', 'for', 'in', 'of', 'and', 'or'];
|
||||
const words = keyword.toLowerCase().split(' ');
|
||||
const mainWords = words.filter((w) => !stopWords.includes(w));
|
||||
return mainWords[0] || words[0] || keyword;
|
||||
}
|
||||
}
|
||||
14
src/modules/social-integration/index.ts
Normal file
14
src/modules/social-integration/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Social Integration Module - Index exports
|
||||
// Path: src/modules/social-integration/index.ts
|
||||
|
||||
export * from './social-integration.module';
|
||||
export * from './social-integration.service';
|
||||
export * from './social-integration.controller';
|
||||
export * from './services/oauth.service';
|
||||
export * from './services/twitter-api.service';
|
||||
export * from './services/instagram-api.service';
|
||||
export * from './services/linkedin-api.service';
|
||||
export * from './services/facebook-api.service';
|
||||
export * from './services/tiktok-api.service';
|
||||
export * from './services/youtube-api.service';
|
||||
export * from './services/auto-publish.service';
|
||||
424
src/modules/social-integration/services/auto-publish.service.ts
Normal file
424
src/modules/social-integration/services/auto-publish.service.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
// Auto Publish Service - Unified posting interface
|
||||
// Path: src/modules/social-integration/services/auto-publish.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OAuthService, SocialPlatform, ConnectedAccount } from './oauth.service';
|
||||
import { TwitterApiService } from './twitter-api.service';
|
||||
import { InstagramApiService } from './instagram-api.service';
|
||||
import { LinkedInApiService } from './linkedin-api.service';
|
||||
import { FacebookApiService } from './facebook-api.service';
|
||||
import { TikTokApiService } from './tiktok-api.service';
|
||||
import { YouTubeApiService } from './youtube-api.service';
|
||||
|
||||
export interface PublishRequest {
|
||||
userId: string;
|
||||
platforms: SocialPlatform[];
|
||||
content: UnifiedContent;
|
||||
scheduledTime?: Date;
|
||||
queuePosition?: number;
|
||||
}
|
||||
|
||||
export interface UnifiedContent {
|
||||
text: string;
|
||||
mediaUrls?: string[];
|
||||
mediaType?: 'image' | 'video';
|
||||
link?: string;
|
||||
thumbnail?: string;
|
||||
hashtags?: string[];
|
||||
mentions?: string[];
|
||||
title?: string; // For YouTube
|
||||
description?: string; // For YouTube
|
||||
}
|
||||
|
||||
export interface PublishResult {
|
||||
platform: SocialPlatform;
|
||||
success: boolean;
|
||||
postId?: string;
|
||||
postUrl?: string;
|
||||
error?: string;
|
||||
publishedAt?: Date;
|
||||
}
|
||||
|
||||
export interface QueuedPost {
|
||||
id: string;
|
||||
userId: string;
|
||||
platform: SocialPlatform;
|
||||
content: UnifiedContent;
|
||||
scheduledTime: Date;
|
||||
status: 'pending' | 'publishing' | 'published' | 'failed';
|
||||
retryCount: number;
|
||||
result?: PublishResult;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface PublishStats {
|
||||
totalPosts: number;
|
||||
successfulPosts: number;
|
||||
failedPosts: number;
|
||||
platformBreakdown: Record<SocialPlatform, {
|
||||
total: number;
|
||||
successful: number;
|
||||
failed: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AutoPublishService {
|
||||
private readonly logger = new Logger(AutoPublishService.name);
|
||||
private queue: Map<string, QueuedPost[]> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly oauthService: OAuthService,
|
||||
private readonly twitterApi: TwitterApiService,
|
||||
private readonly instagramApi: InstagramApiService,
|
||||
private readonly linkedinApi: LinkedInApiService,
|
||||
private readonly facebookApi: FacebookApiService,
|
||||
private readonly tiktokApi: TikTokApiService,
|
||||
private readonly youtubeApi: YouTubeApiService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Publish to multiple platforms at once
|
||||
*/
|
||||
async publishToMultiplePlatforms(request: PublishRequest): Promise<PublishResult[]> {
|
||||
const results: PublishResult[] = [];
|
||||
|
||||
for (const platform of request.platforms) {
|
||||
try {
|
||||
const account = this.oauthService.getAccountByPlatform(request.userId, platform);
|
||||
if (!account) {
|
||||
results.push({
|
||||
platform,
|
||||
success: false,
|
||||
error: `No connected ${platform} account found`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const tokens = await this.oauthService.ensureValidToken(account);
|
||||
const adaptedContent = this.adaptContentForPlatform(request.content, platform);
|
||||
const result = await this.publishToPlatform(platform, tokens.accessToken, adaptedContent, account);
|
||||
results.push(result);
|
||||
} catch (error) {
|
||||
results.push({
|
||||
platform,
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a post for later
|
||||
*/
|
||||
schedulePost(request: PublishRequest): QueuedPost[] {
|
||||
const scheduled: QueuedPost[] = [];
|
||||
|
||||
for (const platform of request.platforms) {
|
||||
const post: QueuedPost = {
|
||||
id: `queue-${Date.now()}-${platform}`,
|
||||
userId: request.userId,
|
||||
platform,
|
||||
content: this.adaptContentForPlatform(request.content, platform),
|
||||
scheduledTime: request.scheduledTime || new Date(),
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
const userQueue = this.queue.get(request.userId) || [];
|
||||
userQueue.push(post);
|
||||
this.queue.set(request.userId, userQueue);
|
||||
scheduled.push(post);
|
||||
}
|
||||
|
||||
return scheduled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's queue
|
||||
*/
|
||||
getQueue(userId: string): QueuedPost[] {
|
||||
return this.queue.get(userId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a scheduled post
|
||||
*/
|
||||
cancelScheduledPost(userId: string, postId: string): boolean {
|
||||
const userQueue = this.queue.get(userId) || [];
|
||||
const index = userQueue.findIndex((p) => p.id === postId);
|
||||
|
||||
if (index !== -1 && userQueue[index].status === 'pending') {
|
||||
userQueue.splice(index, 1);
|
||||
this.queue.set(userId, userQueue);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule a post
|
||||
*/
|
||||
reschedulePost(userId: string, postId: string, newTime: Date): boolean {
|
||||
const userQueue = this.queue.get(userId) || [];
|
||||
const post = userQueue.find((p) => p.id === postId);
|
||||
|
||||
if (post && post.status === 'pending') {
|
||||
post.scheduledTime = newTime;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get publishing statistics
|
||||
*/
|
||||
getPublishingStats(userId: string): PublishStats {
|
||||
const userQueue = this.queue.get(userId) || [];
|
||||
const stats: PublishStats = {
|
||||
totalPosts: userQueue.length,
|
||||
successfulPosts: userQueue.filter((p) => p.status === 'published').length,
|
||||
failedPosts: userQueue.filter((p) => p.status === 'failed').length,
|
||||
platformBreakdown: {} as any,
|
||||
};
|
||||
|
||||
const platforms: SocialPlatform[] = ['twitter', 'instagram', 'linkedin', 'facebook', 'tiktok', 'youtube'];
|
||||
for (const platform of platforms) {
|
||||
const platformPosts = userQueue.filter((p) => p.platform === platform);
|
||||
stats.platformBreakdown[platform] = {
|
||||
total: platformPosts.length,
|
||||
successful: platformPosts.filter((p) => p.status === 'published').length,
|
||||
failed: platformPosts.filter((p) => p.status === 'failed').length,
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimal posting schedule
|
||||
*/
|
||||
getOptimalSchedule(platforms: SocialPlatform[]): Record<SocialPlatform, {
|
||||
bestTimes: { day: string; time: string; engagement: string }[];
|
||||
}> {
|
||||
const schedule: any = {};
|
||||
|
||||
for (const platform of platforms) {
|
||||
switch (platform) {
|
||||
case 'twitter':
|
||||
schedule[platform] = {
|
||||
bestTimes: [
|
||||
{ day: 'Wednesday', time: '09:00', engagement: 'Peak' },
|
||||
{ day: 'Friday', time: '11:00', engagement: 'High' },
|
||||
],
|
||||
};
|
||||
break;
|
||||
case 'instagram':
|
||||
schedule[platform] = {
|
||||
bestTimes: [
|
||||
{ day: 'Tuesday', time: '11:00', engagement: 'Peak' },
|
||||
{ day: 'Wednesday', time: '14:00', engagement: 'High' },
|
||||
],
|
||||
};
|
||||
break;
|
||||
case 'linkedin':
|
||||
schedule[platform] = {
|
||||
bestTimes: [
|
||||
{ day: 'Tuesday', time: '10:00', engagement: 'Peak' },
|
||||
{ day: 'Wednesday', time: '12:00', engagement: 'High' },
|
||||
],
|
||||
};
|
||||
break;
|
||||
case 'tiktok':
|
||||
schedule[platform] = {
|
||||
bestTimes: [
|
||||
{ day: 'Thursday', time: '19:00', engagement: 'Peak' },
|
||||
{ day: 'Saturday', time: '20:00', engagement: 'Peak' },
|
||||
],
|
||||
};
|
||||
break;
|
||||
default:
|
||||
schedule[platform] = {
|
||||
bestTimes: [
|
||||
{ day: 'Wednesday', time: '12:00', engagement: 'Medium' },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview how content will look on each platform
|
||||
*/
|
||||
previewContent(content: UnifiedContent, platforms: SocialPlatform[]): Record<SocialPlatform, {
|
||||
adaptedContent: UnifiedContent;
|
||||
truncated: boolean;
|
||||
warnings: string[];
|
||||
}> {
|
||||
const previews: any = {};
|
||||
|
||||
for (const platform of platforms) {
|
||||
const adapted = this.adaptContentForPlatform(content, platform);
|
||||
const limits = this.getCharacterLimit(platform);
|
||||
|
||||
previews[platform] = {
|
||||
adaptedContent: adapted,
|
||||
truncated: content.text.length > limits,
|
||||
warnings: this.getContentWarnings(content, platform),
|
||||
};
|
||||
}
|
||||
|
||||
return previews;
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private async publishToPlatform(
|
||||
platform: SocialPlatform,
|
||||
accessToken: string,
|
||||
content: UnifiedContent,
|
||||
account: ConnectedAccount,
|
||||
): Promise<PublishResult> {
|
||||
try {
|
||||
let postId: string;
|
||||
let postUrl: string = '';
|
||||
|
||||
switch (platform) {
|
||||
case 'twitter':
|
||||
const tweet = await this.twitterApi.postTweet(accessToken, { text: content.text });
|
||||
postId = tweet.id;
|
||||
postUrl = `https://twitter.com/i/status/${postId}`;
|
||||
break;
|
||||
|
||||
case 'instagram':
|
||||
if (content.mediaUrls?.[0]) {
|
||||
const igPost = await this.instagramApi.createPost(accessToken, {
|
||||
mediaUrl: content.mediaUrls[0],
|
||||
mediaType: content.mediaType || 'image',
|
||||
caption: content.text,
|
||||
});
|
||||
postId = igPost.id;
|
||||
} else {
|
||||
throw new Error('Instagram requires media');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'linkedin':
|
||||
const liPost = await this.linkedinApi.createPost(accessToken, { text: content.text });
|
||||
postId = liPost.id;
|
||||
break;
|
||||
|
||||
case 'facebook':
|
||||
const fbPost = await this.facebookApi.createPost(accessToken, 'page-id', { message: content.text });
|
||||
postId = fbPost.id;
|
||||
break;
|
||||
|
||||
case 'tiktok':
|
||||
if (content.mediaUrls?.[0] && content.mediaType === 'video') {
|
||||
const tikTok = await this.tiktokApi.uploadVideo(accessToken, {
|
||||
videoUrl: content.mediaUrls[0],
|
||||
caption: content.text,
|
||||
});
|
||||
postId = tikTok.id;
|
||||
} else {
|
||||
throw new Error('TikTok requires video');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'youtube':
|
||||
if (content.mediaUrls?.[0] && content.mediaType === 'video') {
|
||||
const ytVideo = await this.youtubeApi.uploadVideo(accessToken, {
|
||||
videoUrl: content.mediaUrls[0],
|
||||
title: content.title || 'Untitled',
|
||||
description: content.description || content.text,
|
||||
});
|
||||
postId = ytVideo.id;
|
||||
postUrl = `https://youtube.com/watch?v=${postId}`;
|
||||
} else {
|
||||
throw new Error('YouTube requires video');
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
return {
|
||||
platform,
|
||||
success: true,
|
||||
postId,
|
||||
postUrl: postUrl || `https://${platform}.com/post/${postId}`,
|
||||
publishedAt: new Date(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
platform,
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private adaptContentForPlatform(content: UnifiedContent, platform: SocialPlatform): UnifiedContent {
|
||||
const limit = this.getCharacterLimit(platform);
|
||||
let text = content.text;
|
||||
|
||||
// Add hashtags if provided
|
||||
if (content.hashtags?.length) {
|
||||
const hashtagString = content.hashtags.map((h) => h.startsWith('#') ? h : `#${h}`).join(' ');
|
||||
|
||||
if (text.length + hashtagString.length + 2 <= limit) {
|
||||
text = `${text}\n\n${hashtagString}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Truncate if needed
|
||||
if (text.length > limit) {
|
||||
text = text.substring(0, limit - 3) + '...';
|
||||
}
|
||||
|
||||
return {
|
||||
...content,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
private getCharacterLimit(platform: SocialPlatform): number {
|
||||
const limits: Record<SocialPlatform, number> = {
|
||||
twitter: 280,
|
||||
instagram: 2200,
|
||||
linkedin: 3000,
|
||||
facebook: 63206,
|
||||
tiktok: 2200,
|
||||
youtube: 5000,
|
||||
threads: 500,
|
||||
pinterest: 500,
|
||||
};
|
||||
return limits[platform] || 280;
|
||||
}
|
||||
|
||||
private getContentWarnings(content: UnifiedContent, platform: SocialPlatform): string[] {
|
||||
const warnings: string[] = [];
|
||||
const limit = this.getCharacterLimit(platform);
|
||||
|
||||
if (content.text.length > limit) {
|
||||
warnings.push(`Content exceeds ${platform} character limit (${limit})`);
|
||||
}
|
||||
|
||||
if (['instagram', 'tiktok'].includes(platform) && !content.mediaUrls?.length) {
|
||||
warnings.push(`${platform} requires media (image or video)`);
|
||||
}
|
||||
|
||||
if (platform === 'youtube' && (!content.title || !content.description)) {
|
||||
warnings.push('YouTube videos require a title and description');
|
||||
}
|
||||
|
||||
return warnings;
|
||||
}
|
||||
}
|
||||
263
src/modules/social-integration/services/facebook-api.service.ts
Normal file
263
src/modules/social-integration/services/facebook-api.service.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
// Facebook API Service - Facebook pages and groups posting
|
||||
// Path: src/modules/social-integration/services/facebook-api.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface FacebookPost {
|
||||
id: string;
|
||||
pageId: string;
|
||||
type: 'text' | 'photo' | 'video' | 'link' | 'album' | 'reel' | 'story';
|
||||
message?: string;
|
||||
mediaUrls?: string[];
|
||||
link?: string;
|
||||
metrics?: FacebookMetrics;
|
||||
scheduledTime?: Date;
|
||||
isPublished: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface FacebookMetrics {
|
||||
likes: number;
|
||||
loves: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
reach: number;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
engagementRate: number;
|
||||
}
|
||||
|
||||
export interface FacebookPage {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
followers: number;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FacebookApiService {
|
||||
private readonly logger = new Logger(FacebookApiService.name);
|
||||
private readonly MAX_CHARS = 63206;
|
||||
|
||||
/**
|
||||
* Get user's pages
|
||||
*/
|
||||
async getPages(accessToken: string): Promise<FacebookPage[]> {
|
||||
// Mock pages data
|
||||
return [
|
||||
{
|
||||
id: 'page-1',
|
||||
name: 'My Business Page',
|
||||
category: 'Business',
|
||||
followers: 5000,
|
||||
accessToken: `page_token_${Date.now()}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a text post
|
||||
*/
|
||||
async createPost(
|
||||
pageAccessToken: string,
|
||||
pageId: string,
|
||||
content: {
|
||||
message: string;
|
||||
link?: string;
|
||||
scheduledTime?: Date;
|
||||
},
|
||||
): Promise<FacebookPost> {
|
||||
const post: FacebookPost = {
|
||||
id: `fb-post-${Date.now()}`,
|
||||
pageId,
|
||||
type: content.link ? 'link' : 'text',
|
||||
message: content.message,
|
||||
link: content.link,
|
||||
scheduledTime: content.scheduledTime,
|
||||
isPublished: !content.scheduledTime,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created Facebook post: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a photo post
|
||||
*/
|
||||
async createPhotoPost(
|
||||
pageAccessToken: string,
|
||||
pageId: string,
|
||||
content: {
|
||||
message?: string;
|
||||
photoUrls: string[];
|
||||
},
|
||||
): Promise<FacebookPost> {
|
||||
const post: FacebookPost = {
|
||||
id: `fb-photo-${Date.now()}`,
|
||||
pageId,
|
||||
type: content.photoUrls.length > 1 ? 'album' : 'photo',
|
||||
message: content.message,
|
||||
mediaUrls: content.photoUrls,
|
||||
isPublished: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created Facebook photo post: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a video post
|
||||
*/
|
||||
async createVideoPost(
|
||||
pageAccessToken: string,
|
||||
pageId: string,
|
||||
content: {
|
||||
message?: string;
|
||||
videoUrl: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
},
|
||||
): Promise<FacebookPost> {
|
||||
const post: FacebookPost = {
|
||||
id: `fb-video-${Date.now()}`,
|
||||
pageId,
|
||||
type: 'video',
|
||||
message: content.message,
|
||||
mediaUrls: [content.videoUrl],
|
||||
isPublished: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created Facebook video post: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Reel
|
||||
*/
|
||||
async createReel(
|
||||
pageAccessToken: string,
|
||||
pageId: string,
|
||||
content: {
|
||||
videoUrl: string;
|
||||
description?: string;
|
||||
},
|
||||
): Promise<FacebookPost> {
|
||||
const post: FacebookPost = {
|
||||
id: `fb-reel-${Date.now()}`,
|
||||
pageId,
|
||||
type: 'reel',
|
||||
message: content.description,
|
||||
mediaUrls: [content.videoUrl],
|
||||
isPublished: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created Facebook Reel: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Story
|
||||
*/
|
||||
async createStory(
|
||||
pageAccessToken: string,
|
||||
pageId: string,
|
||||
content: {
|
||||
mediaUrl: string;
|
||||
mediaType: 'image' | 'video';
|
||||
},
|
||||
): Promise<FacebookPost> {
|
||||
const post: FacebookPost = {
|
||||
id: `fb-story-${Date.now()}`,
|
||||
pageId,
|
||||
type: 'story',
|
||||
mediaUrls: [content.mediaUrl],
|
||||
isPublished: true,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created Facebook Story: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a post
|
||||
*/
|
||||
async schedulePost(
|
||||
pageAccessToken: string,
|
||||
pageId: string,
|
||||
content: {
|
||||
message: string;
|
||||
scheduledTime: Date;
|
||||
mediaUrls?: string[];
|
||||
},
|
||||
): Promise<FacebookPost> {
|
||||
const post: FacebookPost = {
|
||||
id: `fb-scheduled-${Date.now()}`,
|
||||
pageId,
|
||||
type: content.mediaUrls ? 'photo' : 'text',
|
||||
message: content.message,
|
||||
mediaUrls: content.mediaUrls,
|
||||
scheduledTime: content.scheduledTime,
|
||||
isPublished: false,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Scheduled Facebook post: ${post.id} for ${content.scheduledTime}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post metrics
|
||||
*/
|
||||
async getPostMetrics(pageAccessToken: string, postId: string): Promise<FacebookMetrics> {
|
||||
return {
|
||||
likes: Math.floor(Math.random() * 1000),
|
||||
loves: Math.floor(Math.random() * 100),
|
||||
comments: Math.floor(Math.random() * 50),
|
||||
shares: Math.floor(Math.random() * 30),
|
||||
reach: Math.floor(Math.random() * 5000),
|
||||
impressions: Math.floor(Math.random() * 10000),
|
||||
clicks: Math.floor(Math.random() * 200),
|
||||
engagementRate: Math.random() * 5,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a post
|
||||
*/
|
||||
async deletePost(pageAccessToken: string, postId: string): Promise<boolean> {
|
||||
this.logger.log(`Deleted Facebook post: ${postId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get constraints
|
||||
*/
|
||||
getConstraints(): {
|
||||
maxChars: number;
|
||||
maxPhotos: number;
|
||||
maxVideoLength: string;
|
||||
formats: { image: string[]; video: string[] };
|
||||
aspectRatios: { feed: string[]; reel: string; story: string };
|
||||
} {
|
||||
return {
|
||||
maxChars: this.MAX_CHARS,
|
||||
maxPhotos: 10,
|
||||
maxVideoLength: '240:00',
|
||||
formats: {
|
||||
image: ['jpg', 'png', 'gif', 'bmp', 'tiff'],
|
||||
video: ['mp4', 'mov', 'avi'],
|
||||
},
|
||||
aspectRatios: {
|
||||
feed: ['1:1', '4:5', '16:9', '9:16'],
|
||||
reel: '9:16',
|
||||
story: '9:16',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
227
src/modules/social-integration/services/instagram-api.service.ts
Normal file
227
src/modules/social-integration/services/instagram-api.service.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
// Instagram API Service - Instagram posting and stories
|
||||
// Path: src/modules/social-integration/services/instagram-api.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface InstagramPost {
|
||||
id: string;
|
||||
type: 'image' | 'video' | 'carousel' | 'reel' | 'story';
|
||||
mediaUrl: string;
|
||||
caption?: string;
|
||||
hashtags?: string[];
|
||||
location?: string;
|
||||
metrics?: InstagramMetrics;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface InstagramMetrics {
|
||||
likes: number;
|
||||
comments: number;
|
||||
saves: number;
|
||||
shares: number;
|
||||
reach: number;
|
||||
impressions: number;
|
||||
engagementRate: number;
|
||||
}
|
||||
|
||||
export interface CarouselItem {
|
||||
mediaType: 'image' | 'video';
|
||||
mediaUrl: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InstagramApiService {
|
||||
private readonly logger = new Logger(InstagramApiService.name);
|
||||
private readonly MAX_CAPTION = 2200;
|
||||
private readonly MAX_HASHTAGS = 30;
|
||||
private readonly MAX_CAROUSEL = 10;
|
||||
|
||||
/**
|
||||
* Create a single image/video post
|
||||
*/
|
||||
async createPost(
|
||||
accessToken: string,
|
||||
content: {
|
||||
mediaUrl: string;
|
||||
mediaType: 'image' | 'video';
|
||||
caption?: string;
|
||||
location?: string;
|
||||
},
|
||||
): Promise<InstagramPost> {
|
||||
const { caption, hashtags } = this.processCaption(content.caption || '');
|
||||
|
||||
const post: InstagramPost = {
|
||||
id: `ig-post-${Date.now()}`,
|
||||
type: content.mediaType,
|
||||
mediaUrl: content.mediaUrl,
|
||||
caption,
|
||||
hashtags,
|
||||
location: content.location,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created Instagram post: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a carousel post
|
||||
*/
|
||||
async createCarousel(
|
||||
accessToken: string,
|
||||
content: {
|
||||
items: CarouselItem[];
|
||||
caption?: string;
|
||||
location?: string;
|
||||
},
|
||||
): Promise<InstagramPost> {
|
||||
if (content.items.length > this.MAX_CAROUSEL) {
|
||||
throw new Error(`Carousel cannot exceed ${this.MAX_CAROUSEL} items`);
|
||||
}
|
||||
|
||||
const { caption, hashtags } = this.processCaption(content.caption || '');
|
||||
|
||||
const post: InstagramPost = {
|
||||
id: `ig-carousel-${Date.now()}`,
|
||||
type: 'carousel',
|
||||
mediaUrl: content.items[0].mediaUrl,
|
||||
caption,
|
||||
hashtags,
|
||||
location: content.location,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created Instagram carousel: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Reel
|
||||
*/
|
||||
async createReel(
|
||||
accessToken: string,
|
||||
content: {
|
||||
videoUrl: string;
|
||||
caption?: string;
|
||||
coverImage?: string;
|
||||
shareToFeed?: boolean;
|
||||
},
|
||||
): Promise<InstagramPost> {
|
||||
const { caption, hashtags } = this.processCaption(content.caption || '');
|
||||
|
||||
const post: InstagramPost = {
|
||||
id: `ig-reel-${Date.now()}`,
|
||||
type: 'reel',
|
||||
mediaUrl: content.videoUrl,
|
||||
caption,
|
||||
hashtags,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created Instagram Reel: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Story
|
||||
*/
|
||||
async createStory(
|
||||
accessToken: string,
|
||||
content: {
|
||||
mediaUrl: string;
|
||||
mediaType: 'image' | 'video';
|
||||
stickers?: StorySticker[];
|
||||
links?: { url: string; text: string }[];
|
||||
},
|
||||
): Promise<InstagramPost> {
|
||||
const story: InstagramPost = {
|
||||
id: `ig-story-${Date.now()}`,
|
||||
type: 'story',
|
||||
mediaUrl: content.mediaUrl,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created Instagram Story: ${story.id}`);
|
||||
return story;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post metrics
|
||||
*/
|
||||
async getPostMetrics(accessToken: string, postId: string): Promise<InstagramMetrics> {
|
||||
return {
|
||||
likes: Math.floor(Math.random() * 5000),
|
||||
comments: Math.floor(Math.random() * 200),
|
||||
saves: Math.floor(Math.random() * 500),
|
||||
shares: Math.floor(Math.random() * 100),
|
||||
reach: Math.floor(Math.random() * 20000),
|
||||
impressions: Math.floor(Math.random() * 30000),
|
||||
engagementRate: Math.random() * 15,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get optimal posting times
|
||||
*/
|
||||
getOptimalPostingTimes(): {
|
||||
dayOfWeek: string;
|
||||
times: string[];
|
||||
engagement: string;
|
||||
}[] {
|
||||
return [
|
||||
{ dayOfWeek: 'Monday', times: ['11:00', '14:00', '19:00'], engagement: 'Medium' },
|
||||
{ dayOfWeek: 'Tuesday', times: ['10:00', '14:00', '18:00'], engagement: 'High' },
|
||||
{ dayOfWeek: 'Wednesday', times: ['11:00', '15:00', '19:00'], engagement: 'High' },
|
||||
{ dayOfWeek: 'Thursday', times: ['09:00', '12:00', '19:00'], engagement: 'Medium' },
|
||||
{ dayOfWeek: 'Friday', times: ['11:00', '13:00', '16:00'], engagement: 'High' },
|
||||
{ dayOfWeek: 'Saturday', times: ['10:00', '12:00', '20:00'], engagement: 'Very High' },
|
||||
{ dayOfWeek: 'Sunday', times: ['10:00', '14:00', '19:00'], engagement: 'Very High' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get constraints
|
||||
*/
|
||||
getConstraints(): {
|
||||
maxCaption: number;
|
||||
maxHashtags: number;
|
||||
maxCarouselItems: number;
|
||||
imageFormats: string[];
|
||||
videoFormats: string[];
|
||||
maxVideoLength: { feed: string; reel: string; story: string };
|
||||
aspectRatios: { feed: string[]; reel: string; story: string };
|
||||
} {
|
||||
return {
|
||||
maxCaption: this.MAX_CAPTION,
|
||||
maxHashtags: this.MAX_HASHTAGS,
|
||||
maxCarouselItems: this.MAX_CAROUSEL,
|
||||
imageFormats: ['jpg', 'png'],
|
||||
videoFormats: ['mp4', 'mov'],
|
||||
maxVideoLength: { feed: '60s', reel: '90s', story: '60s' },
|
||||
aspectRatios: { feed: ['1:1', '4:5', '1.91:1'], reel: '9:16', story: '9:16' },
|
||||
};
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private processCaption(caption: string): { caption: string; hashtags: string[] } {
|
||||
const hashtagRegex = /#\w+/g;
|
||||
const hashtags = caption.match(hashtagRegex) || [];
|
||||
|
||||
if (hashtags.length > this.MAX_HASHTAGS) {
|
||||
throw new Error(`Cannot exceed ${this.MAX_HASHTAGS} hashtags`);
|
||||
}
|
||||
|
||||
if (caption.length > this.MAX_CAPTION) {
|
||||
throw new Error(`Caption cannot exceed ${this.MAX_CAPTION} characters`);
|
||||
}
|
||||
|
||||
return { caption, hashtags };
|
||||
}
|
||||
}
|
||||
|
||||
interface StorySticker {
|
||||
type: 'poll' | 'question' | 'countdown' | 'emoji' | 'mention' | 'hashtag' | 'location' | 'link';
|
||||
position: { x: number; y: number };
|
||||
data: Record<string, any>;
|
||||
}
|
||||
260
src/modules/social-integration/services/linkedin-api.service.ts
Normal file
260
src/modules/social-integration/services/linkedin-api.service.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
// LinkedIn API Service - LinkedIn posts and articles
|
||||
// Path: src/modules/social-integration/services/linkedin-api.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface LinkedInPost {
|
||||
id: string;
|
||||
type: 'text' | 'image' | 'video' | 'article' | 'document' | 'poll';
|
||||
text: string;
|
||||
mediaUrls?: string[];
|
||||
articleUrl?: string;
|
||||
visibility: 'public' | 'connections' | 'logged_in';
|
||||
metrics?: LinkedInMetrics;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface LinkedInMetrics {
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
engagementRate: number;
|
||||
}
|
||||
|
||||
export interface LinkedInArticle {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
coverImage?: string;
|
||||
canonicalUrl?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LinkedInApiService {
|
||||
private readonly logger = new Logger(LinkedInApiService.name);
|
||||
private readonly MAX_CHARS = 3000;
|
||||
private readonly MAX_IMAGES = 9;
|
||||
|
||||
/**
|
||||
* Create a text post
|
||||
*/
|
||||
async createPost(
|
||||
accessToken: string,
|
||||
content: {
|
||||
text: string;
|
||||
visibility?: 'public' | 'connections' | 'logged_in';
|
||||
},
|
||||
): Promise<LinkedInPost> {
|
||||
this.validateContent(content.text);
|
||||
|
||||
const post: LinkedInPost = {
|
||||
id: `li-post-${Date.now()}`,
|
||||
type: 'text',
|
||||
text: content.text,
|
||||
visibility: content.visibility || 'public',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created LinkedIn post: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a post with images
|
||||
*/
|
||||
async createImagePost(
|
||||
accessToken: string,
|
||||
content: {
|
||||
text: string;
|
||||
imageUrls: string[];
|
||||
visibility?: 'public' | 'connections' | 'logged_in';
|
||||
},
|
||||
): Promise<LinkedInPost> {
|
||||
if (content.imageUrls.length > this.MAX_IMAGES) {
|
||||
throw new Error(`Cannot exceed ${this.MAX_IMAGES} images`);
|
||||
}
|
||||
|
||||
const post: LinkedInPost = {
|
||||
id: `li-img-post-${Date.now()}`,
|
||||
type: 'image',
|
||||
text: content.text,
|
||||
mediaUrls: content.imageUrls,
|
||||
visibility: content.visibility || 'public',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created LinkedIn image post: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a video post
|
||||
*/
|
||||
async createVideoPost(
|
||||
accessToken: string,
|
||||
content: {
|
||||
text: string;
|
||||
videoUrl: string;
|
||||
visibility?: 'public' | 'connections' | 'logged_in';
|
||||
},
|
||||
): Promise<LinkedInPost> {
|
||||
const post: LinkedInPost = {
|
||||
id: `li-video-${Date.now()}`,
|
||||
type: 'video',
|
||||
text: content.text,
|
||||
mediaUrls: [content.videoUrl],
|
||||
visibility: content.visibility || 'public',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created LinkedIn video post: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a document (PDF) post
|
||||
*/
|
||||
async createDocumentPost(
|
||||
accessToken: string,
|
||||
content: {
|
||||
text: string;
|
||||
documentUrl: string;
|
||||
title: string;
|
||||
},
|
||||
): Promise<LinkedInPost> {
|
||||
const post: LinkedInPost = {
|
||||
id: `li-doc-${Date.now()}`,
|
||||
type: 'document',
|
||||
text: content.text,
|
||||
mediaUrls: [content.documentUrl],
|
||||
visibility: 'public',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created LinkedIn document post: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a poll
|
||||
*/
|
||||
async createPoll(
|
||||
accessToken: string,
|
||||
content: {
|
||||
question: string;
|
||||
options: string[];
|
||||
durationDays: 1 | 3 | 7 | 14;
|
||||
},
|
||||
): Promise<LinkedInPost> {
|
||||
if (content.options.length < 2 || content.options.length > 4) {
|
||||
throw new Error('Poll must have 2-4 options');
|
||||
}
|
||||
|
||||
const post: LinkedInPost = {
|
||||
id: `li-poll-${Date.now()}`,
|
||||
type: 'poll',
|
||||
text: content.question,
|
||||
visibility: 'public',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Created LinkedIn poll: ${post.id}`);
|
||||
return post;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish an article
|
||||
*/
|
||||
async publishArticle(
|
||||
accessToken: string,
|
||||
article: {
|
||||
title: string;
|
||||
content: string;
|
||||
coverImage?: string;
|
||||
},
|
||||
): Promise<LinkedInArticle> {
|
||||
const published: LinkedInArticle = {
|
||||
id: `li-article-${Date.now()}`,
|
||||
title: article.title,
|
||||
content: article.content,
|
||||
coverImage: article.coverImage,
|
||||
};
|
||||
|
||||
this.logger.log(`Published LinkedIn article: ${published.id}`);
|
||||
return published;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post metrics
|
||||
*/
|
||||
async getPostMetrics(accessToken: string, postId: string): Promise<LinkedInMetrics> {
|
||||
return {
|
||||
likes: Math.floor(Math.random() * 500),
|
||||
comments: Math.floor(Math.random() * 50),
|
||||
shares: Math.floor(Math.random() * 30),
|
||||
impressions: Math.floor(Math.random() * 10000),
|
||||
clicks: Math.floor(Math.random() * 200),
|
||||
engagementRate: Math.random() * 8,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format content for LinkedIn
|
||||
*/
|
||||
formatForLinkedIn(content: string): {
|
||||
formatted: string;
|
||||
hooks: string[];
|
||||
improvements: string[];
|
||||
} {
|
||||
const hooks = [
|
||||
'Start with a bold statement or question',
|
||||
'Use white space and short paragraphs',
|
||||
'Add a clear call-to-action at the end',
|
||||
];
|
||||
|
||||
const improvements: string[] = [];
|
||||
let formatted = content;
|
||||
|
||||
// Check for improvements
|
||||
if (!content.includes('\n\n')) {
|
||||
improvements.push('Add more line breaks for readability');
|
||||
}
|
||||
if (content.length > 1500) {
|
||||
improvements.push('Consider shortening - optimal is 700-1500 chars');
|
||||
}
|
||||
if (!content.includes('?')) {
|
||||
improvements.push('Add a question to boost engagement');
|
||||
}
|
||||
|
||||
return { formatted, hooks, improvements };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get constraints
|
||||
*/
|
||||
getConstraints(): {
|
||||
maxChars: number;
|
||||
maxImages: number;
|
||||
maxVideoLength: string;
|
||||
formats: { image: string[]; video: string[]; document: string[] };
|
||||
} {
|
||||
return {
|
||||
maxChars: this.MAX_CHARS,
|
||||
maxImages: this.MAX_IMAGES,
|
||||
maxVideoLength: '10:00',
|
||||
formats: {
|
||||
image: ['jpg', 'png', 'gif'],
|
||||
video: ['mp4', 'avi', 'mov'],
|
||||
document: ['pdf'],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private validateContent(text: string): void {
|
||||
if (text.length > this.MAX_CHARS) {
|
||||
throw new Error(`Post exceeds ${this.MAX_CHARS} character limit`);
|
||||
}
|
||||
}
|
||||
}
|
||||
385
src/modules/social-integration/services/oauth.service.ts
Normal file
385
src/modules/social-integration/services/oauth.service.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
// OAuth Service - Social media platform authentication
|
||||
// Path: src/modules/social-integration/services/oauth.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface OAuthProvider {
|
||||
id: SocialPlatform;
|
||||
name: string;
|
||||
authUrl: string;
|
||||
tokenUrl: string;
|
||||
scopes: string[];
|
||||
requiredFields: string[];
|
||||
rateLimits: RateLimits;
|
||||
}
|
||||
|
||||
export type SocialPlatform =
|
||||
| 'twitter'
|
||||
| 'instagram'
|
||||
| 'linkedin'
|
||||
| 'facebook'
|
||||
| 'tiktok'
|
||||
| 'youtube'
|
||||
| 'threads'
|
||||
| 'pinterest';
|
||||
|
||||
export interface OAuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt?: Date;
|
||||
scope?: string[];
|
||||
tokenType: string;
|
||||
}
|
||||
|
||||
export interface ConnectedAccount {
|
||||
id: string;
|
||||
userId: string;
|
||||
platform: SocialPlatform;
|
||||
platformUserId: string;
|
||||
platformUsername: string;
|
||||
displayName?: string;
|
||||
profileImage?: string;
|
||||
tokens: OAuthTokens;
|
||||
scopes: string[];
|
||||
isActive: boolean;
|
||||
lastSync?: Date;
|
||||
metadata: AccountMetadata;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface AccountMetadata {
|
||||
followers?: number;
|
||||
following?: number;
|
||||
posts?: number;
|
||||
verified?: boolean;
|
||||
accountType?: string;
|
||||
}
|
||||
|
||||
export interface RateLimits {
|
||||
postsPerDay: number;
|
||||
postsPerHour: number;
|
||||
requestsPerMinute: number;
|
||||
mediaPerPost: number;
|
||||
charactersPerPost: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OAuthService {
|
||||
private readonly logger = new Logger(OAuthService.name);
|
||||
|
||||
// OAuth providers configuration
|
||||
private readonly providers: Record<SocialPlatform, OAuthProvider> = {
|
||||
twitter: {
|
||||
id: 'twitter',
|
||||
name: 'X (Twitter)',
|
||||
authUrl: 'https://twitter.com/i/oauth2/authorize',
|
||||
tokenUrl: 'https://api.twitter.com/2/oauth2/token',
|
||||
scopes: ['tweet.read', 'tweet.write', 'users.read', 'offline.access'],
|
||||
requiredFields: ['TWITTER_CLIENT_ID', 'TWITTER_CLIENT_SECRET'],
|
||||
rateLimits: {
|
||||
postsPerDay: 2400,
|
||||
postsPerHour: 300,
|
||||
requestsPerMinute: 300,
|
||||
mediaPerPost: 4,
|
||||
charactersPerPost: 280,
|
||||
},
|
||||
},
|
||||
instagram: {
|
||||
id: 'instagram',
|
||||
name: 'Instagram',
|
||||
authUrl: 'https://api.instagram.com/oauth/authorize',
|
||||
tokenUrl: 'https://api.instagram.com/oauth/access_token',
|
||||
scopes: ['user_profile', 'user_media', 'content_publish'],
|
||||
requiredFields: ['INSTAGRAM_CLIENT_ID', 'INSTAGRAM_CLIENT_SECRET'],
|
||||
rateLimits: {
|
||||
postsPerDay: 25,
|
||||
postsPerHour: 25,
|
||||
requestsPerMinute: 200,
|
||||
mediaPerPost: 10,
|
||||
charactersPerPost: 2200,
|
||||
},
|
||||
},
|
||||
linkedin: {
|
||||
id: 'linkedin',
|
||||
name: 'LinkedIn',
|
||||
authUrl: 'https://www.linkedin.com/oauth/v2/authorization',
|
||||
tokenUrl: 'https://www.linkedin.com/oauth/v2/accessToken',
|
||||
scopes: ['w_member_social', 'r_liteprofile', 'r_emailaddress'],
|
||||
requiredFields: ['LINKEDIN_CLIENT_ID', 'LINKEDIN_CLIENT_SECRET'],
|
||||
rateLimits: {
|
||||
postsPerDay: 100,
|
||||
postsPerHour: 50,
|
||||
requestsPerMinute: 100,
|
||||
mediaPerPost: 9,
|
||||
charactersPerPost: 3000,
|
||||
},
|
||||
},
|
||||
facebook: {
|
||||
id: 'facebook',
|
||||
name: 'Facebook',
|
||||
authUrl: 'https://www.facebook.com/v18.0/dialog/oauth',
|
||||
tokenUrl: 'https://graph.facebook.com/v18.0/oauth/access_token',
|
||||
scopes: ['pages_manage_posts', 'pages_read_engagement', 'pages_show_list'],
|
||||
requiredFields: ['FACEBOOK_APP_ID', 'FACEBOOK_APP_SECRET'],
|
||||
rateLimits: {
|
||||
postsPerDay: 200,
|
||||
postsPerHour: 50,
|
||||
requestsPerMinute: 200,
|
||||
mediaPerPost: 10,
|
||||
charactersPerPost: 63206,
|
||||
},
|
||||
},
|
||||
tiktok: {
|
||||
id: 'tiktok',
|
||||
name: 'TikTok',
|
||||
authUrl: 'https://www.tiktok.com/v2/auth/authorize/',
|
||||
tokenUrl: 'https://open.tiktokapis.com/v2/oauth/token/',
|
||||
scopes: ['video.upload', 'user.info.basic'],
|
||||
requiredFields: ['TIKTOK_CLIENT_KEY', 'TIKTOK_CLIENT_SECRET'],
|
||||
rateLimits: {
|
||||
postsPerDay: 50,
|
||||
postsPerHour: 10,
|
||||
requestsPerMinute: 100,
|
||||
mediaPerPost: 1,
|
||||
charactersPerPost: 2200,
|
||||
},
|
||||
},
|
||||
youtube: {
|
||||
id: 'youtube',
|
||||
name: 'YouTube',
|
||||
authUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
tokenUrl: 'https://oauth2.googleapis.com/token',
|
||||
scopes: ['https://www.googleapis.com/auth/youtube.upload', 'https://www.googleapis.com/auth/youtube'],
|
||||
requiredFields: ['YOUTUBE_CLIENT_ID', 'YOUTUBE_CLIENT_SECRET'],
|
||||
rateLimits: {
|
||||
postsPerDay: 100,
|
||||
postsPerHour: 50,
|
||||
requestsPerMinute: 100,
|
||||
mediaPerPost: 1,
|
||||
charactersPerPost: 5000,
|
||||
},
|
||||
},
|
||||
threads: {
|
||||
id: 'threads',
|
||||
name: 'Threads',
|
||||
authUrl: 'https://www.threads.net/oauth/authorize',
|
||||
tokenUrl: 'https://graph.threads.net/oauth/access_token',
|
||||
scopes: ['threads_basic', 'threads_content_publish', 'threads_manage_insights'],
|
||||
requiredFields: ['THREADS_APP_ID', 'THREADS_APP_SECRET'],
|
||||
rateLimits: {
|
||||
postsPerDay: 500,
|
||||
postsPerHour: 250,
|
||||
requestsPerMinute: 250,
|
||||
mediaPerPost: 10,
|
||||
charactersPerPost: 500,
|
||||
},
|
||||
},
|
||||
pinterest: {
|
||||
id: 'pinterest',
|
||||
name: 'Pinterest',
|
||||
authUrl: 'https://api.pinterest.com/oauth/',
|
||||
tokenUrl: 'https://api.pinterest.com/v5/oauth/token',
|
||||
scopes: ['pins:read', 'pins:write', 'boards:read', 'user_accounts:read'],
|
||||
requiredFields: ['PINTEREST_APP_ID', 'PINTEREST_APP_SECRET'],
|
||||
rateLimits: {
|
||||
postsPerDay: 500,
|
||||
postsPerHour: 100,
|
||||
requestsPerMinute: 1000,
|
||||
mediaPerPost: 1,
|
||||
charactersPerPost: 500,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// In-memory connected accounts (would be in database in production)
|
||||
private connectedAccounts: Map<string, ConnectedAccount[]> = new Map();
|
||||
|
||||
/**
|
||||
* Get OAuth authorization URL
|
||||
*/
|
||||
getAuthorizationUrl(platform: SocialPlatform, redirectUri: string, state?: string): {
|
||||
url: string;
|
||||
state: string;
|
||||
} {
|
||||
const provider = this.providers[platform];
|
||||
const generatedState = state || this.generateState();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: `${platform.toUpperCase()}_CLIENT_ID`,
|
||||
redirect_uri: redirectUri,
|
||||
scope: provider.scopes.join(' '),
|
||||
response_type: 'code',
|
||||
state: generatedState,
|
||||
});
|
||||
|
||||
return {
|
||||
url: `${provider.authUrl}?${params.toString()}`,
|
||||
state: generatedState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens
|
||||
*/
|
||||
async exchangeCodeForTokens(
|
||||
platform: SocialPlatform,
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
): Promise<OAuthTokens> {
|
||||
// In production, this would make actual API calls
|
||||
this.logger.log(`Exchanging code for ${platform} tokens`);
|
||||
|
||||
return {
|
||||
accessToken: `mock_access_token_${platform}_${Date.now()}`,
|
||||
refreshToken: `mock_refresh_token_${platform}_${Date.now()}`,
|
||||
expiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
scope: this.providers[platform].scopes,
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token
|
||||
*/
|
||||
async refreshAccessToken(
|
||||
platform: SocialPlatform,
|
||||
refreshToken: string,
|
||||
): Promise<OAuthTokens> {
|
||||
this.logger.log(`Refreshing ${platform} access token`);
|
||||
|
||||
return {
|
||||
accessToken: `mock_refreshed_token_${platform}_${Date.now()}`,
|
||||
refreshToken: `mock_new_refresh_token_${platform}_${Date.now()}`,
|
||||
expiresAt: new Date(Date.now() + 3600 * 1000),
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a social account
|
||||
*/
|
||||
async connectAccount(
|
||||
userId: string,
|
||||
platform: SocialPlatform,
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
): Promise<ConnectedAccount> {
|
||||
const tokens = await this.exchangeCodeForTokens(platform, code, redirectUri);
|
||||
const userInfo = await this.fetchUserInfo(platform, tokens.accessToken);
|
||||
|
||||
const account: ConnectedAccount = {
|
||||
id: `acc-${Date.now()}`,
|
||||
userId,
|
||||
platform,
|
||||
platformUserId: userInfo.id,
|
||||
platformUsername: userInfo.username,
|
||||
displayName: userInfo.displayName,
|
||||
profileImage: userInfo.profileImage,
|
||||
tokens,
|
||||
scopes: this.providers[platform].scopes,
|
||||
isActive: true,
|
||||
lastSync: new Date(),
|
||||
metadata: {
|
||||
followers: userInfo.followers,
|
||||
following: userInfo.following,
|
||||
verified: userInfo.verified,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
// Store account
|
||||
const userAccounts = this.connectedAccounts.get(userId) || [];
|
||||
userAccounts.push(account);
|
||||
this.connectedAccounts.set(userId, userAccounts);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect a social account
|
||||
*/
|
||||
disconnectAccount(userId: string, accountId: string): boolean {
|
||||
const userAccounts = this.connectedAccounts.get(userId) || [];
|
||||
const filtered = userAccounts.filter((a) => a.id !== accountId);
|
||||
this.connectedAccounts.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connected accounts for user
|
||||
*/
|
||||
getConnectedAccounts(userId: string): ConnectedAccount[] {
|
||||
return this.connectedAccounts.get(userId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get account by platform
|
||||
*/
|
||||
getAccountByPlatform(userId: string, platform: SocialPlatform): ConnectedAccount | null {
|
||||
const accounts = this.getConnectedAccounts(userId);
|
||||
return accounts.find((a) => a.platform === platform) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token needs refresh
|
||||
*/
|
||||
async ensureValidToken(account: ConnectedAccount): Promise<OAuthTokens> {
|
||||
if (account.tokens.expiresAt && account.tokens.expiresAt < new Date()) {
|
||||
if (account.tokens.refreshToken) {
|
||||
return this.refreshAccessToken(account.platform, account.tokens.refreshToken);
|
||||
}
|
||||
throw new Error(`Token expired and no refresh token available for ${account.platform}`);
|
||||
}
|
||||
return account.tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available providers
|
||||
*/
|
||||
getAvailableProviders(): OAuthProvider[] {
|
||||
return Object.values(this.providers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider info
|
||||
*/
|
||||
getProviderInfo(platform: SocialPlatform): OAuthProvider {
|
||||
return this.providers[platform];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rate limits
|
||||
*/
|
||||
getRateLimits(platform: SocialPlatform): RateLimits {
|
||||
return this.providers[platform].rateLimits;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
|
||||
private generateState(): string {
|
||||
return `state_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
||||
}
|
||||
|
||||
private async fetchUserInfo(platform: SocialPlatform, accessToken: string): Promise<{
|
||||
id: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
profileImage?: string;
|
||||
followers?: number;
|
||||
following?: number;
|
||||
verified?: boolean;
|
||||
}> {
|
||||
// Mock user info - in production, would call platform APIs
|
||||
return {
|
||||
id: `${platform}_user_${Date.now()}`,
|
||||
username: `user_${platform}`,
|
||||
displayName: `User on ${platform}`,
|
||||
profileImage: `https://example.com/${platform}_avatar.jpg`,
|
||||
followers: Math.floor(Math.random() * 10000),
|
||||
following: Math.floor(Math.random() * 1000),
|
||||
verified: Math.random() > 0.8,
|
||||
};
|
||||
}
|
||||
}
|
||||
217
src/modules/social-integration/services/tiktok-api.service.ts
Normal file
217
src/modules/social-integration/services/tiktok-api.service.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
// TikTok API Service - TikTok video posting
|
||||
// Path: src/modules/social-integration/services/tiktok-api.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface TikTokVideo {
|
||||
id: string;
|
||||
videoUrl: string;
|
||||
caption: string;
|
||||
hashtags: string[];
|
||||
musicId?: string;
|
||||
coverImage?: string;
|
||||
privacy: TikTokPrivacy;
|
||||
allowComments: boolean;
|
||||
allowDuet: boolean;
|
||||
allowStitch: boolean;
|
||||
metrics?: TikTokMetrics;
|
||||
status: 'uploading' | 'processing' | 'published' | 'failed';
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type TikTokPrivacy = 'public' | 'friends' | 'private';
|
||||
|
||||
export interface TikTokMetrics {
|
||||
views: number;
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
saves: number;
|
||||
watchTime: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
export interface TikTokSound {
|
||||
id: string;
|
||||
title: string;
|
||||
author: string;
|
||||
duration: number;
|
||||
isOriginal: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TikTokApiService {
|
||||
private readonly logger = new Logger(TikTokApiService.name);
|
||||
private readonly MAX_CAPTION = 2200;
|
||||
private readonly MAX_HASHTAGS = 100;
|
||||
private readonly MAX_VIDEO_LENGTH = 180; // 3 minutes in seconds
|
||||
|
||||
/**
|
||||
* Upload a video
|
||||
*/
|
||||
async uploadVideo(
|
||||
accessToken: string,
|
||||
content: {
|
||||
videoUrl: string;
|
||||
caption: string;
|
||||
coverImage?: string;
|
||||
privacy?: TikTokPrivacy;
|
||||
allowComments?: boolean;
|
||||
allowDuet?: boolean;
|
||||
allowStitch?: boolean;
|
||||
},
|
||||
): Promise<TikTokVideo> {
|
||||
const { caption, hashtags } = this.processCaption(content.caption);
|
||||
|
||||
const video: TikTokVideo = {
|
||||
id: `tiktok-${Date.now()}`,
|
||||
videoUrl: content.videoUrl,
|
||||
caption,
|
||||
hashtags,
|
||||
coverImage: content.coverImage,
|
||||
privacy: content.privacy || 'public',
|
||||
allowComments: content.allowComments ?? true,
|
||||
allowDuet: content.allowDuet ?? true,
|
||||
allowStitch: content.allowStitch ?? true,
|
||||
status: 'processing',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Uploaded TikTok video: ${video.id}`);
|
||||
return video;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check video status
|
||||
*/
|
||||
async getVideoStatus(accessToken: string, videoId: string): Promise<{
|
||||
status: TikTokVideo['status'];
|
||||
publishedUrl?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
// Mock status check
|
||||
return {
|
||||
status: 'published',
|
||||
publishedUrl: `https://www.tiktok.com/@user/video/${videoId}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video metrics
|
||||
*/
|
||||
async getVideoMetrics(accessToken: string, videoId: string): Promise<TikTokMetrics> {
|
||||
return {
|
||||
views: Math.floor(Math.random() * 100000),
|
||||
likes: Math.floor(Math.random() * 10000),
|
||||
comments: Math.floor(Math.random() * 500),
|
||||
shares: Math.floor(Math.random() * 200),
|
||||
saves: Math.floor(Math.random() * 1000),
|
||||
watchTime: Math.floor(Math.random() * 60),
|
||||
completionRate: Math.random() * 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trending sounds
|
||||
*/
|
||||
async getTrendingSounds(accessToken: string): Promise<TikTokSound[]> {
|
||||
return [
|
||||
{ id: 'sound-1', title: 'Trending Sound 1', author: 'Artist 1', duration: 30, isOriginal: false },
|
||||
{ id: 'sound-2', title: 'Trending Sound 2', author: 'Artist 2', duration: 45, isOriginal: false },
|
||||
{ id: 'sound-3', title: 'Original Audio', author: 'Creator', duration: 60, isOriginal: true },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get trending hashtags
|
||||
*/
|
||||
async getTrendingHashtags(): Promise<Array<{
|
||||
hashtag: string;
|
||||
views: string;
|
||||
description: string;
|
||||
}>> {
|
||||
return [
|
||||
{ hashtag: '#fyp', views: '100B+', description: 'For You Page' },
|
||||
{ hashtag: '#viral', views: '50B+', description: 'Viral content' },
|
||||
{ hashtag: '#trending', views: '30B+', description: 'Trending topics' },
|
||||
{ hashtag: '#foryou', views: '80B+', description: 'For You' },
|
||||
{ hashtag: '#foryoupage', views: '70B+', description: 'For You Page' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize caption for TikTok
|
||||
*/
|
||||
optimizeCaption(caption: string): {
|
||||
optimized: string;
|
||||
hashtags: string[];
|
||||
suggestedHashtags: string[];
|
||||
characterCount: number;
|
||||
} {
|
||||
const { caption: extracted, hashtags } = this.processCaption(caption);
|
||||
|
||||
const suggestedHashtags = ['#fyp', '#foryou', '#viral'].filter(
|
||||
(h) => !hashtags.includes(h)
|
||||
);
|
||||
|
||||
return {
|
||||
optimized: extracted,
|
||||
hashtags,
|
||||
suggestedHashtags,
|
||||
characterCount: caption.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get best posting times
|
||||
*/
|
||||
getBestPostingTimes(): Array<{
|
||||
day: string;
|
||||
times: string[];
|
||||
engagement: 'low' | 'medium' | 'high' | 'peak';
|
||||
}> {
|
||||
return [
|
||||
{ day: 'Monday', times: ['06:00', '10:00', '22:00'], engagement: 'medium' },
|
||||
{ day: 'Tuesday', times: ['02:00', '09:00', '14:00'], engagement: 'high' },
|
||||
{ day: 'Wednesday', times: ['07:00', '11:00', '21:00'], engagement: 'high' },
|
||||
{ day: 'Thursday', times: ['09:00', '12:00', '19:00'], engagement: 'peak' },
|
||||
{ day: 'Friday', times: ['05:00', '13:00', '15:00'], engagement: 'peak' },
|
||||
{ day: 'Saturday', times: ['11:00', '19:00', '20:00'], engagement: 'peak' },
|
||||
{ day: 'Sunday', times: ['07:00', '08:00', '16:00'], engagement: 'high' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get constraints
|
||||
*/
|
||||
getConstraints(): {
|
||||
maxCaption: number;
|
||||
maxVideoLength: number;
|
||||
minVideoLength: number;
|
||||
aspectRatio: string;
|
||||
formats: string[];
|
||||
resolution: { min: string; max: string };
|
||||
} {
|
||||
return {
|
||||
maxCaption: this.MAX_CAPTION,
|
||||
maxVideoLength: this.MAX_VIDEO_LENGTH,
|
||||
minVideoLength: 3,
|
||||
aspectRatio: '9:16',
|
||||
formats: ['mp4', 'mov', 'webm'],
|
||||
resolution: { min: '720x1280', max: '1080x1920' },
|
||||
};
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private processCaption(caption: string): { caption: string; hashtags: string[] } {
|
||||
const hashtagRegex = /#\w+/g;
|
||||
const hashtags = caption.match(hashtagRegex) || [];
|
||||
|
||||
if (caption.length > this.MAX_CAPTION) {
|
||||
throw new Error(`Caption cannot exceed ${this.MAX_CAPTION} characters`);
|
||||
}
|
||||
|
||||
return { caption, hashtags };
|
||||
}
|
||||
}
|
||||
211
src/modules/social-integration/services/twitter-api.service.ts
Normal file
211
src/modules/social-integration/services/twitter-api.service.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
// Twitter API Service - X/Twitter posting and engagement
|
||||
// Path: src/modules/social-integration/services/twitter-api.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OAuthService, OAuthTokens } from './oauth.service';
|
||||
|
||||
export interface Tweet {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: Date;
|
||||
metrics?: TweetMetrics;
|
||||
mediaIds?: string[];
|
||||
replyToId?: string;
|
||||
quoteTweetId?: string;
|
||||
}
|
||||
|
||||
export interface TweetMetrics {
|
||||
likes: number;
|
||||
retweets: number;
|
||||
replies: number;
|
||||
impressions: number;
|
||||
engagementRate: number;
|
||||
}
|
||||
|
||||
export interface TwitterMedia {
|
||||
id: string;
|
||||
type: 'image' | 'video' | 'gif';
|
||||
url: string;
|
||||
altText?: string;
|
||||
}
|
||||
|
||||
export interface ThreadTweet {
|
||||
text: string;
|
||||
mediaIds?: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TwitterApiService {
|
||||
private readonly logger = new Logger(TwitterApiService.name);
|
||||
private readonly MAX_CHARS = 280;
|
||||
private readonly MAX_MEDIA = 4;
|
||||
|
||||
constructor(private readonly oauthService: OAuthService) { }
|
||||
|
||||
/**
|
||||
* Post a tweet
|
||||
*/
|
||||
async postTweet(
|
||||
accessToken: string,
|
||||
content: {
|
||||
text: string;
|
||||
mediaIds?: string[];
|
||||
replyToId?: string;
|
||||
quoteTweetId?: string;
|
||||
},
|
||||
): Promise<Tweet> {
|
||||
this.validateContent(content.text);
|
||||
|
||||
// Mock API response
|
||||
const tweet: Tweet = {
|
||||
id: `tweet-${Date.now()}`,
|
||||
text: content.text,
|
||||
createdAt: new Date(),
|
||||
mediaIds: content.mediaIds,
|
||||
replyToId: content.replyToId,
|
||||
quoteTweetId: content.quoteTweetId,
|
||||
};
|
||||
|
||||
this.logger.log(`Posted tweet: ${tweet.id}`);
|
||||
return tweet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a thread (multiple connected tweets)
|
||||
*/
|
||||
async postThread(
|
||||
accessToken: string,
|
||||
tweets: ThreadTweet[],
|
||||
): Promise<Tweet[]> {
|
||||
const postedTweets: Tweet[] = [];
|
||||
let previousTweetId: string | undefined;
|
||||
|
||||
for (const tweet of tweets) {
|
||||
const posted = await this.postTweet(accessToken, {
|
||||
text: tweet.text,
|
||||
mediaIds: tweet.mediaIds,
|
||||
replyToId: previousTweetId,
|
||||
});
|
||||
postedTweets.push(posted);
|
||||
previousTweetId = posted.id;
|
||||
}
|
||||
|
||||
return postedTweets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload media
|
||||
*/
|
||||
async uploadMedia(
|
||||
accessToken: string,
|
||||
media: {
|
||||
file: Buffer;
|
||||
mimeType: string;
|
||||
altText?: string;
|
||||
},
|
||||
): Promise<TwitterMedia> {
|
||||
// Mock media upload
|
||||
return {
|
||||
id: `media-${Date.now()}`,
|
||||
type: media.mimeType.includes('video') ? 'video' : 'image',
|
||||
url: `https://pbs.twimg.com/media/${Date.now()}.jpg`,
|
||||
altText: media.altText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tweet metrics
|
||||
*/
|
||||
async getTweetMetrics(accessToken: string, tweetId: string): Promise<TweetMetrics> {
|
||||
// Mock metrics
|
||||
return {
|
||||
likes: Math.floor(Math.random() * 1000),
|
||||
retweets: Math.floor(Math.random() * 200),
|
||||
replies: Math.floor(Math.random() * 50),
|
||||
impressions: Math.floor(Math.random() * 10000),
|
||||
engagementRate: Math.random() * 10,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a tweet
|
||||
*/
|
||||
async deleteTweet(accessToken: string, tweetId: string): Promise<boolean> {
|
||||
this.logger.log(`Deleted tweet: ${tweetId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a tweet (using internal scheduling)
|
||||
*/
|
||||
scheduleForPosting(content: ThreadTweet[], scheduledTime: Date): {
|
||||
scheduledId: string;
|
||||
scheduledTime: Date;
|
||||
content: ThreadTweet[];
|
||||
} {
|
||||
return {
|
||||
scheduledId: `sched-${Date.now()}`,
|
||||
scheduledTime,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Split long text into thread
|
||||
*/
|
||||
splitIntoThread(text: string): string[] {
|
||||
if (text.length <= this.MAX_CHARS) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const words = text.split(' ');
|
||||
const tweets: string[] = [];
|
||||
let current = '';
|
||||
|
||||
for (const word of words) {
|
||||
const testLength = current ? current.length + 1 + word.length : word.length;
|
||||
|
||||
if (testLength <= this.MAX_CHARS - 10) { // Reserve space for thread indicators
|
||||
current = current ? `${current} ${word}` : word;
|
||||
} else {
|
||||
if (current) {
|
||||
tweets.push(current);
|
||||
}
|
||||
current = word;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tweets.push(current);
|
||||
}
|
||||
|
||||
// Add thread indicators
|
||||
return tweets.map((t, i) => `${t} ${i + 1}/${tweets.length}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate content before posting
|
||||
*/
|
||||
private validateContent(text: string): void {
|
||||
if (text.length > this.MAX_CHARS) {
|
||||
throw new Error(`Tweet exceeds ${this.MAX_CHARS} character limit`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get character limits and constraints
|
||||
*/
|
||||
getConstraints(): {
|
||||
maxChars: number;
|
||||
maxMedia: number;
|
||||
maxVideoLength: string;
|
||||
supportedFormats: string[];
|
||||
} {
|
||||
return {
|
||||
maxChars: this.MAX_CHARS,
|
||||
maxMedia: this.MAX_MEDIA,
|
||||
maxVideoLength: '2:20',
|
||||
supportedFormats: ['jpg', 'png', 'gif', 'mp4', 'webm'],
|
||||
};
|
||||
}
|
||||
}
|
||||
345
src/modules/social-integration/services/youtube-api.service.ts
Normal file
345
src/modules/social-integration/services/youtube-api.service.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
// YouTube API Service - YouTube video and Shorts publishing
|
||||
// Path: src/modules/social-integration/services/youtube-api.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface YouTubeVideo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
categoryId: string;
|
||||
privacy: YouTubePrivacy;
|
||||
isShort: boolean;
|
||||
thumbnailUrl?: string;
|
||||
videoUrl: string;
|
||||
playlistId?: string;
|
||||
metrics?: YouTubeMetrics;
|
||||
status: 'uploading' | 'processing' | 'published' | 'private' | 'unlisted';
|
||||
scheduledPublish?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export type YouTubePrivacy = 'public' | 'private' | 'unlisted';
|
||||
|
||||
export interface YouTubeMetrics {
|
||||
views: number;
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
watchTime: number;
|
||||
averageViewDuration: number;
|
||||
subscribersGained: number;
|
||||
ctr: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
export interface YouTubePlaylist {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
privacy: YouTubePrivacy;
|
||||
videoCount: number;
|
||||
}
|
||||
|
||||
export interface YouTubeChannel {
|
||||
id: string;
|
||||
title: string;
|
||||
subscribers: number;
|
||||
totalViews: number;
|
||||
videoCount: number;
|
||||
}
|
||||
|
||||
export type YouTubeCategory = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class YouTubeApiService {
|
||||
private readonly logger = new Logger(YouTubeApiService.name);
|
||||
private readonly MAX_TITLE = 100;
|
||||
private readonly MAX_DESCRIPTION = 5000;
|
||||
private readonly MAX_TAGS = 500;
|
||||
|
||||
private readonly categories: YouTubeCategory[] = [
|
||||
{ id: '1', name: 'Film & Animation' },
|
||||
{ id: '2', name: 'Autos & Vehicles' },
|
||||
{ id: '10', name: 'Music' },
|
||||
{ id: '15', name: 'Pets & Animals' },
|
||||
{ id: '17', name: 'Sports' },
|
||||
{ id: '19', name: 'Travel & Events' },
|
||||
{ id: '20', name: 'Gaming' },
|
||||
{ id: '22', name: 'People & Blogs' },
|
||||
{ id: '23', name: 'Comedy' },
|
||||
{ id: '24', name: 'Entertainment' },
|
||||
{ id: '25', name: 'News & Politics' },
|
||||
{ id: '26', name: 'Howto & Style' },
|
||||
{ id: '27', name: 'Education' },
|
||||
{ id: '28', name: 'Science & Technology' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Upload a video
|
||||
*/
|
||||
async uploadVideo(
|
||||
accessToken: string,
|
||||
content: {
|
||||
videoUrl: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags?: string[];
|
||||
categoryId?: string;
|
||||
privacy?: YouTubePrivacy;
|
||||
thumbnailUrl?: string;
|
||||
playlistId?: string;
|
||||
isShort?: boolean;
|
||||
scheduledPublish?: Date;
|
||||
},
|
||||
): Promise<YouTubeVideo> {
|
||||
this.validateMetadata(content);
|
||||
|
||||
const video: YouTubeVideo = {
|
||||
id: `yt-${Date.now()}`,
|
||||
title: content.title,
|
||||
description: content.description,
|
||||
tags: content.tags || [],
|
||||
categoryId: content.categoryId || '22',
|
||||
privacy: content.privacy || 'private',
|
||||
isShort: content.isShort || false,
|
||||
thumbnailUrl: content.thumbnailUrl,
|
||||
videoUrl: content.videoUrl,
|
||||
playlistId: content.playlistId,
|
||||
status: content.scheduledPublish ? 'private' : 'processing',
|
||||
scheduledPublish: content.scheduledPublish,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.logger.log(`Uploaded YouTube video: ${video.id}`);
|
||||
return video;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a Short
|
||||
*/
|
||||
async uploadShort(
|
||||
accessToken: string,
|
||||
content: {
|
||||
videoUrl: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
},
|
||||
): Promise<YouTubeVideo> {
|
||||
// Shorts have #Shorts in title/description
|
||||
const shortTitle = content.title.includes('#Shorts')
|
||||
? content.title
|
||||
: `${content.title} #Shorts`;
|
||||
|
||||
return this.uploadVideo(accessToken, {
|
||||
...content,
|
||||
title: shortTitle,
|
||||
description: content.description || '',
|
||||
isShort: true,
|
||||
privacy: 'public',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set custom thumbnail
|
||||
*/
|
||||
async setThumbnail(
|
||||
accessToken: string,
|
||||
videoId: string,
|
||||
thumbnailUrl: string,
|
||||
): Promise<boolean> {
|
||||
this.logger.log(`Set thumbnail for video ${videoId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get video metrics
|
||||
*/
|
||||
async getVideoMetrics(accessToken: string, videoId: string): Promise<YouTubeMetrics> {
|
||||
return {
|
||||
views: Math.floor(Math.random() * 50000),
|
||||
likes: Math.floor(Math.random() * 5000),
|
||||
dislikes: Math.floor(Math.random() * 50),
|
||||
comments: Math.floor(Math.random() * 200),
|
||||
shares: Math.floor(Math.random() * 100),
|
||||
watchTime: Math.floor(Math.random() * 100000),
|
||||
averageViewDuration: Math.floor(Math.random() * 300),
|
||||
subscribersGained: Math.floor(Math.random() * 100),
|
||||
ctr: Math.random() * 10,
|
||||
impressions: Math.floor(Math.random() * 100000),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel info
|
||||
*/
|
||||
async getChannelInfo(accessToken: string): Promise<YouTubeChannel> {
|
||||
return {
|
||||
id: `channel-${Date.now()}`,
|
||||
title: 'My Channel',
|
||||
subscribers: Math.floor(Math.random() * 100000),
|
||||
totalViews: Math.floor(Math.random() * 1000000),
|
||||
videoCount: Math.floor(Math.random() * 100),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a playlist
|
||||
*/
|
||||
async createPlaylist(
|
||||
accessToken: string,
|
||||
content: {
|
||||
title: string;
|
||||
description?: string;
|
||||
privacy?: YouTubePrivacy;
|
||||
},
|
||||
): Promise<YouTubePlaylist> {
|
||||
return {
|
||||
id: `playlist-${Date.now()}`,
|
||||
title: content.title,
|
||||
description: content.description || '',
|
||||
privacy: content.privacy || 'public',
|
||||
videoCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Add video to playlist
|
||||
*/
|
||||
async addToPlaylist(
|
||||
accessToken: string,
|
||||
playlistId: string,
|
||||
videoId: string,
|
||||
): Promise<boolean> {
|
||||
this.logger.log(`Added ${videoId} to playlist ${playlistId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get categories
|
||||
*/
|
||||
getCategories(): YouTubeCategory[] {
|
||||
return this.categories;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimize title for SEO
|
||||
*/
|
||||
optimizeTitle(title: string): {
|
||||
original: string;
|
||||
optimized: string;
|
||||
suggestions: string[];
|
||||
score: number;
|
||||
} {
|
||||
const suggestions: string[] = [];
|
||||
let score = 50;
|
||||
|
||||
if (title.length < 40) {
|
||||
suggestions.push('Title may be too short - aim for 40-60 characters');
|
||||
} else if (title.length > 60) {
|
||||
suggestions.push('Title may be truncated in search results');
|
||||
} else {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
if (/\d/.test(title)) {
|
||||
score += 10;
|
||||
} else {
|
||||
suggestions.push('Consider adding a number for better CTR');
|
||||
}
|
||||
|
||||
if (title.includes('|') || title.includes('-')) {
|
||||
score += 5;
|
||||
} else {
|
||||
suggestions.push('Use separators (|, -) to organize keywords');
|
||||
}
|
||||
|
||||
return {
|
||||
original: title,
|
||||
optimized: title,
|
||||
suggestions,
|
||||
score: Math.min(100, score),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate description
|
||||
*/
|
||||
generateDescription(template: {
|
||||
intro: string;
|
||||
timestamps?: Array<{ time: string; label: string }>;
|
||||
links?: Array<{ label: string; url: string }>;
|
||||
hashtags?: string[];
|
||||
keywords?: string[];
|
||||
}): string {
|
||||
let description = template.intro + '\n\n';
|
||||
|
||||
if (template.timestamps?.length) {
|
||||
description += '⏱️ Timestamps:\n';
|
||||
template.timestamps.forEach((t) => {
|
||||
description += `${t.time} - ${t.label}\n`;
|
||||
});
|
||||
description += '\n';
|
||||
}
|
||||
|
||||
if (template.links?.length) {
|
||||
description += '🔗 Links:\n';
|
||||
template.links.forEach((l) => {
|
||||
description += `${l.label}: ${l.url}\n`;
|
||||
});
|
||||
description += '\n';
|
||||
}
|
||||
|
||||
if (template.hashtags?.length) {
|
||||
description += template.hashtags.join(' ') + '\n\n';
|
||||
}
|
||||
|
||||
if (template.keywords?.length) {
|
||||
description += 'Keywords: ' + template.keywords.join(', ');
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get constraints
|
||||
*/
|
||||
getConstraints(): {
|
||||
maxTitle: number;
|
||||
maxDescription: number;
|
||||
maxTags: number;
|
||||
formats: string[];
|
||||
maxFileSize: string;
|
||||
maxDuration: { regular: string; short: string };
|
||||
thumbnailSpecs: { format: string[]; resolution: string };
|
||||
} {
|
||||
return {
|
||||
maxTitle: this.MAX_TITLE,
|
||||
maxDescription: this.MAX_DESCRIPTION,
|
||||
maxTags: this.MAX_TAGS,
|
||||
formats: ['mp4', 'mov', 'avi', 'wmv', 'flv', 'webm'],
|
||||
maxFileSize: '256GB',
|
||||
maxDuration: { regular: '12:00:00', short: '60s' },
|
||||
thumbnailSpecs: { format: ['jpg', 'png'], resolution: '1280x720' },
|
||||
};
|
||||
}
|
||||
|
||||
// Private helpers
|
||||
|
||||
private validateMetadata(content: { title: string; description: string; tags?: string[] }): void {
|
||||
if (content.title.length > this.MAX_TITLE) {
|
||||
throw new Error(`Title exceeds ${this.MAX_TITLE} character limit`);
|
||||
}
|
||||
if (content.description.length > this.MAX_DESCRIPTION) {
|
||||
throw new Error(`Description exceeds ${this.MAX_DESCRIPTION} character limit`);
|
||||
}
|
||||
if (content.tags && content.tags.join(' ').length > this.MAX_TAGS) {
|
||||
throw new Error(`Tags exceed ${this.MAX_TAGS} character limit`);
|
||||
}
|
||||
}
|
||||
}
|
||||
228
src/modules/social-integration/social-integration.controller.ts
Normal file
228
src/modules/social-integration/social-integration.controller.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// Social Integration Controller - API endpoints
|
||||
// Path: src/modules/social-integration/social-integration.controller.ts
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { SocialIntegrationService } from './social-integration.service';
|
||||
import type { SocialPlatform } from './services/oauth.service';
|
||||
|
||||
@Controller('social')
|
||||
export class SocialIntegrationController {
|
||||
constructor(private readonly service: SocialIntegrationService) { }
|
||||
|
||||
// ========== OAUTH & ACCOUNTS ==========
|
||||
|
||||
@Get('providers')
|
||||
getAvailableProviders() {
|
||||
return this.service.getAvailableProviders();
|
||||
}
|
||||
|
||||
@Get('auth/:platform')
|
||||
getAuthorizationUrl(
|
||||
@Param('platform') platform: SocialPlatform,
|
||||
@Query('redirectUri') redirectUri: string,
|
||||
) {
|
||||
return this.service.getAuthorizationUrl(platform, redirectUri);
|
||||
}
|
||||
|
||||
@Post('connect/:platform')
|
||||
connectAccount(
|
||||
@Param('platform') platform: SocialPlatform,
|
||||
@Body() body: { userId: string; code: string; redirectUri: string },
|
||||
) {
|
||||
return this.service.connectAccount(body.userId, platform, body.code, body.redirectUri);
|
||||
}
|
||||
|
||||
@Delete('disconnect/:accountId')
|
||||
disconnectAccount(
|
||||
@Param('accountId') accountId: string,
|
||||
@Body() body: { userId: string },
|
||||
) {
|
||||
return this.service.disconnectAccount(body.userId, accountId);
|
||||
}
|
||||
|
||||
@Get('accounts/:userId')
|
||||
getConnectedAccounts(@Param('userId') userId: string) {
|
||||
return this.service.getConnectedAccounts(userId);
|
||||
}
|
||||
|
||||
// ========== PUBLISHING ==========
|
||||
|
||||
@Post('publish')
|
||||
publishToMultiplePlatforms(
|
||||
@Body() body: {
|
||||
userId: string;
|
||||
platforms: SocialPlatform[];
|
||||
content: {
|
||||
text: string;
|
||||
mediaUrls?: string[];
|
||||
mediaType?: 'image' | 'video';
|
||||
link?: string;
|
||||
hashtags?: string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
},
|
||||
) {
|
||||
return this.service.publishToMultiplePlatforms(body);
|
||||
}
|
||||
|
||||
@Post('schedule')
|
||||
schedulePost(
|
||||
@Body() body: {
|
||||
userId: string;
|
||||
platforms: SocialPlatform[];
|
||||
content: any;
|
||||
scheduledTime: string;
|
||||
},
|
||||
) {
|
||||
return this.service.schedulePost({
|
||||
...body,
|
||||
scheduledTime: new Date(body.scheduledTime),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('queue/:userId')
|
||||
getQueue(@Param('userId') userId: string) {
|
||||
return this.service.getQueue(userId);
|
||||
}
|
||||
|
||||
@Delete('queue/:userId/:postId')
|
||||
cancelScheduledPost(
|
||||
@Param('userId') userId: string,
|
||||
@Param('postId') postId: string,
|
||||
) {
|
||||
return { success: this.service.cancelScheduledPost(userId, postId) };
|
||||
}
|
||||
|
||||
@Post('queue/:userId/:postId/reschedule')
|
||||
reschedulePost(
|
||||
@Param('userId') userId: string,
|
||||
@Param('postId') postId: string,
|
||||
@Body() body: { newTime: string },
|
||||
) {
|
||||
return { success: this.service.reschedulePost(userId, postId, new Date(body.newTime)) };
|
||||
}
|
||||
|
||||
@Get('stats/:userId')
|
||||
getPublishingStats(@Param('userId') userId: string) {
|
||||
return this.service.getPublishingStats(userId);
|
||||
}
|
||||
|
||||
@Post('optimal-schedule')
|
||||
getOptimalSchedule(@Body() body: { platforms: SocialPlatform[] }) {
|
||||
return this.service.getOptimalSchedule(body.platforms);
|
||||
}
|
||||
|
||||
@Post('preview')
|
||||
previewContent(
|
||||
@Body() body: { content: any; platforms: SocialPlatform[] },
|
||||
) {
|
||||
return this.service.previewContent(body.content, body.platforms);
|
||||
}
|
||||
|
||||
// ========== PLATFORM CONSTRAINTS ==========
|
||||
|
||||
@Get('constraints/twitter')
|
||||
getTwitterConstraints() {
|
||||
return this.service.getTwitterConstraints();
|
||||
}
|
||||
|
||||
@Get('constraints/instagram')
|
||||
getInstagramConstraints() {
|
||||
return this.service.getInstagramConstraints();
|
||||
}
|
||||
|
||||
@Get('constraints/linkedin')
|
||||
getLinkedInConstraints() {
|
||||
return this.service.getLinkedInConstraints();
|
||||
}
|
||||
|
||||
@Get('constraints/facebook')
|
||||
getFacebookConstraints() {
|
||||
return this.service.getFacebookConstraints();
|
||||
}
|
||||
|
||||
@Get('constraints/tiktok')
|
||||
getTikTokConstraints() {
|
||||
return this.service.getTikTokConstraints();
|
||||
}
|
||||
|
||||
@Get('constraints/youtube')
|
||||
getYouTubeConstraints() {
|
||||
return this.service.getYouTubeConstraints();
|
||||
}
|
||||
|
||||
// ========== PLATFORM-SPECIFIC HELPERS ==========
|
||||
|
||||
@Post('twitter/thread-split')
|
||||
splitIntoThread(@Body() body: { text: string }) {
|
||||
return { tweets: this.service.splitIntoThread(body.text) };
|
||||
}
|
||||
|
||||
@Post('linkedin/format')
|
||||
formatForLinkedIn(@Body() body: { content: string }) {
|
||||
return this.service.formatForLinkedIn(body.content);
|
||||
}
|
||||
|
||||
@Post('tiktok/optimize-caption')
|
||||
optimizeTikTokCaption(@Body() body: { caption: string }) {
|
||||
return this.service.optimizeTikTokCaption(body.caption);
|
||||
}
|
||||
|
||||
@Get('tiktok/trending')
|
||||
getTrendingHashtags() {
|
||||
return this.service.getTrendingHashtags();
|
||||
}
|
||||
|
||||
@Post('youtube/optimize-title')
|
||||
optimizeYouTubeTitle(@Body() body: { title: string }) {
|
||||
return this.service.optimizeYouTubeTitle(body.title);
|
||||
}
|
||||
|
||||
@Get('youtube/categories')
|
||||
getYouTubeCategories() {
|
||||
return this.service.getYouTubeCategories();
|
||||
}
|
||||
|
||||
@Post('youtube/description')
|
||||
generateYouTubeDescription(@Body() body: { template: any }) {
|
||||
return { description: this.service.generateYouTubeDescription(body.template) };
|
||||
}
|
||||
|
||||
// ========== OPTIMAL TIMES ==========
|
||||
|
||||
@Get('optimal-times/instagram')
|
||||
getInstagramOptimalTimes() {
|
||||
return this.service.getInstagramOptimalTimes();
|
||||
}
|
||||
|
||||
@Get('optimal-times/tiktok')
|
||||
getTikTokBestTimes() {
|
||||
return this.service.getTikTokBestTimes();
|
||||
}
|
||||
|
||||
// ========== RECOMMENDATIONS ==========
|
||||
|
||||
@Get('recommendations/:platform')
|
||||
getContentRecommendations(@Param('platform') platform: SocialPlatform) {
|
||||
return this.service.getContentRecommendations(platform);
|
||||
}
|
||||
|
||||
// ========== ANALYTICS ==========
|
||||
|
||||
@Get('analytics/:userId/:platform')
|
||||
getAccountAnalytics(
|
||||
@Param('userId') userId: string,
|
||||
@Param('platform') platform: SocialPlatform,
|
||||
) {
|
||||
return this.service.getAccountAnalytics(userId, platform);
|
||||
}
|
||||
}
|
||||
33
src/modules/social-integration/social-integration.module.ts
Normal file
33
src/modules/social-integration/social-integration.module.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// Social Integration Module - OAuth connections and platform APIs
|
||||
// Path: src/modules/social-integration/social-integration.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../database/prisma.module';
|
||||
import { SocialIntegrationService } from './social-integration.service';
|
||||
import { SocialIntegrationController } from './social-integration.controller';
|
||||
import { OAuthService } from './services/oauth.service';
|
||||
import { TwitterApiService } from './services/twitter-api.service';
|
||||
import { InstagramApiService } from './services/instagram-api.service';
|
||||
import { LinkedInApiService } from './services/linkedin-api.service';
|
||||
import { FacebookApiService } from './services/facebook-api.service';
|
||||
import { TikTokApiService } from './services/tiktok-api.service';
|
||||
import { YouTubeApiService } from './services/youtube-api.service';
|
||||
import { AutoPublishService } from './services/auto-publish.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
SocialIntegrationService,
|
||||
OAuthService,
|
||||
TwitterApiService,
|
||||
InstagramApiService,
|
||||
LinkedInApiService,
|
||||
FacebookApiService,
|
||||
TikTokApiService,
|
||||
YouTubeApiService,
|
||||
AutoPublishService,
|
||||
],
|
||||
controllers: [SocialIntegrationController],
|
||||
exports: [SocialIntegrationService],
|
||||
})
|
||||
export class SocialIntegrationModule { }
|
||||
232
src/modules/social-integration/social-integration.service.ts
Normal file
232
src/modules/social-integration/social-integration.service.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
// Social Integration Service - Main orchestration
|
||||
// Path: src/modules/social-integration/social-integration.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { OAuthService, SocialPlatform, ConnectedAccount, OAuthProvider } from './services/oauth.service';
|
||||
import { TwitterApiService } from './services/twitter-api.service';
|
||||
import { InstagramApiService } from './services/instagram-api.service';
|
||||
import { LinkedInApiService } from './services/linkedin-api.service';
|
||||
import { FacebookApiService } from './services/facebook-api.service';
|
||||
import { TikTokApiService } from './services/tiktok-api.service';
|
||||
import { YouTubeApiService } from './services/youtube-api.service';
|
||||
import { AutoPublishService, PublishRequest, PublishResult, QueuedPost } from './services/auto-publish.service';
|
||||
|
||||
@Injectable()
|
||||
export class SocialIntegrationService {
|
||||
private readonly logger = new Logger(SocialIntegrationService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly oauthService: OAuthService,
|
||||
private readonly twitterApi: TwitterApiService,
|
||||
private readonly instagramApi: InstagramApiService,
|
||||
private readonly linkedinApi: LinkedInApiService,
|
||||
private readonly facebookApi: FacebookApiService,
|
||||
private readonly tiktokApi: TikTokApiService,
|
||||
private readonly youtubeApi: YouTubeApiService,
|
||||
private readonly autoPublishService: AutoPublishService,
|
||||
) { }
|
||||
|
||||
// ========== OAUTH & ACCOUNTS ==========
|
||||
|
||||
getAuthorizationUrl(platform: SocialPlatform, redirectUri: string) {
|
||||
return this.oauthService.getAuthorizationUrl(platform, redirectUri);
|
||||
}
|
||||
|
||||
async connectAccount(userId: string, platform: SocialPlatform, code: string, redirectUri: string) {
|
||||
return this.oauthService.connectAccount(userId, platform, code, redirectUri);
|
||||
}
|
||||
|
||||
disconnectAccount(userId: string, accountId: string) {
|
||||
return this.oauthService.disconnectAccount(userId, accountId);
|
||||
}
|
||||
|
||||
getConnectedAccounts(userId: string): ConnectedAccount[] {
|
||||
return this.oauthService.getConnectedAccounts(userId);
|
||||
}
|
||||
|
||||
getAvailableProviders(): OAuthProvider[] {
|
||||
return this.oauthService.getAvailableProviders();
|
||||
}
|
||||
|
||||
// ========== PUBLISHING ==========
|
||||
|
||||
async publishToMultiplePlatforms(request: PublishRequest): Promise<PublishResult[]> {
|
||||
return this.autoPublishService.publishToMultiplePlatforms(request);
|
||||
}
|
||||
|
||||
schedulePost(request: PublishRequest): QueuedPost[] {
|
||||
return this.autoPublishService.schedulePost(request);
|
||||
}
|
||||
|
||||
getQueue(userId: string): QueuedPost[] {
|
||||
return this.autoPublishService.getQueue(userId);
|
||||
}
|
||||
|
||||
cancelScheduledPost(userId: string, postId: string) {
|
||||
return this.autoPublishService.cancelScheduledPost(userId, postId);
|
||||
}
|
||||
|
||||
reschedulePost(userId: string, postId: string, newTime: Date) {
|
||||
return this.autoPublishService.reschedulePost(userId, postId, newTime);
|
||||
}
|
||||
|
||||
getPublishingStats(userId: string) {
|
||||
return this.autoPublishService.getPublishingStats(userId);
|
||||
}
|
||||
|
||||
getOptimalSchedule(platforms: SocialPlatform[]) {
|
||||
return this.autoPublishService.getOptimalSchedule(platforms);
|
||||
}
|
||||
|
||||
previewContent(content: any, platforms: SocialPlatform[]) {
|
||||
return this.autoPublishService.previewContent(content, platforms);
|
||||
}
|
||||
|
||||
// ========== PLATFORM-SPECIFIC ==========
|
||||
|
||||
// Twitter
|
||||
getTwitterConstraints() {
|
||||
return this.twitterApi.getConstraints();
|
||||
}
|
||||
|
||||
splitIntoThread(text: string) {
|
||||
return this.twitterApi.splitIntoThread(text);
|
||||
}
|
||||
|
||||
// Instagram
|
||||
getInstagramConstraints() {
|
||||
return this.instagramApi.getConstraints();
|
||||
}
|
||||
|
||||
getInstagramOptimalTimes() {
|
||||
return this.instagramApi.getOptimalPostingTimes();
|
||||
}
|
||||
|
||||
// LinkedIn
|
||||
getLinkedInConstraints() {
|
||||
return this.linkedinApi.getConstraints();
|
||||
}
|
||||
|
||||
formatForLinkedIn(content: string) {
|
||||
return this.linkedinApi.formatForLinkedIn(content);
|
||||
}
|
||||
|
||||
// Facebook
|
||||
getFacebookConstraints() {
|
||||
return this.facebookApi.getConstraints();
|
||||
}
|
||||
|
||||
// TikTok
|
||||
getTikTokConstraints() {
|
||||
return this.tiktokApi.getConstraints();
|
||||
}
|
||||
|
||||
getTikTokBestTimes() {
|
||||
return this.tiktokApi.getBestPostingTimes();
|
||||
}
|
||||
|
||||
getTrendingHashtags() {
|
||||
return this.tiktokApi.getTrendingHashtags();
|
||||
}
|
||||
|
||||
optimizeTikTokCaption(caption: string) {
|
||||
return this.tiktokApi.optimizeCaption(caption);
|
||||
}
|
||||
|
||||
// YouTube
|
||||
getYouTubeConstraints() {
|
||||
return this.youtubeApi.getConstraints();
|
||||
}
|
||||
|
||||
getYouTubeCategories() {
|
||||
return this.youtubeApi.getCategories();
|
||||
}
|
||||
|
||||
optimizeYouTubeTitle(title: string) {
|
||||
return this.youtubeApi.optimizeTitle(title);
|
||||
}
|
||||
|
||||
generateYouTubeDescription(template: any) {
|
||||
return this.youtubeApi.generateDescription(template);
|
||||
}
|
||||
|
||||
// ========== ANALYTICS ==========
|
||||
|
||||
async getAccountAnalytics(userId: string, platform: SocialPlatform): Promise<any> {
|
||||
const account = this.oauthService.getAccountByPlatform(userId, platform);
|
||||
if (!account) {
|
||||
throw new Error(`No ${platform} account connected`);
|
||||
}
|
||||
|
||||
// Aggregate metrics based on platform
|
||||
return {
|
||||
platform,
|
||||
account: {
|
||||
username: account.platformUsername,
|
||||
followers: account.metadata.followers,
|
||||
following: account.metadata.following,
|
||||
},
|
||||
recentPosts: [],
|
||||
engagement: {
|
||||
averageLikes: 0,
|
||||
averageComments: 0,
|
||||
averageShares: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ========== CONTENT RECOMMENDATIONS ==========
|
||||
|
||||
getContentRecommendations(platform: SocialPlatform): {
|
||||
tips: string[];
|
||||
bestPractices: string[];
|
||||
avoidList: string[];
|
||||
} {
|
||||
const recommendations: Record<SocialPlatform, any> = {
|
||||
twitter: {
|
||||
tips: ['Use trending hashtags', 'Post 3-5 times daily', 'Engage in replies'],
|
||||
bestPractices: ['Keep tweets concise', 'Use visuals', 'Ask questions'],
|
||||
avoidList: ['Too many hashtags (>3)', 'Automated replies', 'Link-only tweets'],
|
||||
},
|
||||
instagram: {
|
||||
tips: ['Post at peak hours', 'Use Reels for reach', 'Engage via Stories'],
|
||||
bestPractices: ['High-quality visuals', '3-5 hashtags', 'Consistent aesthetic'],
|
||||
avoidList: ['Over-editing', 'Irrelevant hashtags', 'Ignoring comments'],
|
||||
},
|
||||
linkedin: {
|
||||
tips: ['Post early morning', 'Share insights', 'Engage professionally'],
|
||||
bestPractices: ['Long-form content', 'Personal stories', 'Industry news'],
|
||||
avoidList: ['Overly promotional', 'Casual tone', 'Too frequent posting'],
|
||||
},
|
||||
facebook: {
|
||||
tips: ['Use video content', 'Post 1-2 times daily', 'Run polls'],
|
||||
bestPractices: ['Native video', 'Community building', 'Interactive content'],
|
||||
avoidList: ['Click-bait', 'Too many links', 'Controversial topics'],
|
||||
},
|
||||
tiktok: {
|
||||
tips: ['Jump on trends', 'Post 1-4 times daily', 'Use trending sounds'],
|
||||
bestPractices: ['Vertical video', 'Hook in first second', 'Authentic content'],
|
||||
avoidList: ['Low-quality video', 'Boring intros', 'No captions'],
|
||||
},
|
||||
youtube: {
|
||||
tips: ['SEO-optimize titles', 'Custom thumbnails', 'Consistent uploads'],
|
||||
bestPractices: ['Long-form content', 'Engaging scripts', 'Community tab'],
|
||||
avoidList: ['Click-bait', 'Irregular uploads', 'Poor audio quality'],
|
||||
},
|
||||
threads: {
|
||||
tips: ['Cross-post from Instagram', 'Be conversational', 'Short threads'],
|
||||
bestPractices: ['Timely content', 'Authentic voice', 'Engage with replies'],
|
||||
avoidList: ['Hard selling', 'Over-posting', 'Copied content'],
|
||||
},
|
||||
pinterest: {
|
||||
tips: ['Rich pins', 'SEO descriptions', 'Vertical images'],
|
||||
bestPractices: ['High-quality images', 'Keyword-rich', 'Board organization'],
|
||||
avoidList: ['Low resolution', 'No descriptions', 'Spammy pinning'],
|
||||
},
|
||||
};
|
||||
|
||||
return recommendations[platform] || recommendations.twitter;
|
||||
}
|
||||
}
|
||||
11
src/modules/source-accounts/index.ts
Normal file
11
src/modules/source-accounts/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Source Accounts Module - Index exports
|
||||
// Path: src/modules/source-accounts/index.ts
|
||||
|
||||
export * from './source-accounts.module';
|
||||
export * from './source-accounts.service';
|
||||
export * from './source-accounts.controller';
|
||||
export * from './services/content-parser.service';
|
||||
export * from './services/viral-post-analyzer.service';
|
||||
export * from './services/structure-skeleton.service';
|
||||
export * from './services/gold-post.service';
|
||||
export * from './services/content-rewriter.service';
|
||||
247
src/modules/source-accounts/services/content-parser.service.ts
Normal file
247
src/modules/source-accounts/services/content-parser.service.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
// Content Parser Service - Parse and extract content from social media posts
|
||||
// Path: src/modules/source-accounts/services/content-parser.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface ParsedContent {
|
||||
id: string;
|
||||
platform: SocialPlatform;
|
||||
originalUrl: string;
|
||||
author: {
|
||||
username: string;
|
||||
displayName: string;
|
||||
followerCount?: number;
|
||||
verified: boolean;
|
||||
};
|
||||
content: {
|
||||
text: string;
|
||||
cleanText: string; // Without hashtags, mentions, URLs
|
||||
hashtags: string[];
|
||||
mentions: string[];
|
||||
urls: string[];
|
||||
emojis: string[];
|
||||
mediaType: 'text' | 'image' | 'video' | 'carousel' | 'thread';
|
||||
mediaUrls: string[];
|
||||
};
|
||||
engagement: {
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
saves?: number;
|
||||
views?: number;
|
||||
engagementRate: number;
|
||||
};
|
||||
metadata: {
|
||||
postedAt: Date;
|
||||
language: string;
|
||||
isThread: boolean;
|
||||
threadLength?: number;
|
||||
replyTo?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type SocialPlatform =
|
||||
| 'twitter'
|
||||
| 'linkedin'
|
||||
| 'instagram'
|
||||
| 'facebook'
|
||||
| 'tiktok'
|
||||
| 'youtube'
|
||||
| 'threads'
|
||||
| 'pinterest';
|
||||
|
||||
export interface ParseResult {
|
||||
success: boolean;
|
||||
content?: ParsedContent;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentParserService {
|
||||
private readonly logger = new Logger(ContentParserService.name);
|
||||
|
||||
/**
|
||||
* Parse a social media post URL
|
||||
*/
|
||||
async parseUrl(url: string): Promise<ParseResult> {
|
||||
const platform = this.detectPlatform(url);
|
||||
|
||||
if (!platform) {
|
||||
return { success: false, error: 'Unsupported platform' };
|
||||
}
|
||||
|
||||
// In production, this would fetch and parse the actual content
|
||||
// For now, return mock structure
|
||||
const mockContent = this.generateMockContent(url, platform);
|
||||
|
||||
return { success: true, content: mockContent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw text content (for copy-paste scenarios)
|
||||
*/
|
||||
parseText(text: string, platform?: SocialPlatform): ParsedContent {
|
||||
const hashtags = text.match(/#\w+/g) || [];
|
||||
const mentions = text.match(/@\w+/g) || [];
|
||||
const urls = text.match(/https?:\/\/\S+/g) || [];
|
||||
const emojis = text.match(/[\u{1F300}-\u{1F9FF}]/gu) || [];
|
||||
|
||||
// Clean text - remove hashtags, mentions, URLs
|
||||
let cleanText = text;
|
||||
hashtags.forEach((h) => cleanText = cleanText.replace(h, ''));
|
||||
mentions.forEach((m) => cleanText = cleanText.replace(m, ''));
|
||||
urls.forEach((u) => cleanText = cleanText.replace(u, ''));
|
||||
cleanText = cleanText.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return {
|
||||
id: `parsed-${Date.now()}`,
|
||||
platform: platform || 'twitter',
|
||||
originalUrl: '',
|
||||
author: {
|
||||
username: 'unknown',
|
||||
displayName: 'Unknown',
|
||||
verified: false,
|
||||
},
|
||||
content: {
|
||||
text,
|
||||
cleanText,
|
||||
hashtags,
|
||||
mentions,
|
||||
urls,
|
||||
emojis,
|
||||
mediaType: 'text',
|
||||
mediaUrls: [],
|
||||
},
|
||||
engagement: {
|
||||
likes: 0,
|
||||
comments: 0,
|
||||
shares: 0,
|
||||
engagementRate: 0,
|
||||
},
|
||||
metadata: {
|
||||
postedAt: new Date(),
|
||||
language: this.detectLanguage(text),
|
||||
isThread: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thread from multiple posts
|
||||
*/
|
||||
parseThread(posts: string[]): ParsedContent {
|
||||
const combined = posts.join('\n\n---\n\n');
|
||||
const parsed = this.parseText(combined, 'twitter');
|
||||
|
||||
parsed.metadata.isThread = true;
|
||||
parsed.metadata.threadLength = posts.length;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect platform from URL
|
||||
*/
|
||||
detectPlatform(url: string): SocialPlatform | null {
|
||||
const patterns: Record<SocialPlatform, RegExp> = {
|
||||
twitter: /twitter\.com|x\.com/i,
|
||||
linkedin: /linkedin\.com/i,
|
||||
instagram: /instagram\.com/i,
|
||||
facebook: /facebook\.com|fb\.com/i,
|
||||
tiktok: /tiktok\.com/i,
|
||||
youtube: /youtube\.com|youtu\.be/i,
|
||||
threads: /threads\.net/i,
|
||||
pinterest: /pinterest\./i,
|
||||
};
|
||||
|
||||
for (const [platform, pattern] of Object.entries(patterns)) {
|
||||
if (pattern.test(url)) {
|
||||
return platform as SocialPlatform;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract post ID from URL
|
||||
*/
|
||||
extractPostId(url: string, platform: SocialPlatform): string | null {
|
||||
const patterns: Record<SocialPlatform, RegExp> = {
|
||||
twitter: /status\/(\d+)/,
|
||||
linkedin: /activity-(\d+)|posts\/([a-zA-Z0-9-]+)/,
|
||||
instagram: /\/p\/([a-zA-Z0-9_-]+)/,
|
||||
facebook: /\/posts\/(\d+)|\/permalink\/(\d+)/,
|
||||
tiktok: /video\/(\d+)/,
|
||||
youtube: /v=([a-zA-Z0-9_-]+)|\/shorts\/([a-zA-Z0-9_-]+)/,
|
||||
threads: /post\/([a-zA-Z0-9_-]+)/,
|
||||
pinterest: /pin\/(\d+)/,
|
||||
};
|
||||
|
||||
const match = url.match(patterns[platform]);
|
||||
if (match) {
|
||||
return match[1] || match[2] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect language from text
|
||||
*/
|
||||
private detectLanguage(text: string): string {
|
||||
// Simple language detection based on character patterns
|
||||
if (/[\u4e00-\u9fff]/.test(text)) return 'zh';
|
||||
if (/[\u3040-\u309f\u30a0-\u30ff]/.test(text)) return 'ja';
|
||||
if (/[\u0600-\u06ff]/.test(text)) return 'ar';
|
||||
if (/[\u0400-\u04ff]/.test(text)) return 'ru';
|
||||
if (/[çğıöşü]/i.test(text)) return 'tr';
|
||||
if (/[áéíóúñ]/i.test(text)) return 'es';
|
||||
if (/[àâçéèêëîïôûùüÿœæ]/i.test(text)) return 'fr';
|
||||
if (/[äöüß]/i.test(text)) return 'de';
|
||||
if (/[ãõç]/i.test(text)) return 'pt';
|
||||
|
||||
return 'en';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate mock content for demo purposes
|
||||
*/
|
||||
private generateMockContent(url: string, platform: SocialPlatform): ParsedContent {
|
||||
const postId = this.extractPostId(url, platform) || `mock-${Date.now()}`;
|
||||
|
||||
return {
|
||||
id: postId,
|
||||
platform,
|
||||
originalUrl: url,
|
||||
author: {
|
||||
username: `user_${platform}`,
|
||||
displayName: `Demo User (${platform})`,
|
||||
followerCount: Math.floor(Math.random() * 100000 + 1000),
|
||||
verified: Math.random() > 0.7,
|
||||
},
|
||||
content: {
|
||||
text: 'This is a parsed social media post. #ContentHunter #Demo',
|
||||
cleanText: 'This is a parsed social media post.',
|
||||
hashtags: ['#ContentHunter', '#Demo'],
|
||||
mentions: [],
|
||||
urls: [],
|
||||
emojis: [],
|
||||
mediaType: 'text',
|
||||
mediaUrls: [],
|
||||
},
|
||||
engagement: {
|
||||
likes: Math.floor(Math.random() * 10000),
|
||||
comments: Math.floor(Math.random() * 500),
|
||||
shares: Math.floor(Math.random() * 1000),
|
||||
views: Math.floor(Math.random() * 100000),
|
||||
engagementRate: Math.random() * 10,
|
||||
},
|
||||
metadata: {
|
||||
postedAt: new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000),
|
||||
language: 'en',
|
||||
isThread: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
495
src/modules/source-accounts/services/content-rewriter.service.ts
Normal file
495
src/modules/source-accounts/services/content-rewriter.service.ts
Normal file
@@ -0,0 +1,495 @@
|
||||
// Content Rewriter Service - Plagiarism-free content rewriting
|
||||
// Path: src/modules/source-accounts/services/content-rewriter.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface RewriteOptions {
|
||||
preserveTone: boolean;
|
||||
preserveStructure: boolean;
|
||||
targetPlatform?: string;
|
||||
style?: 'professional' | 'casual' | 'humorous' | 'educational';
|
||||
length?: 'shorter' | 'same' | 'longer';
|
||||
addPersonalization?: boolean;
|
||||
}
|
||||
|
||||
export interface RewriteResult {
|
||||
original: string;
|
||||
rewritten: string;
|
||||
similarity: number; // 0-100, lower is more unique
|
||||
changes: ChangeDetail[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export interface ChangeDetail {
|
||||
type: 'synonym' | 'restructure' | 'paraphrase' | 'expand' | 'condense';
|
||||
original: string;
|
||||
replacement: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContentRewriterService {
|
||||
private readonly logger = new Logger(ContentRewriterService.name);
|
||||
|
||||
// Synonym database for variety
|
||||
private readonly synonyms: Record<string, string[]> = {
|
||||
'important': ['crucial', 'essential', 'vital', 'significant', 'key'],
|
||||
'great': ['excellent', 'outstanding', 'remarkable', 'exceptional', 'superb'],
|
||||
'good': ['solid', 'strong', 'effective', 'valuable', 'beneficial'],
|
||||
'bad': ['poor', 'weak', 'ineffective', 'problematic', 'flawed'],
|
||||
'big': ['substantial', 'significant', 'major', 'considerable', 'massive'],
|
||||
'small': ['minor', 'modest', 'limited', 'slight', 'minimal'],
|
||||
'fast': ['quick', 'rapid', 'swift', 'speedy', 'prompt'],
|
||||
'slow': ['gradual', 'steady', 'measured', 'deliberate', 'unhurried'],
|
||||
'easy': ['simple', 'straightforward', 'effortless', 'seamless', 'accessible'],
|
||||
'hard': ['challenging', 'difficult', 'demanding', 'complex', 'tough'],
|
||||
'think': ['believe', 'consider', 'feel', 'reckon', 'suspect'],
|
||||
'know': ['understand', 'recognize', 'realize', 'grasp', 'comprehend'],
|
||||
'show': ['demonstrate', 'reveal', 'illustrate', 'display', 'exhibit'],
|
||||
'make': ['create', 'build', 'develop', 'craft', 'produce'],
|
||||
'get': ['obtain', 'acquire', 'gain', 'secure', 'achieve'],
|
||||
'use': ['utilize', 'employ', 'leverage', 'apply', 'implement'],
|
||||
'help': ['assist', 'support', 'aid', 'enable', 'facilitate'],
|
||||
'need': ['require', 'must have', 'demand', 'call for', 'necessitate'],
|
||||
'want': ['desire', 'wish', 'aim', 'seek', 'aspire'],
|
||||
'start': ['begin', 'launch', 'initiate', 'commence', 'kick off'],
|
||||
'stop': ['cease', 'halt', 'end', 'discontinue', 'pause'],
|
||||
'change': ['transform', 'modify', 'alter', 'shift', 'evolve'],
|
||||
'improve': ['enhance', 'boost', 'elevate', 'upgrade', 'optimize'],
|
||||
'increase': ['grow', 'expand', 'rise', 'surge', 'escalate'],
|
||||
'decrease': ['reduce', 'lower', 'diminish', 'drop', 'decline'],
|
||||
};
|
||||
|
||||
// Sentence starters for variety
|
||||
private readonly sentenceStarters: Record<string, string[]> = {
|
||||
cause: ['Because', 'Since', 'As', 'Given that', 'Due to the fact'],
|
||||
contrast: ['However', 'On the other hand', 'Conversely', 'Yet', 'Despite this'],
|
||||
addition: ['Additionally', 'Furthermore', 'Moreover', 'Also', 'In addition'],
|
||||
example: ['For instance', 'For example', 'As an illustration', 'Consider', 'Take'],
|
||||
conclusion: ['Therefore', 'Thus', 'Consequently', 'As a result', 'In conclusion'],
|
||||
emphasis: ['Importantly', 'Notably', 'Significantly', 'Crucially', 'Essentially'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Rewrite content to be unique while preserving meaning
|
||||
*/
|
||||
async rewrite(
|
||||
content: string,
|
||||
options: RewriteOptions = {
|
||||
preserveTone: true,
|
||||
preserveStructure: true,
|
||||
},
|
||||
): Promise<RewriteResult> {
|
||||
const changes: ChangeDetail[] = [];
|
||||
let rewritten = content;
|
||||
|
||||
// Step 1: Replace common words with synonyms
|
||||
rewritten = this.applySynonyms(rewritten, changes);
|
||||
|
||||
// Step 2: Restructure sentences
|
||||
if (!options.preserveStructure) {
|
||||
rewritten = this.restructureSentences(rewritten, changes);
|
||||
}
|
||||
|
||||
// Step 3: Vary sentence starters
|
||||
rewritten = this.varySentenceStarters(rewritten, changes);
|
||||
|
||||
// Step 4: Adjust length if requested
|
||||
if (options.length === 'shorter') {
|
||||
rewritten = this.condenseContent(rewritten, changes);
|
||||
} else if (options.length === 'longer') {
|
||||
rewritten = this.expandContent(rewritten, changes);
|
||||
}
|
||||
|
||||
// Step 5: Platform adaptation
|
||||
if (options.targetPlatform) {
|
||||
rewritten = this.adaptForPlatform(rewritten, options.targetPlatform, changes);
|
||||
}
|
||||
|
||||
// Step 6: Style adjustment
|
||||
if (options.style) {
|
||||
rewritten = this.adjustStyle(rewritten, options.style, changes);
|
||||
}
|
||||
|
||||
// Calculate similarity
|
||||
const similarity = this.calculateSimilarity(content, rewritten);
|
||||
|
||||
// Generate suggestions for further improvement
|
||||
const suggestions = this.generateSuggestions(similarity, changes);
|
||||
|
||||
return {
|
||||
original: content,
|
||||
rewritten,
|
||||
similarity,
|
||||
changes,
|
||||
suggestions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate multiple variations
|
||||
*/
|
||||
async generateVariations(
|
||||
content: string,
|
||||
count: number = 3,
|
||||
): Promise<RewriteResult[]> {
|
||||
const variations: RewriteResult[] = [];
|
||||
const styles: RewriteOptions['style'][] = ['professional', 'casual', 'educational'];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const result = await this.rewrite(content, {
|
||||
preserveTone: false,
|
||||
preserveStructure: Math.random() > 0.5,
|
||||
style: styles[i % styles.length],
|
||||
});
|
||||
variations.push(result);
|
||||
}
|
||||
|
||||
return variations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check plagiarism risk
|
||||
*/
|
||||
checkPlagiarismRisk(
|
||||
original: string,
|
||||
rewritten: string,
|
||||
): {
|
||||
risk: 'low' | 'medium' | 'high';
|
||||
similarity: number;
|
||||
flaggedPhrases: string[];
|
||||
recommendations: string[];
|
||||
} {
|
||||
const similarity = this.calculateSimilarity(original, rewritten);
|
||||
const flaggedPhrases = this.findMatchingPhrases(original, rewritten);
|
||||
|
||||
let risk: 'low' | 'medium' | 'high';
|
||||
if (similarity <= 30) risk = 'low';
|
||||
else if (similarity <= 60) risk = 'medium';
|
||||
else risk = 'high';
|
||||
|
||||
const recommendations: string[] = [];
|
||||
if (risk !== 'low') {
|
||||
recommendations.push('Consider rewording the flagged phrases');
|
||||
recommendations.push('Add personal examples or anecdotes');
|
||||
recommendations.push('Change the structure of key paragraphs');
|
||||
if (flaggedPhrases.length > 3) {
|
||||
recommendations.push('The content needs more significant rewriting');
|
||||
}
|
||||
}
|
||||
|
||||
return { risk, similarity, flaggedPhrases, recommendations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate AI prompt for advanced rewriting
|
||||
*/
|
||||
generateRewritePrompt(
|
||||
content: string,
|
||||
options: RewriteOptions,
|
||||
): string {
|
||||
const styleGuide = {
|
||||
professional: 'Use formal, business-appropriate language',
|
||||
casual: 'Use conversational, friendly tone with contractions',
|
||||
humorous: 'Add wit and playful language',
|
||||
educational: 'Be clear, instructive, and methodical',
|
||||
};
|
||||
|
||||
return `Rewrite the following content to be completely unique while preserving the core message.
|
||||
|
||||
ORIGINAL CONTENT:
|
||||
${content}
|
||||
|
||||
REQUIREMENTS:
|
||||
1. ${options.preserveTone ? 'Maintain the original tone' : 'Adjust tone as needed'}
|
||||
2. ${options.preserveStructure ? 'Keep similar structure' : 'Feel free to restructure'}
|
||||
3. ${options.style ? styleGuide[options.style] : 'Match the original style'}
|
||||
4. ${options.length === 'shorter' ? 'Make it 30% shorter' : options.length === 'longer' ? 'Expand with additional detail' : 'Similar length'}
|
||||
5. ${options.targetPlatform ? `Optimize for ${options.targetPlatform}` : ''}
|
||||
6. ${options.addPersonalization ? 'Add personal touches and [PERSONALIZATION_PLACEHOLDER] where relevant' : ''}
|
||||
|
||||
IMPORTANT:
|
||||
- Do NOT copy phrases directly
|
||||
- Change sentence structures
|
||||
- Use different words with same meaning
|
||||
- Add value where possible
|
||||
- Make it feel original and authentic`;
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private applySynonyms(text: string, changes: ChangeDetail[]): string {
|
||||
let result = text;
|
||||
|
||||
for (const [word, synonymList] of Object.entries(this.synonyms)) {
|
||||
const regex = new RegExp(`\\b${word}\\b`, 'gi');
|
||||
const matches = text.match(regex);
|
||||
|
||||
if (matches) {
|
||||
// Replace first occurrence with a synonym
|
||||
const synonym = synonymList[Math.floor(Math.random() * synonymList.length)];
|
||||
result = result.replace(regex, (match) => {
|
||||
// Preserve case
|
||||
if (match[0] === match[0].toUpperCase()) {
|
||||
return synonym.charAt(0).toUpperCase() + synonym.slice(1);
|
||||
}
|
||||
return synonym;
|
||||
});
|
||||
|
||||
changes.push({
|
||||
type: 'synonym',
|
||||
original: word,
|
||||
replacement: synonym,
|
||||
reason: 'Word variety for uniqueness',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private restructureSentences(text: string, changes: ChangeDetail[]): string {
|
||||
const sentences = text.split(/(?<=[.!?])\s+/);
|
||||
const restructured: string[] = [];
|
||||
|
||||
for (const sentence of sentences) {
|
||||
// Randomly restructure some sentences
|
||||
if (Math.random() > 0.6 && sentence.includes(',')) {
|
||||
// Move clause
|
||||
const clauses = sentence.split(',');
|
||||
if (clauses.length >= 2) {
|
||||
const reordered = [...clauses.slice(1), clauses[0]].join(', ').trim();
|
||||
restructured.push(reordered);
|
||||
changes.push({
|
||||
type: 'restructure',
|
||||
original: sentence,
|
||||
replacement: reordered,
|
||||
reason: 'Clause reordering for uniqueness',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
restructured.push(sentence);
|
||||
}
|
||||
|
||||
return restructured.join(' ');
|
||||
}
|
||||
|
||||
private varySentenceStarters(text: string, changes: ChangeDetail[]): string {
|
||||
let result = text;
|
||||
|
||||
// Find and vary consecutive sentence starters
|
||||
const starterPatterns = Object.entries(this.sentenceStarters);
|
||||
|
||||
for (const [type, starters] of starterPatterns) {
|
||||
for (const starter of starters) {
|
||||
const regex = new RegExp(`^${starter}\\b`, 'gim');
|
||||
if (result.match(regex)) {
|
||||
const alternatives = starters.filter((s) => s !== starter);
|
||||
const newStarter = alternatives[Math.floor(Math.random() * alternatives.length)];
|
||||
result = result.replace(regex, newStarter);
|
||||
|
||||
changes.push({
|
||||
type: 'paraphrase',
|
||||
original: starter,
|
||||
replacement: newStarter,
|
||||
reason: 'Sentence starter variety',
|
||||
});
|
||||
break; // Only change one per type
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private condenseContent(text: string, changes: ChangeDetail[]): string {
|
||||
// Remove filler words
|
||||
const fillers = ['basically', 'actually', 'essentially', 'really', 'very', 'just'];
|
||||
let result = text;
|
||||
|
||||
for (const filler of fillers) {
|
||||
const regex = new RegExp(`\\b${filler}\\b\\s*`, 'gi');
|
||||
if (result.match(regex)) {
|
||||
result = result.replace(regex, '');
|
||||
changes.push({
|
||||
type: 'condense',
|
||||
original: filler,
|
||||
replacement: '',
|
||||
reason: 'Removed filler word',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private expandContent(text: string, changes: ChangeDetail[]): string {
|
||||
// Add transitional phrases
|
||||
const sentences = text.split(/(?<=[.!?])\s+/);
|
||||
const expanded: string[] = [];
|
||||
|
||||
for (let i = 0; i < sentences.length; i++) {
|
||||
if (i > 0 && Math.random() > 0.7) {
|
||||
const transitions = ['Moreover,', 'Additionally,', 'Furthermore,', 'In fact,'];
|
||||
const transition = transitions[Math.floor(Math.random() * transitions.length)];
|
||||
expanded.push(transition + ' ' + sentences[i].toLowerCase());
|
||||
changes.push({
|
||||
type: 'expand',
|
||||
original: sentences[i],
|
||||
replacement: transition + ' ' + sentences[i].toLowerCase(),
|
||||
reason: 'Added transition for flow',
|
||||
});
|
||||
} else {
|
||||
expanded.push(sentences[i]);
|
||||
}
|
||||
}
|
||||
|
||||
return expanded.join(' ');
|
||||
}
|
||||
|
||||
private adaptForPlatform(
|
||||
text: string,
|
||||
platform: string,
|
||||
changes: ChangeDetail[],
|
||||
): string {
|
||||
let result = text;
|
||||
|
||||
switch (platform.toLowerCase()) {
|
||||
case 'twitter':
|
||||
// Shorten if too long
|
||||
if (result.length > 280) {
|
||||
result = result.substring(0, 270) + '...';
|
||||
changes.push({
|
||||
type: 'condense',
|
||||
original: text,
|
||||
replacement: result,
|
||||
reason: 'Truncated for Twitter character limit',
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'linkedin':
|
||||
// Add professional opening if missing
|
||||
if (!/^(I |We |The |In |At |As )/i.test(result)) {
|
||||
result = 'Here\'s something worth considering: ' + result;
|
||||
changes.push({
|
||||
type: 'expand',
|
||||
original: '',
|
||||
replacement: 'Here\'s something worth considering:',
|
||||
reason: 'Added LinkedIn-style opener',
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'instagram':
|
||||
// Add emoji breaks
|
||||
const sentences = result.split('. ');
|
||||
if (sentences.length > 3) {
|
||||
result = sentences.join('. \n\n');
|
||||
changes.push({
|
||||
type: 'restructure',
|
||||
original: text,
|
||||
replacement: result,
|
||||
reason: 'Added line breaks for Instagram readability',
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private adjustStyle(
|
||||
text: string,
|
||||
style: RewriteOptions['style'],
|
||||
changes: ChangeDetail[],
|
||||
): string {
|
||||
let result = text;
|
||||
|
||||
switch (style) {
|
||||
case 'casual':
|
||||
result = result.replace(/\b(is not|are not|do not|does not|cannot)\b/g, (match) => {
|
||||
const contractions: Record<string, string> = {
|
||||
'is not': "isn't",
|
||||
'are not': "aren't",
|
||||
'do not': "don't",
|
||||
'does not': "doesn't",
|
||||
'cannot': "can't",
|
||||
};
|
||||
return contractions[match] || match;
|
||||
});
|
||||
break;
|
||||
|
||||
case 'professional':
|
||||
result = result.replace(/\b(isn't|aren't|don't|doesn't|can't)\b/g, (match) => {
|
||||
const expanded: Record<string, string> = {
|
||||
"isn't": 'is not',
|
||||
"aren't": 'are not',
|
||||
"don't": 'do not',
|
||||
"doesn't": 'does not',
|
||||
"can't": 'cannot',
|
||||
};
|
||||
return expanded[match] || match;
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private calculateSimilarity(original: string, rewritten: string): number {
|
||||
const originalWords = original.toLowerCase().split(/\s+/);
|
||||
const rewrittenWords = rewritten.toLowerCase().split(/\s+/);
|
||||
|
||||
const originalSet = new Set(originalWords);
|
||||
const rewrittenSet = new Set(rewrittenWords);
|
||||
|
||||
let matching = 0;
|
||||
for (const word of rewrittenWords) {
|
||||
if (originalSet.has(word)) matching++;
|
||||
}
|
||||
|
||||
const similarity = (matching / Math.max(originalWords.length, rewrittenWords.length)) * 100;
|
||||
return Math.round(similarity);
|
||||
}
|
||||
|
||||
private findMatchingPhrases(original: string, rewritten: string): string[] {
|
||||
const flagged: string[] = [];
|
||||
const phrases = original.split(/[.!?]+/).filter((p) => p.trim().length > 0);
|
||||
|
||||
for (const phrase of phrases) {
|
||||
const cleanPhrase = phrase.trim().toLowerCase();
|
||||
if (cleanPhrase.length > 20 && rewritten.toLowerCase().includes(cleanPhrase)) {
|
||||
flagged.push(phrase.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return flagged;
|
||||
}
|
||||
|
||||
private generateSuggestions(
|
||||
similarity: number,
|
||||
changes: ChangeDetail[],
|
||||
): string[] {
|
||||
const suggestions: string[] = [];
|
||||
|
||||
if (similarity > 50) {
|
||||
suggestions.push('Consider rewriting longer phrases, not just individual words');
|
||||
suggestions.push('Try restructuring the overall flow of arguments');
|
||||
}
|
||||
|
||||
if (changes.filter((c) => c.type === 'synonym').length < 5) {
|
||||
suggestions.push('More synonym replacements would increase uniqueness');
|
||||
}
|
||||
|
||||
if (!changes.some((c) => c.type === 'restructure')) {
|
||||
suggestions.push('Consider restructuring some sentences for variety');
|
||||
}
|
||||
|
||||
suggestions.push('Adding personal examples or data points increases originality');
|
||||
suggestions.push('Consider adding your unique perspective or angle');
|
||||
|
||||
return suggestions.slice(0, 4);
|
||||
}
|
||||
}
|
||||
342
src/modules/source-accounts/services/gold-post.service.ts
Normal file
342
src/modules/source-accounts/services/gold-post.service.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
// Gold Post Service - Track and manage top-performing posts
|
||||
// Path: src/modules/source-accounts/services/gold-post.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../database/prisma.service';
|
||||
import { ParsedContent } from './content-parser.service';
|
||||
import { ViralAnalysis } from './viral-post-analyzer.service';
|
||||
|
||||
export interface GoldPost {
|
||||
id: string;
|
||||
originalUrl: string;
|
||||
platform: string;
|
||||
author: string;
|
||||
content: string;
|
||||
viralScore: number;
|
||||
engagement: {
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
engagementRate: number;
|
||||
};
|
||||
analysis: ViralAnalysis;
|
||||
spinOffs: SpinOff[];
|
||||
tags: string[];
|
||||
category: GoldPostCategory;
|
||||
notes: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SpinOff {
|
||||
id: string;
|
||||
goldPostId: string;
|
||||
platform: string;
|
||||
content: string;
|
||||
performance?: {
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
comparisonToOriginal: number; // percentage
|
||||
};
|
||||
createdAt: Date;
|
||||
publishedAt?: Date;
|
||||
}
|
||||
|
||||
export type GoldPostCategory =
|
||||
| 'educational'
|
||||
| 'storytelling'
|
||||
| 'promotional'
|
||||
| 'engagement'
|
||||
| 'viral'
|
||||
| 'authority'
|
||||
| 'controversy'
|
||||
| 'inspiration';
|
||||
|
||||
export interface GoldPostFilters {
|
||||
platform?: string;
|
||||
category?: GoldPostCategory;
|
||||
minViralScore?: number;
|
||||
tags?: string[];
|
||||
author?: string;
|
||||
dateRange?: { start: Date; end: Date };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GoldPostService {
|
||||
private readonly logger = new Logger(GoldPostService.name);
|
||||
|
||||
// In-memory storage for demo (would use database in production)
|
||||
private goldPosts: Map<string, GoldPost> = new Map();
|
||||
private readonly GOLD_THRESHOLD = 70; // Minimum viral score to be "gold"
|
||||
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
/**
|
||||
* Save a post as gold
|
||||
*/
|
||||
async saveAsGold(
|
||||
parsed: ParsedContent,
|
||||
analysis: ViralAnalysis,
|
||||
options?: {
|
||||
category?: GoldPostCategory;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
},
|
||||
): Promise<GoldPost> {
|
||||
const goldPost: GoldPost = {
|
||||
id: `gold-${Date.now()}`,
|
||||
originalUrl: parsed.originalUrl,
|
||||
platform: parsed.platform,
|
||||
author: parsed.author.username,
|
||||
content: parsed.content.text,
|
||||
viralScore: analysis.viralScore,
|
||||
engagement: parsed.engagement,
|
||||
analysis,
|
||||
spinOffs: [],
|
||||
tags: options?.tags || [],
|
||||
category: options?.category || this.detectCategory(parsed, analysis),
|
||||
notes: options?.notes || '',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
this.goldPosts.set(goldPost.id, goldPost);
|
||||
this.logger.log(`Saved gold post: ${goldPost.id} with score ${goldPost.viralScore}`);
|
||||
|
||||
return goldPost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if post qualifies as gold
|
||||
*/
|
||||
qualifiesAsGold(analysis: ViralAnalysis): boolean {
|
||||
return analysis.viralScore >= this.GOLD_THRESHOLD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gold post by ID
|
||||
*/
|
||||
getById(id: string): GoldPost | null {
|
||||
return this.goldPosts.get(id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List gold posts with filters
|
||||
*/
|
||||
list(filters?: GoldPostFilters): GoldPost[] {
|
||||
let posts = Array.from(this.goldPosts.values());
|
||||
|
||||
if (filters) {
|
||||
if (filters.platform) {
|
||||
posts = posts.filter((p) => p.platform === filters.platform);
|
||||
}
|
||||
if (filters.category) {
|
||||
posts = posts.filter((p) => p.category === filters.category);
|
||||
}
|
||||
if (filters.minViralScore) {
|
||||
posts = posts.filter((p) => p.viralScore >= filters.minViralScore!);
|
||||
}
|
||||
if (filters.tags?.length) {
|
||||
posts = posts.filter((p) =>
|
||||
filters.tags!.some((t) => p.tags.includes(t))
|
||||
);
|
||||
}
|
||||
if (filters.author) {
|
||||
posts = posts.filter((p) =>
|
||||
p.author.toLowerCase().includes(filters.author!.toLowerCase())
|
||||
);
|
||||
}
|
||||
if (filters.dateRange) {
|
||||
posts = posts.filter((p) =>
|
||||
p.createdAt >= filters.dateRange!.start &&
|
||||
p.createdAt <= filters.dateRange!.end
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return posts.sort((a, b) => b.viralScore - a.viralScore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add spin-off to gold post
|
||||
*/
|
||||
async addSpinOff(
|
||||
goldPostId: string,
|
||||
spinOff: Omit<SpinOff, 'id' | 'goldPostId' | 'createdAt'>,
|
||||
): Promise<SpinOff | null> {
|
||||
const goldPost = this.goldPosts.get(goldPostId);
|
||||
if (!goldPost) return null;
|
||||
|
||||
const newSpinOff: SpinOff = {
|
||||
id: `spinoff-${Date.now()}`,
|
||||
goldPostId,
|
||||
...spinOff,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
goldPost.spinOffs.push(newSpinOff);
|
||||
goldPost.updatedAt = new Date();
|
||||
|
||||
return newSpinOff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update spin-off performance
|
||||
*/
|
||||
updateSpinOffPerformance(
|
||||
goldPostId: string,
|
||||
spinOffId: string,
|
||||
performance: SpinOff['performance'],
|
||||
): boolean {
|
||||
const goldPost = this.goldPosts.get(goldPostId);
|
||||
if (!goldPost) return false;
|
||||
|
||||
const spinOff = goldPost.spinOffs.find((s) => s.id === spinOffId);
|
||||
if (!spinOff) return false;
|
||||
|
||||
spinOff.performance = performance;
|
||||
goldPost.updatedAt = new Date();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get spin-off suggestions based on gold post
|
||||
*/
|
||||
getSpinOffSuggestions(goldPostId: string): {
|
||||
platforms: { platform: string; angle: string }[];
|
||||
variations: string[];
|
||||
timingRecommendations: string[];
|
||||
} {
|
||||
const goldPost = this.goldPosts.get(goldPostId);
|
||||
if (!goldPost) {
|
||||
return { platforms: [], variations: [], timingRecommendations: [] };
|
||||
}
|
||||
|
||||
const platforms = [
|
||||
{ platform: 'twitter', angle: 'Thread format with key points' },
|
||||
{ platform: 'linkedin', angle: 'Professional story angle' },
|
||||
{ platform: 'instagram', angle: 'Carousel with visual tips' },
|
||||
{ platform: 'tiktok', angle: 'Quick video walkthrough' },
|
||||
{ platform: 'threads', angle: 'Casual conversation style' },
|
||||
].filter((p) => p.platform !== goldPost.platform);
|
||||
|
||||
const variations = [
|
||||
'Contrarian take: Flip the main argument',
|
||||
'Deep dive: Expand one point into full content',
|
||||
'Case study: Add real-world examples',
|
||||
'Listicle: Turn into numbered tips',
|
||||
'Story format: Wrap in personal narrative',
|
||||
'Data version: Add statistics and research',
|
||||
];
|
||||
|
||||
const timingRecommendations = [
|
||||
'Wait 2-4 weeks before republishing on same platform',
|
||||
'Post spin-offs on different platforms immediately',
|
||||
'Schedule during peak engagement hours for each platform',
|
||||
'Consider seasonal relevance for timing',
|
||||
];
|
||||
|
||||
return { platforms, variations, timingRecommendations };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get analytics for gold posts
|
||||
*/
|
||||
getAnalytics(): {
|
||||
totalGoldPosts: number;
|
||||
avgViralScore: number;
|
||||
topCategories: { category: string; count: number }[];
|
||||
topPlatforms: { platform: string; count: number }[];
|
||||
spinOffPerformance: {
|
||||
total: number;
|
||||
avgComparisonToOriginal: number;
|
||||
};
|
||||
} {
|
||||
const posts = Array.from(this.goldPosts.values());
|
||||
|
||||
const categoryCount = new Map<string, number>();
|
||||
const platformCount = new Map<string, number>();
|
||||
let totalScore = 0;
|
||||
let spinOffTotal = 0;
|
||||
let spinOffComparison = 0;
|
||||
let spinOffWithPerformance = 0;
|
||||
|
||||
for (const post of posts) {
|
||||
totalScore += post.viralScore;
|
||||
categoryCount.set(post.category, (categoryCount.get(post.category) || 0) + 1);
|
||||
platformCount.set(post.platform, (platformCount.get(post.platform) || 0) + 1);
|
||||
|
||||
spinOffTotal += post.spinOffs.length;
|
||||
for (const spinOff of post.spinOffs) {
|
||||
if (spinOff.performance) {
|
||||
spinOffComparison += spinOff.performance.comparisonToOriginal;
|
||||
spinOffWithPerformance++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalGoldPosts: posts.length,
|
||||
avgViralScore: posts.length ? totalScore / posts.length : 0,
|
||||
topCategories: [...categoryCount.entries()]
|
||||
.map(([category, count]) => ({ category, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
topPlatforms: [...platformCount.entries()]
|
||||
.map(([platform, count]) => ({ platform, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
spinOffPerformance: {
|
||||
total: spinOffTotal,
|
||||
avgComparisonToOriginal: spinOffWithPerformance
|
||||
? spinOffComparison / spinOffWithPerformance
|
||||
: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete gold post
|
||||
*/
|
||||
delete(id: string): boolean {
|
||||
return this.goldPosts.delete(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update gold post tags/notes
|
||||
*/
|
||||
update(
|
||||
id: string,
|
||||
updates: { tags?: string[]; notes?: string; category?: GoldPostCategory },
|
||||
): GoldPost | null {
|
||||
const post = this.goldPosts.get(id);
|
||||
if (!post) return null;
|
||||
|
||||
if (updates.tags) post.tags = updates.tags;
|
||||
if (updates.notes !== undefined) post.notes = updates.notes;
|
||||
if (updates.category) post.category = updates.category;
|
||||
post.updatedAt = new Date();
|
||||
|
||||
return post;
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private detectCategory(
|
||||
parsed: ParsedContent,
|
||||
analysis: ViralAnalysis,
|
||||
): GoldPostCategory {
|
||||
const text = parsed.content.text.toLowerCase();
|
||||
|
||||
if (/buy|sale|offer|discount|limited/i.test(text)) return 'promotional';
|
||||
if (analysis.components.hook.type === 'contrarian') return 'controversy';
|
||||
if (analysis.structure.format === 'story') return 'storytelling';
|
||||
if (analysis.structure.format === 'tutorial' || analysis.structure.format === 'listicle') return 'educational';
|
||||
if (/inspiring|motivat|you can|believe/i.test(text)) return 'inspiration';
|
||||
if (analysis.components.cta.type === 'direct') return 'engagement';
|
||||
if (analysis.components.psychology.authorityUsed) return 'authority';
|
||||
if (analysis.viralScore >= 90) return 'viral';
|
||||
|
||||
return 'educational';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
// Structure Skeleton Service - Extract and generate content structures
|
||||
// Path: src/modules/source-accounts/services/structure-skeleton.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ParsedContent } from './content-parser.service';
|
||||
|
||||
export interface StructureSkeleton {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
structure: StructureBlock[];
|
||||
metadata: {
|
||||
avgWordCount: number;
|
||||
avgParagraphs: number;
|
||||
tone: string;
|
||||
complexity: 'simple' | 'medium' | 'complex';
|
||||
engagementPotential: number;
|
||||
};
|
||||
template: string;
|
||||
examples: string[];
|
||||
}
|
||||
|
||||
export interface StructureBlock {
|
||||
type: BlockType;
|
||||
position: number;
|
||||
purpose: string;
|
||||
template: string;
|
||||
wordCountRange: { min: number; max: number };
|
||||
required: boolean;
|
||||
variations: string[];
|
||||
}
|
||||
|
||||
export type BlockType =
|
||||
| 'hook'
|
||||
| 'context'
|
||||
| 'pain_point'
|
||||
| 'story'
|
||||
| 'lesson'
|
||||
| 'list_item'
|
||||
| 'example'
|
||||
| 'proof'
|
||||
| 'transition'
|
||||
| 'payoff'
|
||||
| 'cta'
|
||||
| 'space'
|
||||
| 'emoji_break';
|
||||
|
||||
@Injectable()
|
||||
export class StructureSkeletonService {
|
||||
private readonly logger = new Logger(StructureSkeletonService.name);
|
||||
|
||||
// Pre-built structure templates
|
||||
private readonly templates: Record<string, StructureSkeleton> = {
|
||||
'twitter-thread': {
|
||||
id: 'twitter-thread',
|
||||
name: 'Twitter Thread',
|
||||
platform: 'twitter',
|
||||
structure: [
|
||||
{
|
||||
type: 'hook',
|
||||
position: 1,
|
||||
purpose: 'Stop the scroll, create curiosity',
|
||||
template: '[Bold statement or question that creates curiosity gap]',
|
||||
wordCountRange: { min: 10, max: 50 },
|
||||
required: true,
|
||||
variations: [
|
||||
'Question hook: Start with "Did you know..." or "Want to know..."',
|
||||
'Bold statement: Make a contrarian claim',
|
||||
'Story hook: "Last year, I..."',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'context',
|
||||
position: 2,
|
||||
purpose: 'Set up the problem/story',
|
||||
template: '[Brief context or backstory]',
|
||||
wordCountRange: { min: 20, max: 80 },
|
||||
required: true,
|
||||
variations: ['Personal story', 'Industry context', 'Problem setup'],
|
||||
},
|
||||
{
|
||||
type: 'list_item',
|
||||
position: 3,
|
||||
purpose: 'Deliver main value',
|
||||
template: '[Point 1: Main insight with example]',
|
||||
wordCountRange: { min: 30, max: 100 },
|
||||
required: true,
|
||||
variations: ['Numbered list', 'Bullet points', 'Emoji markers'],
|
||||
},
|
||||
{
|
||||
type: 'list_item',
|
||||
position: 4,
|
||||
purpose: 'Continue value delivery',
|
||||
template: '[Point 2-5: Supporting insights]',
|
||||
wordCountRange: { min: 30, max: 100 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'payoff',
|
||||
position: 5,
|
||||
purpose: 'Summarize transformation',
|
||||
template: '[Summary of what reader will achieve]',
|
||||
wordCountRange: { min: 20, max: 60 },
|
||||
required: true,
|
||||
variations: ['Before/after', 'Promise', 'Recap'],
|
||||
},
|
||||
{
|
||||
type: 'cta',
|
||||
position: 6,
|
||||
purpose: 'Drive engagement',
|
||||
template: '[Clear action for the reader]',
|
||||
wordCountRange: { min: 10, max: 40 },
|
||||
required: true,
|
||||
variations: ['Follow CTA', 'Repost CTA', 'Comment CTA', 'Save CTA'],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
avgWordCount: 500,
|
||||
avgParagraphs: 7,
|
||||
tone: 'casual-professional',
|
||||
complexity: 'medium',
|
||||
engagementPotential: 85,
|
||||
},
|
||||
template: `🧵 [HOOK - Create curiosity]
|
||||
|
||||
[CONTEXT - Brief setup]
|
||||
|
||||
Here's what I learned:
|
||||
|
||||
1/ [POINT 1]
|
||||
[Supporting detail]
|
||||
|
||||
2/ [POINT 2]
|
||||
[Supporting detail]
|
||||
|
||||
3/ [POINT 3]
|
||||
[Supporting detail]
|
||||
|
||||
The result:
|
||||
[PAYOFF - Transformation]
|
||||
|
||||
[CTA]
|
||||
|
||||
♻️ Repost to help others
|
||||
Follow @you for more`,
|
||||
examples: [],
|
||||
},
|
||||
|
||||
'linkedin-story': {
|
||||
id: 'linkedin-story',
|
||||
name: 'LinkedIn Story Post',
|
||||
platform: 'linkedin',
|
||||
structure: [
|
||||
{
|
||||
type: 'hook',
|
||||
position: 1,
|
||||
purpose: 'Grab attention in feed',
|
||||
template: '[Short, powerful opening line]',
|
||||
wordCountRange: { min: 5, max: 20 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'space',
|
||||
position: 2,
|
||||
purpose: 'Visual break',
|
||||
template: '',
|
||||
wordCountRange: { min: 0, max: 0 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'story',
|
||||
position: 3,
|
||||
purpose: 'Build emotional connection',
|
||||
template: '[Personal story with conflict]',
|
||||
wordCountRange: { min: 100, max: 200 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'lesson',
|
||||
position: 4,
|
||||
purpose: 'Extract the learning',
|
||||
template: '[3-5 key takeaways]',
|
||||
wordCountRange: { min: 50, max: 100 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'cta',
|
||||
position: 5,
|
||||
purpose: 'Spark conversation',
|
||||
template: '[Question for comments]',
|
||||
wordCountRange: { min: 10, max: 30 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
avgWordCount: 300,
|
||||
avgParagraphs: 10,
|
||||
tone: 'professional-personal',
|
||||
complexity: 'medium',
|
||||
engagementPotential: 75,
|
||||
},
|
||||
template: `[HOOK]
|
||||
|
||||
↓
|
||||
|
||||
[STORY - The struggle]
|
||||
|
||||
[STORY - The turning point]
|
||||
|
||||
[STORY - The result]
|
||||
|
||||
Here's what I learned:
|
||||
• [Lesson 1]
|
||||
• [Lesson 2]
|
||||
• [Lesson 3]
|
||||
|
||||
[CTA - Question]`,
|
||||
examples: [],
|
||||
},
|
||||
|
||||
'instagram-carousel': {
|
||||
id: 'instagram-carousel',
|
||||
name: 'Instagram Carousel',
|
||||
platform: 'instagram',
|
||||
structure: [
|
||||
{
|
||||
type: 'hook',
|
||||
position: 1,
|
||||
purpose: 'Cover slide',
|
||||
template: '[Bold headline + visual]',
|
||||
wordCountRange: { min: 3, max: 15 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'pain_point',
|
||||
position: 2,
|
||||
purpose: 'Problem slide',
|
||||
template: '[Relatable problem]',
|
||||
wordCountRange: { min: 10, max: 40 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'list_item',
|
||||
position: 3,
|
||||
purpose: 'Solution slides (3-7)',
|
||||
template: '[One tip per slide]',
|
||||
wordCountRange: { min: 15, max: 50 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'proof',
|
||||
position: 4,
|
||||
purpose: 'Credibility',
|
||||
template: '[Results or testimonial]',
|
||||
wordCountRange: { min: 10, max: 40 },
|
||||
required: false,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'cta',
|
||||
position: 5,
|
||||
purpose: 'Final slide',
|
||||
template: '[Save + Follow CTA]',
|
||||
wordCountRange: { min: 5, max: 20 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
avgWordCount: 200,
|
||||
avgParagraphs: 10,
|
||||
tone: 'casual-educational',
|
||||
complexity: 'simple',
|
||||
engagementPotential: 80,
|
||||
},
|
||||
template: `Slide 1: [HOOK HEADLINE]
|
||||
Slide 2: [PROBLEM] "Are you struggling with..."
|
||||
Slide 3-8: [TIPS] One per slide
|
||||
Slide 9: [PROOF/RESULTS]
|
||||
Slide 10: [CTA] "Save this post | Follow for more"
|
||||
|
||||
Caption:
|
||||
[Hook] + [Value promise] + [CTA] + [Hashtags]`,
|
||||
examples: [],
|
||||
},
|
||||
|
||||
'tiktok-script': {
|
||||
id: 'tiktok-script',
|
||||
name: 'TikTok Video Script',
|
||||
platform: 'tiktok',
|
||||
structure: [
|
||||
{
|
||||
type: 'hook',
|
||||
position: 1,
|
||||
purpose: 'First 3 seconds',
|
||||
template: '[Pattern interrupt or curiosity]',
|
||||
wordCountRange: { min: 5, max: 15 },
|
||||
required: true,
|
||||
variations: [
|
||||
'"Stop scrolling if..."',
|
||||
'"POV..."',
|
||||
'"This changed my life..."',
|
||||
'Direct eye contact + bold claim',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'context',
|
||||
position: 2,
|
||||
purpose: 'Quick setup',
|
||||
template: '[Brief context in 5-10 seconds]',
|
||||
wordCountRange: { min: 15, max: 40 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'list_item',
|
||||
position: 3,
|
||||
purpose: 'Main content',
|
||||
template: '[Value delivery - fast paced]',
|
||||
wordCountRange: { min: 50, max: 150 },
|
||||
required: true,
|
||||
variations: [],
|
||||
},
|
||||
{
|
||||
type: 'cta',
|
||||
position: 4,
|
||||
purpose: 'Engagement',
|
||||
template: '[Loop back or CTA]',
|
||||
wordCountRange: { min: 5, max: 20 },
|
||||
required: true,
|
||||
variations: ['Follow for part 2', 'Comment for...', 'Loop back to hook'],
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
avgWordCount: 100,
|
||||
avgParagraphs: 4,
|
||||
tone: 'casual-energetic',
|
||||
complexity: 'simple',
|
||||
engagementPotential: 90,
|
||||
},
|
||||
template: `[0-3s] HOOK: [Pattern interrupt]
|
||||
[3-10s] CONTEXT: [Quick setup]
|
||||
[10-45s] MAIN: [Value - fast paced, visual cuts]
|
||||
[45-60s] CTA: [Engagement driver]`,
|
||||
examples: [],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract structure from a post
|
||||
*/
|
||||
extractSkeleton(content: ParsedContent): StructureSkeleton {
|
||||
const text = content.content.text;
|
||||
const blocks = this.identifyBlocks(text);
|
||||
|
||||
return {
|
||||
id: `extracted-${Date.now()}`,
|
||||
name: `Extracted from ${content.author.username}`,
|
||||
platform: content.platform,
|
||||
structure: blocks,
|
||||
metadata: {
|
||||
avgWordCount: text.split(/\s+/).length,
|
||||
avgParagraphs: text.split(/\n\n+/).length,
|
||||
tone: this.detectTone(text),
|
||||
complexity: this.assessComplexity(text),
|
||||
engagementPotential: this.estimateEngagement(content),
|
||||
},
|
||||
template: this.generateTemplate(blocks),
|
||||
examples: [text.substring(0, 200) + '...'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pre-built template
|
||||
*/
|
||||
getTemplate(templateId: string): StructureSkeleton | null {
|
||||
return this.templates[templateId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List available templates
|
||||
*/
|
||||
listTemplates(): { id: string; name: string; platform: string }[] {
|
||||
return Object.values(this.templates).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
platform: t.platform,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content from skeleton
|
||||
*/
|
||||
generateFromSkeleton(
|
||||
skeleton: StructureSkeleton,
|
||||
inputs: Record<string, string>,
|
||||
): string {
|
||||
let output = skeleton.template;
|
||||
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
output = output.replace(new RegExp(`\\[${key}\\]`, 'gi'), value);
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple skeletons into a new structure
|
||||
*/
|
||||
mergeSeletons(
|
||||
skeletons: StructureSkeleton[],
|
||||
name: string,
|
||||
): StructureSkeleton {
|
||||
const allBlocks = skeletons.flatMap((s) => s.structure);
|
||||
const uniqueBlocks = this.deduplicateBlocks(allBlocks);
|
||||
|
||||
return {
|
||||
id: `merged-${Date.now()}`,
|
||||
name,
|
||||
platform: 'multi',
|
||||
structure: uniqueBlocks,
|
||||
metadata: {
|
||||
avgWordCount: Math.round(
|
||||
skeletons.reduce((sum, s) => sum + s.metadata.avgWordCount, 0) / skeletons.length
|
||||
),
|
||||
avgParagraphs: Math.round(
|
||||
skeletons.reduce((sum, s) => sum + s.metadata.avgParagraphs, 0) / skeletons.length
|
||||
),
|
||||
tone: 'mixed',
|
||||
complexity: 'medium',
|
||||
engagementPotential: Math.round(
|
||||
skeletons.reduce((sum, s) => sum + s.metadata.engagementPotential, 0) / skeletons.length
|
||||
),
|
||||
},
|
||||
template: uniqueBlocks.map((b) => b.template).join('\n\n'),
|
||||
examples: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private identifyBlocks(text: string): StructureBlock[] {
|
||||
const blocks: StructureBlock[] = [];
|
||||
const paragraphs = text.split(/\n\n+/);
|
||||
|
||||
paragraphs.forEach((para, idx) => {
|
||||
const type = this.detectBlockType(para, idx, paragraphs.length);
|
||||
const words = para.split(/\s+/).length;
|
||||
|
||||
blocks.push({
|
||||
type,
|
||||
position: idx + 1,
|
||||
purpose: this.getBlockPurpose(type),
|
||||
template: `[${type.toUpperCase()}]`,
|
||||
wordCountRange: { min: Math.max(5, words - 10), max: words + 20 },
|
||||
required: idx === 0 || idx === paragraphs.length - 1,
|
||||
variations: [],
|
||||
});
|
||||
});
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private detectBlockType(para: string, idx: number, total: number): BlockType {
|
||||
const paraLower = para.toLowerCase();
|
||||
|
||||
// First paragraph is usually hook
|
||||
if (idx === 0) return 'hook';
|
||||
|
||||
// Last paragraph often CTA
|
||||
if (idx === total - 1) {
|
||||
if (/follow|like|share|comment|subscribe/i.test(para)) return 'cta';
|
||||
return 'payoff';
|
||||
}
|
||||
|
||||
// Detect by content
|
||||
if (/\d+\.|^-|^\*/m.test(para)) return 'list_item';
|
||||
if (/struggle|problem|frustrat|tired/i.test(para)) return 'pain_point';
|
||||
if (/I was|story|happened/i.test(para)) return 'story';
|
||||
if (/learn|lesson|realize/i.test(para)) return 'lesson';
|
||||
if (/example|instance|look at/i.test(para)) return 'example';
|
||||
if (/result|proof|evidence|\d+%/i.test(para)) return 'proof';
|
||||
if (/but|however|now/i.test(para)) return 'transition';
|
||||
if (para.trim() === '') return 'space';
|
||||
|
||||
return 'context';
|
||||
}
|
||||
|
||||
private getBlockPurpose(type: BlockType): string {
|
||||
const purposes: Record<BlockType, string> = {
|
||||
hook: 'Capture attention immediately',
|
||||
context: 'Set the scene and build understanding',
|
||||
pain_point: 'Create emotional connection through shared struggle',
|
||||
story: 'Build narrative and emotional investment',
|
||||
lesson: 'Deliver key insight or takeaway',
|
||||
list_item: 'Provide structured value',
|
||||
example: 'Make abstract concepts concrete',
|
||||
proof: 'Build credibility and trust',
|
||||
transition: 'Connect ideas smoothly',
|
||||
payoff: 'Deliver the promised value',
|
||||
cta: 'Drive specific action',
|
||||
space: 'Create visual breathing room',
|
||||
emoji_break: 'Add visual interest and emotion',
|
||||
};
|
||||
return purposes[type];
|
||||
}
|
||||
|
||||
private detectTone(text: string): string {
|
||||
if (/!\s|wow|amazing|incredible/i.test(text)) return 'enthusiastic';
|
||||
if (/I believe|opinion|think/i.test(text)) return 'opinionated';
|
||||
if (/data|research|study|%/i.test(text)) return 'analytical';
|
||||
if (/story|I was|happened/i.test(text)) return 'narrative';
|
||||
if (/fuck|damn|shit/i.test(text)) return 'casual-edgy';
|
||||
return 'professional';
|
||||
}
|
||||
|
||||
private assessComplexity(text: string): 'simple' | 'medium' | 'complex' {
|
||||
const words = text.split(/\s+/).length;
|
||||
const avgSentenceLength = words / (text.split(/[.!?]+/).length || 1);
|
||||
|
||||
if (avgSentenceLength < 12) return 'simple';
|
||||
if (avgSentenceLength > 20) return 'complex';
|
||||
return 'medium';
|
||||
}
|
||||
|
||||
private estimateEngagement(content: ParsedContent): number {
|
||||
if (content.engagement.engagementRate > 5) return 90;
|
||||
if (content.engagement.engagementRate > 2) return 75;
|
||||
if (content.engagement.engagementRate > 1) return 60;
|
||||
return 50;
|
||||
}
|
||||
|
||||
private generateTemplate(blocks: StructureBlock[]): string {
|
||||
return blocks.map((b) => b.template).join('\n\n');
|
||||
}
|
||||
|
||||
private deduplicateBlocks(blocks: StructureBlock[]): StructureBlock[] {
|
||||
const seen = new Set<BlockType>();
|
||||
const result: StructureBlock[] = [];
|
||||
|
||||
// Always include hook first
|
||||
const hook = blocks.find((b) => b.type === 'hook');
|
||||
if (hook) {
|
||||
result.push(hook);
|
||||
seen.add('hook');
|
||||
}
|
||||
|
||||
// Add unique blocks
|
||||
for (const block of blocks) {
|
||||
if (!seen.has(block.type) || block.type === 'list_item') {
|
||||
result.push(block);
|
||||
seen.add(block.type);
|
||||
}
|
||||
}
|
||||
|
||||
// Renumber positions
|
||||
result.forEach((b, i) => b.position = i + 1);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user