generated from fahricansecer/boilerplate-be
@@ -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 |
|
||||||
Generated
+20
-34
@@ -30,6 +30,7 @@
|
|||||||
"cache-manager-redis-yet": "^5.1.5",
|
"cache-manager-redis-yet": "^5.1.5",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"google-trends-api": "^4.9.2",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.9.0",
|
"ioredis": "^5.9.0",
|
||||||
"nestjs-i18n": "^10.6.0",
|
"nestjs-i18n": "^10.6.0",
|
||||||
@@ -1137,7 +1138,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
@@ -3075,7 +3075,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -3242,7 +3241,6 @@
|
|||||||
"version": "11.1.11",
|
"version": "11.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.11.tgz",
|
||||||
"integrity": "sha512-R/+A8XFqLgN8zNs2twhrOaE7dJbRQhdPX3g46am4RT/x8xGLqDphrXkUIno4cGUZHxbczChBAaAPTdPv73wDZA==",
|
"integrity": "sha512-R/+A8XFqLgN8zNs2twhrOaE7dJbRQhdPX3g46am4RT/x8xGLqDphrXkUIno4cGUZHxbczChBAaAPTdPv73wDZA==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"file-type": "21.2.0",
|
"file-type": "21.2.0",
|
||||||
"iterare": "1.2.1",
|
"iterare": "1.2.1",
|
||||||
@@ -3288,7 +3286,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.11.tgz",
|
||||||
"integrity": "sha512-H9i+zT3RvHi7tDc+lCmWHJ3ustXveABCr+Vcpl96dNOxgmrx4elQSTC4W93Mlav2opfLV+p0UTHY6L+bpUA4zA==",
|
"integrity": "sha512-H9i+zT3RvHi7tDc+lCmWHJ3ustXveABCr+Vcpl96dNOxgmrx4elQSTC4W93Mlav2opfLV+p0UTHY6L+bpUA4zA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxt/opencollective": "0.4.1",
|
"@nuxt/opencollective": "0.4.1",
|
||||||
"fast-safe-stringify": "2.1.1",
|
"fast-safe-stringify": "2.1.1",
|
||||||
@@ -3368,7 +3365,6 @@
|
|||||||
"version": "11.1.11",
|
"version": "11.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.11.tgz",
|
||||||
"integrity": "sha512-kyABSskdMRIAMWL0SlbwtDy4yn59RL4HDdwHDz/fxWuv7/53YP8Y2DtV3/sHqY5Er0msMVTZrM38MjqXhYL7gw==",
|
"integrity": "sha512-kyABSskdMRIAMWL0SlbwtDy4yn59RL4HDdwHDz/fxWuv7/53YP8Y2DtV3/sHqY5Er0msMVTZrM38MjqXhYL7gw==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
@@ -3389,7 +3385,6 @@
|
|||||||
"version": "11.1.11",
|
"version": "11.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.11.tgz",
|
||||||
"integrity": "sha512-0z6pLg9CuTXtz7q2lRZoPOU94DN28OTa39f4cQrlZysKA6QrKM7w7z6xqb4g32qjF+LQHFNRmMJtE/pLrxBaig==",
|
"integrity": "sha512-0z6pLg9CuTXtz7q2lRZoPOU94DN28OTa39f4cQrlZysKA6QrKM7w7z6xqb4g32qjF+LQHFNRmMJtE/pLrxBaig==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"socket.io": "4.8.3",
|
"socket.io": "4.8.3",
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
@@ -3724,7 +3719,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz",
|
||||||
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
"integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.13"
|
"node": ">=16.13"
|
||||||
},
|
},
|
||||||
@@ -3789,7 +3783,6 @@
|
|||||||
"version": "1.6.1",
|
"version": "1.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
|
||||||
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
"integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cluster-key-slot": "1.1.2",
|
"cluster-key-slot": "1.1.2",
|
||||||
"generic-pool": "3.9.0",
|
"generic-pool": "3.9.0",
|
||||||
@@ -4695,7 +4688,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
"integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "*",
|
"@types/estree": "*",
|
||||||
"@types/json-schema": "*"
|
"@types/json-schema": "*"
|
||||||
@@ -4810,7 +4802,6 @@
|
|||||||
"version": "22.19.3",
|
"version": "22.19.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
|
||||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
@@ -4975,7 +4966,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.52.0.tgz",
|
||||||
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
|
"integrity": "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.52.0",
|
"@typescript-eslint/scope-manager": "8.52.0",
|
||||||
"@typescript-eslint/types": "8.52.0",
|
"@typescript-eslint/types": "8.52.0",
|
||||||
@@ -5613,7 +5603,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -5667,7 +5656,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.1",
|
"fast-deep-equal": "^3.1.1",
|
||||||
"fast-json-stable-stringify": "^2.0.0",
|
"fast-json-stable-stringify": "^2.0.0",
|
||||||
@@ -6157,7 +6145,6 @@
|
|||||||
"url": "https://github.com/sponsors/ai"
|
"url": "https://github.com/sponsors/ai"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -6231,7 +6218,6 @@
|
|||||||
"version": "5.66.4",
|
"version": "5.66.4",
|
||||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.66.4.tgz",
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.66.4.tgz",
|
||||||
"integrity": "sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ==",
|
"integrity": "sha512-y2VRk2z7d1YNI2JQDD7iThoD0X/0iZZ3VEp8lqT5s5U0XDl9CIjXp1LQgmE9EKy6ReHtzmYXS1f328PnUbZGtQ==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cron-parser": "4.9.0",
|
"cron-parser": "4.9.0",
|
||||||
"ioredis": "5.8.2",
|
"ioredis": "5.8.2",
|
||||||
@@ -6305,7 +6291,6 @@
|
|||||||
"version": "7.2.7",
|
"version": "7.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/cache-manager/-/cache-manager-7.2.7.tgz",
|
||||||
"integrity": "sha512-TKeeb9nSybk1e9E5yAiPVJ6YKdX9FYhwqqy8fBfVKAFVTJYZUNmeIvwjURW6+UikNsO6l2ta27thYgo/oumDsw==",
|
"integrity": "sha512-TKeeb9nSybk1e9E5yAiPVJ6YKdX9FYhwqqy8fBfVKAFVTJYZUNmeIvwjURW6+UikNsO6l2ta27thYgo/oumDsw==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cacheable/utils": "^2.3.2",
|
"@cacheable/utils": "^2.3.2",
|
||||||
"keyv": "^5.5.4"
|
"keyv": "^5.5.4"
|
||||||
@@ -6457,7 +6442,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"readdirp": "^4.0.1"
|
"readdirp": "^4.0.1"
|
||||||
},
|
},
|
||||||
@@ -6501,14 +6485,12 @@
|
|||||||
"node_modules/class-transformer": {
|
"node_modules/class-transformer": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
||||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw=="
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/class-validator": {
|
"node_modules/class-validator": {
|
||||||
"version": "0.14.3",
|
"version": "0.14.3",
|
||||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
|
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz",
|
||||||
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
"integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/validator": "^13.15.3",
|
"@types/validator": "^13.15.3",
|
||||||
"libphonenumber-js": "^1.11.1",
|
"libphonenumber-js": "^1.11.1",
|
||||||
@@ -7194,7 +7176,8 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||||
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
"integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
@@ -7253,7 +7236,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
|
||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@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",
|
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
|
||||||
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"eslint-config-prettier": "bin/cli.js"
|
"eslint-config-prettier": "bin/cli.js"
|
||||||
},
|
},
|
||||||
@@ -8163,6 +8144,12 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz",
|
||||||
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
"integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jest/core": "30.2.0",
|
"@jest/core": "30.2.0",
|
||||||
"@jest/types": "30.2.0",
|
"@jest/types": "30.2.0",
|
||||||
@@ -9518,7 +9504,6 @@
|
|||||||
"version": "5.5.5",
|
"version": "5.5.5",
|
||||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz",
|
"resolved": "https://registry.npmjs.org/keyv/-/keyv-5.5.5.tgz",
|
||||||
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
|
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@keyv/serialize": "^1.1.1"
|
"@keyv/serialize": "^1.1.1"
|
||||||
}
|
}
|
||||||
@@ -10263,6 +10248,7 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
@@ -10446,7 +10432,6 @@
|
|||||||
"version": "0.7.0",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
|
||||||
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"passport-strategy": "1.x.x",
|
"passport-strategy": "1.x.x",
|
||||||
"pause": "0.0.1",
|
"pause": "0.0.1",
|
||||||
@@ -10573,7 +10558,6 @@
|
|||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.1.0.tgz",
|
||||||
"integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
|
"integrity": "sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@pinojs/redact": "^0.4.0",
|
"@pinojs/redact": "^0.4.0",
|
||||||
"atomic-sleep": "^1.0.0",
|
"atomic-sleep": "^1.0.0",
|
||||||
@@ -10603,7 +10587,6 @@
|
|||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pino-http/-/pino-http-11.0.0.tgz",
|
||||||
"integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==",
|
"integrity": "sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"get-caller-file": "^2.0.5",
|
"get-caller-file": "^2.0.5",
|
||||||
"pino": "^10.0.0",
|
"pino": "^10.0.0",
|
||||||
@@ -10757,7 +10740,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
|
||||||
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"prettier": "bin/prettier.cjs"
|
"prettier": "bin/prettier.cjs"
|
||||||
},
|
},
|
||||||
@@ -10811,7 +10793,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
"resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz",
|
||||||
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
"integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/engines": "5.22.0"
|
"@prisma/engines": "5.22.0"
|
||||||
},
|
},
|
||||||
@@ -11876,7 +11857,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@@ -12191,7 +12171,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
|
||||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cspotcode/source-map-support": "^0.8.0",
|
"@cspotcode/source-map-support": "^0.8.0",
|
||||||
"@tsconfig/node10": "^1.0.7",
|
"@tsconfig/node10": "^1.0.7",
|
||||||
@@ -12329,7 +12308,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -12668,6 +12646,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz",
|
||||||
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
"integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.0.0"
|
"ajv": "^8.0.0"
|
||||||
},
|
},
|
||||||
@@ -12685,6 +12664,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
|
||||||
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3"
|
"fast-deep-equal": "^3.1.3"
|
||||||
},
|
},
|
||||||
@@ -12697,6 +12677,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
|
||||||
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
"integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esrecurse": "^4.3.0",
|
"esrecurse": "^4.3.0",
|
||||||
"estraverse": "^4.1.1"
|
"estraverse": "^4.1.1"
|
||||||
@@ -12710,6 +12691,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
|
||||||
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
"integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
}
|
}
|
||||||
@@ -12718,13 +12700,15 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
"dev": true
|
"dev": true,
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/webpack/node_modules/mime-db": {
|
"node_modules/webpack/node_modules/mime-db": {
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -12734,6 +12718,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "1.52.0"
|
"mime-db": "1.52.0"
|
||||||
},
|
},
|
||||||
@@ -12746,6 +12731,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||||
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/json-schema": "^7.0.9",
|
"@types/json-schema": "^7.0.9",
|
||||||
"ajv": "^8.9.0",
|
"ajv": "^8.9.0",
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"cache-manager-redis-yet": "^5.1.5",
|
"cache-manager-redis-yet": "^5.1.5",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.3",
|
"class-validator": "^0.14.3",
|
||||||
|
"google-trends-api": "^4.9.2",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"ioredis": "^5.9.0",
|
"ioredis": "^5.9.0",
|
||||||
"nestjs-i18n": "^10.6.0",
|
"nestjs-i18n": "^10.6.0",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+2084
-30
File diff suppressed because it is too large
Load Diff
+38
-3
@@ -22,8 +22,9 @@ import {
|
|||||||
i18nConfig,
|
i18nConfig,
|
||||||
featuresConfig,
|
featuresConfig,
|
||||||
throttleConfig,
|
throttleConfig,
|
||||||
|
geminiConfig,
|
||||||
} from './config/configuration';
|
} from './config/configuration';
|
||||||
import { geminiConfig } from './modules/gemini/gemini.config';
|
// import { geminiConfig } from './modules/gemini/gemini.config';
|
||||||
import { validateEnv } from './config/env.validation';
|
import { validateEnv } from './config/env.validation';
|
||||||
|
|
||||||
// Common
|
// Common
|
||||||
@@ -31,7 +32,7 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
|||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { PrismaModule } from './database/prisma.module';
|
||||||
|
|
||||||
// Modules
|
// Modules
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
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 { AdminModule } from './modules/admin/admin.module';
|
||||||
import { HealthModule } from './modules/health/health.module';
|
import { HealthModule } from './modules/health/health.module';
|
||||||
import { GeminiModule } from './modules/gemini/gemini.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
|
// Guards
|
||||||
import {
|
import {
|
||||||
@@ -150,12 +168,29 @@ import {
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
// Database
|
// Database
|
||||||
DatabaseModule,
|
PrismaModule,
|
||||||
|
|
||||||
// Core Modules
|
// Core Modules
|
||||||
AuthModule,
|
AuthModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
CreditsModule,
|
||||||
|
WorkspacesModule,
|
||||||
|
SubscriptionsModule,
|
||||||
|
LanguagesModule,
|
||||||
|
ApprovalsModule,
|
||||||
|
TrendsModule,
|
||||||
|
ContentModule,
|
||||||
|
NeuroMarketingModule,
|
||||||
|
SeoModule,
|
||||||
|
SourceAccountsModule,
|
||||||
|
ContentGenerationModule,
|
||||||
|
VisualGenerationModule,
|
||||||
|
VideoThumbnailModule,
|
||||||
|
SocialIntegrationModule,
|
||||||
|
SchedulingModule,
|
||||||
|
AnalyticsModule,
|
||||||
|
ContentHunterI18nModule,
|
||||||
|
|
||||||
// Optional Modules (controlled by env variables)
|
// Optional Modules (controlled by env variables)
|
||||||
GeminiModule,
|
GeminiModule,
|
||||||
|
|||||||
@@ -55,3 +55,9 @@ export const throttleConfig = registerAs('throttle', () => ({
|
|||||||
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
|
ttl: parseInt(process.env.THROTTLE_TTL || '60000', 10),
|
||||||
limit: parseInt(process.env.THROTTLE_LIMIT || '100', 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
|
||||||
THROTTLE_TTL: z.coerce.number().default(60000),
|
THROTTLE_TTL: z.coerce.number().default(60000),
|
||||||
THROTTLE_LIMIT: z.coerce.number().default(100),
|
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>;
|
export type EnvConfig = z.infer<typeof envSchema>;
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ import { PrismaService } from './prisma.service';
|
|||||||
providers: [PrismaService],
|
providers: [PrismaService],
|
||||||
exports: [PrismaService],
|
exports: [PrismaService],
|
||||||
})
|
})
|
||||||
export class DatabaseModule {}
|
export class PrismaModule { }
|
||||||
@@ -22,8 +22,7 @@ interface PrismaDelegate {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService
|
export class PrismaService
|
||||||
extends PrismaClient
|
extends PrismaClient
|
||||||
implements OnModuleInit, OnModuleDestroy
|
implements OnModuleInit, OnModuleDestroy {
|
||||||
{
|
|
||||||
private readonly logger = new Logger(PrismaService.name);
|
private readonly logger = new Logger(PrismaService.name);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -44,6 +43,11 @@ export class PrismaService
|
|||||||
await this.$connect();
|
await this.$connect();
|
||||||
this.logger.log('✅ Database connected successfully');
|
this.logger.log('✅ Database connected successfully');
|
||||||
} catch (error) {
|
} catch (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(
|
this.logger.error(
|
||||||
`❌ Database connection failed: ${error.message}`,
|
`❌ Database connection failed: ${error.message}`,
|
||||||
error.stack,
|
error.stack,
|
||||||
@@ -51,6 +55,7 @@ export class PrismaService
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
await this.$disconnect();
|
await this.$disconnect();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Approvals Module Index
|
||||||
|
export * from './approvals.module';
|
||||||
|
export * from './approvals.service';
|
||||||
|
export * from './approvals.controller';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
onModuleInit() {
|
||||||
|
this.logger.log(`Initializing GeminiService. isEnabled: ${this.isEnabled}`);
|
||||||
|
|
||||||
if (!this.isEnabled) {
|
if (!this.isEnabled) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
'Gemini AI is disabled. Set ENABLE_GEMINI=true to enable.',
|
'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');
|
const apiKey = this.configService.get<string>('gemini.apiKey');
|
||||||
|
this.logger.log(`API Key found: ${!!apiKey}`);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'GOOGLE_API_KEY is not set. Gemini features will not work.',
|
'GOOGLE_API_KEY is not set. Gemini features will not work.',
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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, ''');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Languages Module Index
|
||||||
|
export * from './languages.module';
|
||||||
|
export * from './languages.service';
|
||||||
|
export * from './languages.controller';
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) + '...';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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