Initial push from server
This commit is contained in:
parent
cd1005fe78
commit
f18b02e88d
132
.gitignore copy
Normal file
132
.gitignore copy
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
# ---> Node
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Snowpack dependency directory (https://snowpack.dev/)
|
||||||
|
web_modules/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional stylelint cache
|
||||||
|
.stylelintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
out
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# vuepress v2.x temp and cache directory
|
||||||
|
.temp
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# Docusaurus cache and generated files
|
||||||
|
.docusaurus
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
# yarn v2
|
||||||
|
.yarn/cache
|
||||||
|
.yarn/unplugged
|
||||||
|
.yarn/build-state.yml
|
||||||
|
.yarn/install-state.gz
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
273
DM_API_TEST_EXAMPLES.md
Normal file
273
DM_API_TEST_EXAMPLES.md
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
# DM Automation API - HTTPie Test Examples
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
```bash
|
||||||
|
# Install httpie if not already installed
|
||||||
|
pip install httpie
|
||||||
|
|
||||||
|
# Set your base URL and user email
|
||||||
|
export BASE_URL="https://api.socialbuddy.co"
|
||||||
|
export USER_EMAIL="alaguraj0361@gmail.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. Test Auto-Reply (Static Template)
|
||||||
|
Test the static reply generator without connecting to Instagram:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
http POST $BASE_URL/api/dm/messages/test-autoreply \
|
||||||
|
messageText="What are your prices?"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"originalMessage": "What are your prices?",
|
||||||
|
"autoReply": "For pricing information, please check our website or DM us with specific requirements."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Get Conversations
|
||||||
|
Fetch all Instagram DM conversations for a user:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
http GET "$BASE_URL/api/dm/messages/conversations?userId=$USER_EMAIL"
|
||||||
|
```
|
||||||
|
|
||||||
|
With pagination:
|
||||||
|
```bash
|
||||||
|
http GET "$BASE_URL/api/dm/messages/conversations?userId=$USER_EMAIL&after=CURSOR_VALUE"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "conversation_id_123",
|
||||||
|
"updated_time": "2026-01-20T10:30:00+0000",
|
||||||
|
"participants": {...},
|
||||||
|
"messages": {...}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paging": {
|
||||||
|
"cursors": {
|
||||||
|
"before": "...",
|
||||||
|
"after": "..."
|
||||||
|
},
|
||||||
|
"next": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Get Messages in a Conversation
|
||||||
|
Fetch messages from a specific conversation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export CONVERSATION_ID="your_conversation_id_here"
|
||||||
|
|
||||||
|
http GET "$BASE_URL/api/dm/messages/conversations/$CONVERSATION_ID/messages?userId=$USER_EMAIL"
|
||||||
|
```
|
||||||
|
|
||||||
|
With pagination:
|
||||||
|
```bash
|
||||||
|
http GET "$BASE_URL/api/dm/messages/conversations/$CONVERSATION_ID/messages?userId=$USER_EMAIL&after=CURSOR_VALUE"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "message_id_123",
|
||||||
|
"created_time": "2026-01-20T10:25:00+0000",
|
||||||
|
"from": {...},
|
||||||
|
"to": {...},
|
||||||
|
"message": "Hello, I have a question"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paging": {...}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Send a Message
|
||||||
|
Send a DM reply to a specific Instagram user:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export RECIPIENT_ID="instagram_scoped_user_id"
|
||||||
|
|
||||||
|
http POST "$BASE_URL/api/dm/messages/send?userId=$USER_EMAIL" \
|
||||||
|
recipientId="$RECIPIENT_ID" \
|
||||||
|
message="Thank you for reaching out! How can I help you today?"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"recipient_id": "instagram_scoped_user_id",
|
||||||
|
"message_id": "mid.message_id_123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Mark Conversation as Read
|
||||||
|
Mark a conversation as read:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
http POST "$BASE_URL/api/dm/messages/conversations/$CONVERSATION_ID/read?userId=$USER_EMAIL"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Webhook Setup (For Auto-Reply Automation)
|
||||||
|
|
||||||
|
### Step 1: Verify Webhook (Facebook will call this during setup)
|
||||||
|
```bash
|
||||||
|
# Facebook will make this GET request:
|
||||||
|
# GET /api/dm/webhook?hub.mode=subscribe&hub.verify_token=your_verify_token_123&hub.challenge=CHALLENGE_STRING
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Test Webhook Locally with ngrok
|
||||||
|
```bash
|
||||||
|
# Install ngrok
|
||||||
|
npm install -g ngrok
|
||||||
|
|
||||||
|
# Expose local server
|
||||||
|
ngrok http 7002
|
||||||
|
|
||||||
|
# Set webhook URL in Meta App Dashboard:
|
||||||
|
# https://your-ngrok-url.ngrok.io/api/dm/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Simulate Incoming Message (Testing)
|
||||||
|
```bash
|
||||||
|
http POST http://localhost:7002/api/dm/webhook \
|
||||||
|
'X-Hub-Signature-256:sha256=YOUR_SIGNATURE_HERE' \
|
||||||
|
object="instagram" \
|
||||||
|
entry:='[{
|
||||||
|
"id": "page_id",
|
||||||
|
"time": 1234567890,
|
||||||
|
"messaging": [{
|
||||||
|
"sender": {"id": "sender_instagram_id"},
|
||||||
|
"recipient": {"id": "page_instagram_id"},
|
||||||
|
"timestamp": 1234567890,
|
||||||
|
"message": {
|
||||||
|
"mid": "message_id",
|
||||||
|
"text": "Hello, I need help"
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling Examples
|
||||||
|
|
||||||
|
### Missing userId
|
||||||
|
```bash
|
||||||
|
http GET "$BASE_URL/api/dm/messages/conversations"
|
||||||
|
|
||||||
|
# Response: 500
|
||||||
|
# {"error": "userId is required as query parameter"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### No Channel Connected
|
||||||
|
```bash
|
||||||
|
http GET "$BASE_URL/api/dm/messages/conversations?userId=nonexistent@example.com"
|
||||||
|
|
||||||
|
# Response: 500
|
||||||
|
# {"error": "Connect a channel first"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Invalid Conversation ID
|
||||||
|
```bash
|
||||||
|
http GET "$BASE_URL/api/dm/messages/conversations/invalid_id/messages?userId=$USER_EMAIL"
|
||||||
|
|
||||||
|
# Response: 500
|
||||||
|
# {"error": "Unsupported get request..."}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Workflow (End-to-End)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. First, ensure user has connected their Instagram account
|
||||||
|
# (Use the social OAuth flow first)
|
||||||
|
|
||||||
|
# 2. Verify connection
|
||||||
|
http GET "$BASE_URL/api/social/auth/status?userId=$USER_EMAIL"
|
||||||
|
|
||||||
|
# 3. Get conversations
|
||||||
|
http GET "$BASE_URL/api/dm/messages/conversations?userId=$USER_EMAIL"
|
||||||
|
|
||||||
|
# 4. Pick a conversation ID from response
|
||||||
|
export CONVO_ID="123456789"
|
||||||
|
|
||||||
|
# 5. Get messages in that conversation
|
||||||
|
http GET "$BASE_URL/api/dm/messages/conversations/$CONVO_ID/messages?userId=$USER_EMAIL"
|
||||||
|
|
||||||
|
# 6. Send a reply (get recipient ID from conversation data)
|
||||||
|
export RECIPIENT="recipient_instagram_scoped_id"
|
||||||
|
http POST "$BASE_URL/api/dm/messages/send?userId=$USER_EMAIL" \
|
||||||
|
recipientId="$RECIPIENT" \
|
||||||
|
message="Thanks for your message!"
|
||||||
|
|
||||||
|
# 7. Mark conversation as read
|
||||||
|
http POST "$BASE_URL/api/dm/messages/conversations/$CONVO_ID/read?userId=$USER_EMAIL"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables Required
|
||||||
|
|
||||||
|
Add these to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Webhook Configuration
|
||||||
|
WEBHOOK_VERIFY_TOKEN=your_custom_verify_token_123
|
||||||
|
PAGE_ACCESS_TOKEN=your_page_access_token_here # Temporary, will be replaced by user-specific tokens
|
||||||
|
|
||||||
|
# OAuth (already configured)
|
||||||
|
OAUTH_APP_ID=your_facebook_app_id
|
||||||
|
OAUTH_APP_SECRET=your_facebook_app_secret
|
||||||
|
|
||||||
|
# Graph API
|
||||||
|
GRAPH_VER=v21.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues & Solutions
|
||||||
|
|
||||||
|
### Issue: "Connect a channel first"
|
||||||
|
**Solution:** User needs to complete the Instagram connection flow:
|
||||||
|
```bash
|
||||||
|
http GET "$BASE_URL/api/social/auth/login"
|
||||||
|
# Follow OAuth flow, then connect a channel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: "Invalid signature" on webhook
|
||||||
|
**Solution:** Ensure `OAUTH_APP_SECRET` matches your Meta App settings and raw body middleware is working.
|
||||||
|
|
||||||
|
### Issue: Empty conversations list
|
||||||
|
**Solution:**
|
||||||
|
1. Verify Instagram Professional/Business account is linked to Facebook Page
|
||||||
|
2. Check that `instagram_manage_messages` permission is granted
|
||||||
|
3. Ensure page has at least one conversation/message
|
||||||
|
|
||||||
|
### Issue: Cannot send message
|
||||||
|
**Solution:**
|
||||||
|
1. Recipient must have messaged you first (Instagram limitation)
|
||||||
|
2. Check that recipient ID is the Instagram-scoped ID (IGSID), not username
|
||||||
|
3. Verify page token has `pages_manage_engagement` permission
|
||||||
|
|
||||||
|
## Meta App Configuration Checklist
|
||||||
|
|
||||||
|
- [ ] App has Instagram Basic Display API enabled
|
||||||
|
- [ ] App has Instagram Graph API enabled
|
||||||
|
- [ ] App has Messenger Platform enabled
|
||||||
|
- [ ] Permissions granted:
|
||||||
|
- `instagram_manage_messages`
|
||||||
|
- `pages_manage_engagement`
|
||||||
|
- `pages_read_engagement`
|
||||||
|
- `pages_show_list`
|
||||||
|
- `instagram_basic`
|
||||||
|
- [ ] Webhook subscribed to `messages` field
|
||||||
|
- [ ] Webhook verify token matches `WEBHOOK_VERIFY_TOKEN` in .env
|
||||||
|
- [ ] Page is connected and has Instagram Professional/Business account
|
||||||
2
README copy.md
Normal file
2
README copy.md
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# socialbuddy_app_backend
|
||||||
|
|
||||||
13
config/db.js
Normal file
13
config/db.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const connectDB = async () => {
|
||||||
|
try {
|
||||||
|
await mongoose.connect(process.env.MONGODB_URI, { dbName: "SocialBuddy" });
|
||||||
|
console.log("✅ MongoDB connected");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ MongoDB connection error:", err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { connectDB };
|
||||||
51
config/passport.js
Normal file
51
config/passport.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
const passport = require("passport");
|
||||||
|
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
||||||
|
const { getPool } = require("./db.js");
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 🧠 Save user to DB or fetch if exists
|
||||||
|
async function findOrCreateUser(profile, provider) {
|
||||||
|
const email = profile.emails?.[0]?.value || `${profile.id}@facebook.com`;
|
||||||
|
const name = profile.displayName;
|
||||||
|
const pool = await getPool();
|
||||||
|
|
||||||
|
const [existing] = await pool.execute(
|
||||||
|
"SELECT * FROM tbl_userlogin WHERE email = ? LIMIT 1",
|
||||||
|
[email]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing.length) return existing[0];
|
||||||
|
|
||||||
|
const userid = uuidv4();
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
"INSERT INTO tbl_userlogin (userid, name, email, password, provider) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
[userid, name, email, null, provider]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { userid, name, email, provider };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔹 Google
|
||||||
|
passport.use(
|
||||||
|
new GoogleStrategy(
|
||||||
|
{
|
||||||
|
clientID: "657565817895-sqmh7bd4cef3j7c7cmuno1tpbhg7fjbo.apps.googleusercontent.com",
|
||||||
|
clientSecret: "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",
|
||||||
|
callbackURL: `https://api.socialbuddy.co/api/auth/google/callback`,
|
||||||
|
},
|
||||||
|
async (accessToken, refreshToken, profile, done) => {
|
||||||
|
const user = await findOrCreateUser(profile, "google");
|
||||||
|
done(null, user);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// 🔹 Serialize/deserialize
|
||||||
|
passport.serializeUser((user, done) => done(null, user));
|
||||||
|
passport.deserializeUser((user, done) => done(null, user));
|
||||||
|
|
||||||
|
module.exports = passport;
|
||||||
66
data/store.json
Normal file
66
data/store.json
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"null": {
|
||||||
|
"longUserToken": "EAAH1s4xuDg8BQOioF1dZCz50GaZC2Rzw1ZCGLj1RQ06sNspbZBnh3O6xnpzgd4q9PuE41Wxw20hDSKKUsHrZBJ3EFoJDbMTNsQbR6vrsUylbLsb41ZBAZAXwVyMU5LfauZAxAm488Qe8v54sHZB7j0s8QroFE2PClmxqvZBEsWvmpTx73ZATbyTjnE6ZBpt8oi2IwkT5",
|
||||||
|
"longUserTokenExpiresAt": null,
|
||||||
|
"pageId": "356453210890355",
|
||||||
|
"pageToken": "EAAH1s4xuDg8BQZAOdlPaZAyTZC1HMwjFyamNVcfvw5JWNgnYgZBJBaSRVd7EZB6uyy8DqmQjTMLQglXJhoIyATRagVn0ynkZA1oLtio8hjW5DGPUPCsaMmyOcdtzpXyY72ARCASwD4gJNMjKKuBq3vZCG2TcWUpw2N2ZBOqZAFELUCrNQ2gZAGisVNipOR4I2BTW3o8Ljk",
|
||||||
|
"igUserId": "17841467507578923"
|
||||||
|
},
|
||||||
|
"manesh@gmail.com": {
|
||||||
|
"longUserToken": "EAAH1s4xuDg8BQMcvltO48gREI4t0wEKeej2ehL1Kel3ZAU8nwfAUZBakiZCzIILyMm9fHX4aejCVKHtV17s2VJqLawgjhw6W1zeVtWnOZCHSiXtWio7oZCxzf6iH8m09ogAF4PsjnM1cvOBGazpf3525XhEYELnoJekDVeRYpDvOZCC2yn8OecbxgzhFM4FH4R",
|
||||||
|
"longUserTokenExpiresAt": null
|
||||||
|
},
|
||||||
|
"vijay@gmail.com": {
|
||||||
|
"longUserToken": "EAAH1s4xuDg8BQHHioWnhkoLTcrUCsohfVnZC6jUrckykslwpoZAnOXRAA5TCPCNPR3aU7TNap4VRDQwGhriUx1CIY0aCEiExkyPxCHWRS6D2dbJyz6ltFnhkPlyPaW5v7BKPZACiVVg8A7tP3sAmaUZBpetRg6ACESD8FyWmwTVQHPfpYs2uasfPme1Y3eCj",
|
||||||
|
"longUserTokenExpiresAt": null,
|
||||||
|
"pageId": "631970130002549",
|
||||||
|
"pageToken": "EAAH1s4xuDg8BQM3K7uiQVWdktgIFO4hjoCJSdmExSb0eQnF0eOKgxzeIJkZCGIAGb3JjeuXu3oGHTb0tSRJT3K3JZAGAbpcliCJccR56oi6epz31ZCOBYDEZAq3ClqcSfobxNC0Fwond0qPZAQZABfglE9z5iHZAov22WvNLaOO07TG9hWG3WADHexEQtutraxYfR4b",
|
||||||
|
"igUserId": "17841474871998063"
|
||||||
|
},
|
||||||
|
"partner@gmail.com": {
|
||||||
|
"longUserToken": "EAAH1s4xuDg8BQM48InpqtHcgXpnfnssHYTFpZBhR8ZAgbt1BVSOPmzpN2LBjKmoskfkGZBMUkDPNDc0ctVwMWrNUoeCJuRDReeoWzakYzvRhPJuBeUxoaZCmDcupPZCwpFsXkeh9hFtaYFz2hbS3ZCVtFc84fPuwTTYH6AL7WV3grQyOxGUTZAXh6b597D6",
|
||||||
|
"longUserTokenExpiresAt": null,
|
||||||
|
"pageId": "631970130002549",
|
||||||
|
"pageToken": "EAAH1s4xuDg8BQDGwkCZAZAsq3hCxnYUhZClRYl8DqKf0qi44iWtUW6FaD0wZBDz36esp7fpQMVKu2CooK0miuD0TK6cmQzN4A9V4DOe2dmwBYL886YE5SYXMrm7ZBlt4KNXmFZBGSoHB22xz9cV2jPuy7bNCmHjKxM5YS6NlSLo1pq7ZBWDUgZA0Sqz2e94G0R634xsaWZBNc",
|
||||||
|
"igUserId": "17841474871998063"
|
||||||
|
},
|
||||||
|
"trailuser@gmail.com": {
|
||||||
|
"longUserToken": "EAAH1s4xuDg8BQB4IKZCSADGUxNM3bZAFbV3Vzv94OCi3rKV2ERaIzZB3vm9fY7iMdcbPuhSpTZBfX13WZA2llzowkBIK7mCP9UXCHc9lMglBa5LfANgqefAMcEX0juRMs9EVSiqtY0qIZB4XsO0SXzekVlqM79hZCO5YlX5OL3BSGkJZA70G9wXORxm4vtcYsxXy",
|
||||||
|
"longUserTokenExpiresAt": null,
|
||||||
|
"pageId": "631970130002549",
|
||||||
|
"pageToken": "EAAH1s4xuDg8BQLc4vQhrTq89cnZCUnnrCWaDqKMS3wN98yMqGzlWseLrV7lCWyDvJ9w1YA5774kbhPzawViEgy9rrjZCjKGsrV4dLcxYOvcfozcDPGIBHnvluHAWcvUeAJ8HAH108sljDqDGiIRAwgtGyvGlZBG3YpAHwLbglcDUxpwtZAUMeor0xxF74IM1dRKY",
|
||||||
|
"igUserId": "17841474871998063"
|
||||||
|
},
|
||||||
|
"test@gmail.com": {
|
||||||
|
"longUserToken": "EAAH1s4xuDg8BQasZCppDmYQCrQEcemXWZC2yNKK3TFVQMnxGIqzZCG2RQSoBlpkNOHTB9HlnZCdHSoqUhkQ0ZA2HDHe3smxZBQF1wMZBm8eqaoTEraTJVPHU1gEqqa6OcdXWO6jlUEMSNhXfZCwphsGuMdYBtar5ouXgy9tVzed6NRwbdBCEVlPxMSJmseZBbgwtG",
|
||||||
|
"longUserTokenExpiresAt": null,
|
||||||
|
"pageId": "631970130002549",
|
||||||
|
"pageToken": "EAAH1s4xuDg8BQRwtXTkzGqcg0RQXJzM1WH3e0DlUNnu3EcZBMxg85vWkIRiNHDxWmMvCDbMSK85c88M6ANjoBQuSH25tUfZCmFkbz60CK1BfZCxwf1MTE6WZB1AOqmCOLibfrZAlngBHLSx0JGXZBMvy0un5IIPhsDWucqcGY8PudBZAlJ9KpZAJh9ZAAR5fzJpFzVGyA",
|
||||||
|
"igUserId": "17841474871998063"
|
||||||
|
},
|
||||||
|
"info@metatroncubesolutions.com": {
|
||||||
|
"longUserToken": "EAAH1s4xuDg8BQnXFK9ynDX0ZBLWGBrftzTcWHptrYIINEy01ZCMCQhPcHrPIPkp5Ho0FsAKIz2A7nIPlnDnMrNPWLt4qUU2bHXljY7sOs04w9w6b8qMsAs0wVIBfQVgjYcQo6krZAvHs7e1XqeqnVyNPaqr5MNwOPmoScaaz8xmUHJf4sZAW47yXQdag",
|
||||||
|
"longUserTokenExpiresAt": null,
|
||||||
|
"pageId": "109240551620324",
|
||||||
|
"pageToken": "EAAH1s4xuDg8BQkZC55qjeeEvHte7JWqlFtzGEWV1obZC4h57sZCIlIZC40ur7ZCq99UHNoaf2uKPrL40NGVHeZBxqVXSZCqkUvNw2lAGY8AkIiRZAGTKzfnLvh1moSV655QKfHZAsX67AZCJHZAT4w7QT0Nk25sY3xUfz65TZCVNI6dLE5LWRVR4aUDcnsYyzGNop6KrsSMSDcwZD",
|
||||||
|
"igUserId": "17841440545601461"
|
||||||
|
},
|
||||||
|
"alaguraj0361@gmail.com1": {
|
||||||
|
"longUserToken": "EAAH1s4xuDg8BQoW5CDQtJvmLnqhNzHY5ux40vTuApkw2W5hoZA5F9vXXBSMTDFghyZAzJSG8GlP3uOZBuFdS2uDxO2t6IdeZBi9rHxmTUnbgBRinBOPBehmzRXafYFYohiyQ6ve0CsAlRijSPBXwDZBRAaP0XqXLZAzTfZBNjzwoq2DXKIvvUJCSjR6y2hhxOiE",
|
||||||
|
"longUserTokenExpiresAt": null,
|
||||||
|
"pageId": "109240551620324",
|
||||||
|
"pageToken": "EAAH1s4xuDg8BQiuAU2jT4O9G2ZCOChSMZAThq5turitdjld1mrZBeIaNJOrwGtulvb661SiO8uZC3y9mTrbB9RapcCNiCta7ZCIVK2LixDweZBoIPtPLTvhA1HwQWp1jXCYqqiqmFiJZATCKoxgTiXNk4cK6HSKs1j1ZC8YZAgltr2P6QQ8h2j6mR8HaRZBPDrGZABaYFq7BpAZD",
|
||||||
|
"igUserId": "17841440545601461"
|
||||||
|
},
|
||||||
|
"gogreenpackscanada@gmail.com": {
|
||||||
|
"longUserToken": "EAAH1s4xuDg8BQsZBYfXghYZAgZBtx5JvX7b3Y4CMo7QEFZAXjFmOdZBopWJ3r3XfIu6ZBe3yeZAZAnDrFqot1dquGYk5ki9xw3GgToncdEG6LebVZAvQHbjEGlZB1iO7E8FgS6ZAwenw9CZCAbI3iDhkL0bCPxK679TjbsByztUWWipW6F2CgGWTPMvQL5RsDim7KThM",
|
||||||
|
"longUserTokenExpiresAt": null
|
||||||
|
},
|
||||||
|
"admin@gmail.com": {
|
||||||
|
"longUserToken": "EAAH1s4xuDg8BQZC6CGyQZAtJtf1zB3FgzjZC08Lyrz7bJUzXqZAw9GnJSZBGIfOPvQQeLYOQdKoSFeNdPZBANLrC9MGnbVAhih5h7ZBJ1g7XhhulZCANsyNJZCqY6OPy0ROb7t25UxAzuAq4ip980c04f1uQFuCaMm8uGhSPauxQbSfOvbYvJxZB80cZCxjWdqZB",
|
||||||
|
"longUserTokenExpiresAt": null,
|
||||||
|
"pageId": "631970130002549",
|
||||||
|
"pageToken": "EAAH1s4xuDg8BQZB6FUjZAnpzdgXVAkZBFY2cqb8pK16KtfWmL7nqZAVkAsmMwZB1nXVpTHEIBqwTPaL3AoYX7WYy2zjL3jo5xQX7mn1GiDZCMN2ZAvZCpCREEZAkNuhKU3SMv7AD5sVC1cK9gsj3tIfLX40dh5v3okNtdZCE6QYo4DuT41sT7YrMfjjEnDJlTa97FQRXsGEtwb",
|
||||||
|
"igUserId": "17841474871998063"
|
||||||
|
}
|
||||||
|
}
|
||||||
1925
package-lock.json
generated
Normal file
1925
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "socialbuddy_app_backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"body-parser": "^2.2.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"mongoose": "^8.19.3",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"nodemailer": "^7.0.10",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"stripe": "^20.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
68
server.js
Normal file
68
server.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
require("dotenv").config();
|
||||||
|
const express = require("express");
|
||||||
|
const cors = require("cors");
|
||||||
|
const path = require("path");
|
||||||
|
const { connectDB } = require("./config/db");
|
||||||
|
const passport = require("./config/passport");
|
||||||
|
const socialRoutes = require("./src/routes/social.routes");
|
||||||
|
const authRoutes = require("./src/routes/auth.routes");
|
||||||
|
const paymentRoutes = require("./src/routes/payment.routes");
|
||||||
|
const userRoutes = require("./src/routes/user.routes");
|
||||||
|
const dmWebhookRoutes = require("./src/routes/dm_webhook.routes");
|
||||||
|
const dmMessagesRoutes = require("./src/routes/dm_messages.routes");
|
||||||
|
const { captureDmRawBody } = require("./src/middlewares/dm_rawBody.middleware");
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 7002;
|
||||||
|
|
||||||
|
// Request Logger
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
console.log(`[REQUEST] ${req.method} ${req.url}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------ Connect DB ------------------
|
||||||
|
connectDB()
|
||||||
|
.then(() => console.log("✅ Database connected successfully"))
|
||||||
|
.catch((err) => console.error("❌ Database connection failed:", err));
|
||||||
|
|
||||||
|
// ------------------ CORS ------------------
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: ["http://localhost:3500", "https://devapp.socialbuddy.co"],
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ⚠ STRIPE WEBHOOK MUST USE RAW BODY ⚠
|
||||||
|
app.use(
|
||||||
|
"/api/payment/webhook",
|
||||||
|
express.raw({ type: "application/json" })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Capture raw body for DM webhook before JSON parsing
|
||||||
|
app.use(captureDmRawBody);
|
||||||
|
|
||||||
|
// ------------------ Body Parser (After Webhook) ------------------
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// ------------------ Static Files ------------------
|
||||||
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
|
||||||
|
//passport
|
||||||
|
app.use(passport.initialize());
|
||||||
|
|
||||||
|
// ------------------ Routes ------------------
|
||||||
|
app.use("/api/social", socialRoutes);
|
||||||
|
app.use("/api/auth", authRoutes);
|
||||||
|
app.use("/api/users", userRoutes);
|
||||||
|
app.use("/api/payment", paymentRoutes);
|
||||||
|
app.use("/api/dm/webhook", dmWebhookRoutes);
|
||||||
|
app.use("/api/dm/messages", dmMessagesRoutes);
|
||||||
|
|
||||||
|
app.get("/", (_req, res) => {
|
||||||
|
res.send("SocialBuddy backend is running...");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------------------ Start Server ------------------
|
||||||
|
app.listen(PORT, () => console.log(`🚀 Server running on port ${PORT}`));
|
||||||
37
src/controllers/account.controller.js
Normal file
37
src/controllers/account.controller.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
const { getPageConnection } = require('../services/tokenStore.service');
|
||||||
|
const { getBusinessAccountProfile } = require('../services/account.service');
|
||||||
|
|
||||||
|
// async function getAccount(req, res) {
|
||||||
|
// console.log('✅ Account endpoint hit!'); // Add this
|
||||||
|
// const userId =req.query.userId
|
||||||
|
// const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
// if (!pageToken || !igUserId) return res.status(400).json({ error: 'Connect a channel first' });
|
||||||
|
|
||||||
|
// const profile = await getBusinessAccountProfile(igUserId, pageToken);
|
||||||
|
// return res.json(profile);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// module.exports = { getAccount };
|
||||||
|
// account.controller.js - Add temporarily
|
||||||
|
async function getAccount(req, res) {
|
||||||
|
console.log('🔍 Account endpoint hit');
|
||||||
|
const userId =req.query.userId
|
||||||
|
console.log('👤 User ID for Account :', userId) ;
|
||||||
|
try {
|
||||||
|
const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
console.log('🔍 Page connection found:', { hasToken: !!pageToken, igUserId });
|
||||||
|
|
||||||
|
if (!pageToken || !igUserId) {
|
||||||
|
console.log('❌ No connection found for user:', userId);
|
||||||
|
return res.status(400).json({ error: 'Connect a channel first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await getBusinessAccountProfile(igUserId, pageToken);
|
||||||
|
console.log('✅ Profile fetched successfully');
|
||||||
|
return res.json(profile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Account error:', error.message);
|
||||||
|
return res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = { getAccount };
|
||||||
28
src/controllers/auth.controller copy.js
Normal file
28
src/controllers/auth.controller copy.js
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
const { buildLoginUrl, getShortLivedUserToken, exchangeToLongLivedUserToken } = require('../services/oauth.service');
|
||||||
|
const { saveUserToken } = require('../services/tokenStore.service');
|
||||||
|
|
||||||
|
async function login(req, res) {
|
||||||
|
return res.redirect(buildLoginUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callback(req, res) {
|
||||||
|
|
||||||
|
const { code, error } = req.query;
|
||||||
|
if (error) return res.status(400).send(`OAuth error: ${error}`);
|
||||||
|
if (!code) return res.status(400).send('Missing code');
|
||||||
|
|
||||||
|
const short = await getShortLivedUserToken(code);
|
||||||
|
|
||||||
|
const long = await exchangeToLongLivedUserToken(short.access_token);
|
||||||
|
|
||||||
|
const userId =req.query.userId
|
||||||
|
const expiresAt = new Date(Date.now() + (long.expires_in * 1000));
|
||||||
|
|
||||||
|
|
||||||
|
await saveUserToken(userId, long.access_token, expiresAt);
|
||||||
|
// 🔥 Redirect to Next.js page after success
|
||||||
|
return res.redirect('https://devapp.socialbuddy.co/social-media-connect');
|
||||||
|
// return res.json({ ok: true, token_type: long.token_type, expires_in: long.expires_in });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { login, callback };
|
||||||
117
src/controllers/auth.controller.js
Normal file
117
src/controllers/auth.controller.js
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
const { buildLoginUrl, getShortLivedUserToken, exchangeToLongLivedUserToken } = require('../services/oauth.service');
|
||||||
|
const { saveUserToken, getUserToken, deleteUserCompletely } = require('../services/tokenStore.service');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
|
||||||
|
async function login(req, res) {
|
||||||
|
return res.redirect(buildLoginUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function status(req, res) {
|
||||||
|
const userId = req.query.userId
|
||||||
|
console.log("Checking OAuth status for user:", userId);
|
||||||
|
|
||||||
|
const userToken = await getUserToken(userId);
|
||||||
|
if (userToken) {
|
||||||
|
return res.json({ connected: true, userId: userId });
|
||||||
|
} else {
|
||||||
|
return res.json({ connected: false, userId: userId });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disconnect(req, res) {
|
||||||
|
try {
|
||||||
|
const userId = req.body.userId
|
||||||
|
console.log("Checking disconnect status for user:", userId);
|
||||||
|
|
||||||
|
await deleteUserCompletely(userId);
|
||||||
|
res.json({ ok: true, message: 'User Facebook Account disconnected successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error disconnecting user:", error);
|
||||||
|
return res.status(500).json({ error: 'Failed to disconnect user' });
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// async function callback(req, res) {
|
||||||
|
|
||||||
|
// const { code, error } = req.query;
|
||||||
|
// if (error) return res.status(400).send(`OAuth error: ${error}`);
|
||||||
|
// if (!code) return res.status(400).send('Missing code');
|
||||||
|
|
||||||
|
// const short = await getShortLivedUserToken(code);
|
||||||
|
|
||||||
|
// const long = await exchangeToLongLivedUserToken(short.access_token);
|
||||||
|
|
||||||
|
// const userId =req.query.userId
|
||||||
|
// const expiresAt = new Date(Date.now() + (long.expires_in * 1000));
|
||||||
|
|
||||||
|
|
||||||
|
// await saveUserToken(userId, long.access_token, expiresAt);
|
||||||
|
// // 🔥 Redirect to Next.js page after success
|
||||||
|
// return res.redirect('https://devapp.socialbuddy.co/social-media-connect');
|
||||||
|
// // return res.json({ ok: true, token_type: long.token_type, expires_in: long.expires_in });
|
||||||
|
// }
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function callback(req, res) {
|
||||||
|
const { code, error } = req.query;
|
||||||
|
if (error) return res.status(400).send(`OAuth error: ${error}`);
|
||||||
|
if (!code) return res.status(400).send('Missing code');
|
||||||
|
|
||||||
|
// 1) Exchange auth code -> tokens (as you already do)
|
||||||
|
const short = await getShortLivedUserToken(code);
|
||||||
|
const long = await exchangeToLongLivedUserToken(short.access_token);
|
||||||
|
|
||||||
|
// // You *can* skip saving here if you really want FE to send it back
|
||||||
|
// const userId =req.query.userId
|
||||||
|
|
||||||
|
// 2) Build a payload with what FE needs
|
||||||
|
const payload = {
|
||||||
|
// userId,
|
||||||
|
access_token: long.access_token,
|
||||||
|
token_type: long.token_type,
|
||||||
|
expires_in: long.expires_in,
|
||||||
|
// include other fields if you want:
|
||||||
|
refresh_token: long.refresh_token,
|
||||||
|
scope: long.scope,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3) Sign it as a short-lived token (5 minutes, for example)
|
||||||
|
const tempToken = jwt.sign(payload, process.env.OAUTH_TEMP_SECRET, {
|
||||||
|
expiresIn: '5m',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4) Redirect with this signed blob as a query param
|
||||||
|
const url = new URL('https://devapp.socialbuddy.co/social-media-connect');
|
||||||
|
url.searchParams.set('auth', tempToken);
|
||||||
|
|
||||||
|
return res.redirect(url.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function save_oauth(req, res) {
|
||||||
|
const { auth, userId } = req.body;
|
||||||
|
if (!auth) return res.status(400).json({ error: 'Missing auth token' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) Decode & verify
|
||||||
|
const decoded = jwt.verify(auth, process.env.OAUTH_TEMP_SECRET);
|
||||||
|
const { access_token, token_type, expires_in } = decoded;
|
||||||
|
|
||||||
|
const expiresAt = new Date(Date.now() + expires_in * 1000);
|
||||||
|
|
||||||
|
// 2) Save token in DB (your existing helper)
|
||||||
|
await saveUserToken(userId, access_token, expiresAt);
|
||||||
|
|
||||||
|
return res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Finalize social auth error:', err);
|
||||||
|
return res.status(400).json({ error: 'Invalid or expired auth token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
module.exports = { login, callback, save_oauth, status, disconnect };
|
||||||
236
src/controllers/automation.controller.js
Normal file
236
src/controllers/automation.controller.js
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
// src/controllers/automation.controller.js
|
||||||
|
const { getPageConnection } = require('../services/tokenStore.service');
|
||||||
|
const { getBusinessAccountProfile } = require('../services/account.service');
|
||||||
|
const { getIGMedia } = require('../services/media.service');
|
||||||
|
const { getIGComments, replyToIGComment } = require('../services/comments.service');
|
||||||
|
const { generateReply, filterRecentComments, sleep } = require('../services/automation.service');
|
||||||
|
|
||||||
|
// In-memory store for replied comments (replace with DB in production)
|
||||||
|
const repliedCommentsStore = new Map(); // userId -> Set of commentIds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load replied comments for a user
|
||||||
|
*/
|
||||||
|
function loadRepliedComments(userId) {
|
||||||
|
if (!repliedCommentsStore.has(userId)) {
|
||||||
|
repliedCommentsStore.set(userId, new Set());
|
||||||
|
}
|
||||||
|
return repliedCommentsStore.get(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save replied comment ID for a user
|
||||||
|
*/
|
||||||
|
function saveRepliedComment(userId, commentId) {
|
||||||
|
const replied = loadRepliedComments(userId);
|
||||||
|
replied.add(commentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/social/automation/auto-reply
|
||||||
|
* Automated comment reply system
|
||||||
|
* Body: { limit: 10 } (optional - number of posts to check)
|
||||||
|
*/
|
||||||
|
async function autoReplyComments(req, res) {
|
||||||
|
console.log('\n🚀 Starting Instagram Comment Auto-Reply Automation...');
|
||||||
|
|
||||||
|
const userId =req.query.userId
|
||||||
|
const { limit = 10 } = req.body;
|
||||||
|
|
||||||
|
const replied = loadRepliedComments(userId);
|
||||||
|
const results = [];
|
||||||
|
let totalProcessed = 0;
|
||||||
|
let totalReplied = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Get page connection
|
||||||
|
const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
|
||||||
|
if (!pageToken || !igUserId) {
|
||||||
|
return res.status(400).json({ error: 'Connect a channel first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Get account details
|
||||||
|
console.log('📊 Fetching Instagram account details...');
|
||||||
|
const account = await getBusinessAccountProfile(igUserId, pageToken);
|
||||||
|
const accountUsername = (account.username || '').toLowerCase();
|
||||||
|
const accountBio = account.biography || '';
|
||||||
|
|
||||||
|
console.log(`✅ Account: @${account.username}`);
|
||||||
|
console.log(` Followers: ${account.followers_count}`);
|
||||||
|
console.log(` Bio: ${accountBio.substring(0, 50)}...`);
|
||||||
|
|
||||||
|
// Step 3: Fetch recent media posts
|
||||||
|
console.log('\n📸 Fetching recent media posts...');
|
||||||
|
const mediaResp = await getIGMedia(igUserId, pageToken, limit);
|
||||||
|
const mediaList = mediaResp.data || [];
|
||||||
|
console.log(`✅ Found ${mediaList.length} recent posts`);
|
||||||
|
|
||||||
|
// Step 4: Process each post
|
||||||
|
for (const [index, media] of mediaList.entries()) {
|
||||||
|
const postId = media.id;
|
||||||
|
const postCaption = media.caption || '';
|
||||||
|
const postPermalink = media.permalink || '';
|
||||||
|
|
||||||
|
console.log(`\n📝 Post ${index + 1}/${mediaList.length}`);
|
||||||
|
console.log(` Caption: ${postCaption.substring(0, 60)}...`);
|
||||||
|
console.log(` Comments: ${media.comments_count}`);
|
||||||
|
|
||||||
|
if (media.comments_count === 0) {
|
||||||
|
console.log(' ⏭️ No comments, skipping...');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Fetch comments for this post
|
||||||
|
console.log(' 💬 Fetching comments...');
|
||||||
|
const commentsResp = await getIGComments(postId, pageToken);
|
||||||
|
const allComments = commentsResp.data || [];
|
||||||
|
|
||||||
|
// Step 6: Filter to last 24 hours only
|
||||||
|
const recentComments = filterRecentComments(allComments);
|
||||||
|
console.log(` ✅ ${recentComments.length} comments in last 24 hours`);
|
||||||
|
|
||||||
|
// Step 7: Process each recent comment
|
||||||
|
for (const comment of recentComments) {
|
||||||
|
const commentId = comment.id;
|
||||||
|
const commentText = comment.text || '';
|
||||||
|
const commenter = (comment.username || '').toLowerCase();
|
||||||
|
totalProcessed++;
|
||||||
|
|
||||||
|
// Skip if already replied
|
||||||
|
if (replied.has(commentId)) {
|
||||||
|
console.log(` ⏭️ Already replied to comment by @${commenter}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if comment is from the account itself
|
||||||
|
if (commenter === accountUsername) {
|
||||||
|
console.log(` ⏭️ Skipping own comment`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n 💡 New comment by @${commenter}:`);
|
||||||
|
console.log(` "${commentText.substring(0, 80)}..."`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 8: Generate AI reply
|
||||||
|
console.log(' 🤖 Generating AI reply...');
|
||||||
|
const replyText = await generateReply(
|
||||||
|
commentText,
|
||||||
|
accountBio,
|
||||||
|
account.username,
|
||||||
|
postCaption,
|
||||||
|
commenter
|
||||||
|
);
|
||||||
|
console.log(` 📝 Reply: "${replyText}"`);
|
||||||
|
|
||||||
|
// Step 9: Post the reply
|
||||||
|
console.log(' 📤 Posting reply...');
|
||||||
|
const replyRes = await replyToIGComment(commentId, replyText, pageToken);
|
||||||
|
const replyId = replyRes?.id || null;
|
||||||
|
|
||||||
|
if (replyId) {
|
||||||
|
// Mark both comment and reply as processed
|
||||||
|
saveRepliedComment(userId, commentId);
|
||||||
|
saveRepliedComment(userId, replyId);
|
||||||
|
totalReplied++;
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
commentId,
|
||||||
|
commenter,
|
||||||
|
commentText,
|
||||||
|
replyText,
|
||||||
|
replyId,
|
||||||
|
postCaption: postCaption.substring(0, 50),
|
||||||
|
status: 'success'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` ✅ Successfully replied! (ID: ${replyId})`);
|
||||||
|
} else {
|
||||||
|
console.log(' ⚠️ Reply posted but no ID returned');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting: wait 1.2 seconds between replies
|
||||||
|
await sleep(1200);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorMsg = err.response?.data?.error?.message || err.message;
|
||||||
|
console.error(` ❌ Failed to reply: ${errorMsg}`);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
commentId,
|
||||||
|
commenter,
|
||||||
|
commentText,
|
||||||
|
error: errorMsg,
|
||||||
|
status: 'failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 10: Summary
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('✨ AUTOMATION COMPLETE ✨');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log(`📊 Total comments processed: ${totalProcessed}`);
|
||||||
|
console.log(`✅ Successfully replied: ${totalReplied}`);
|
||||||
|
console.log(`❌ Failed: ${results.filter(r => r.status === 'failed').length}`);
|
||||||
|
console.log(`⏭️ Skipped (already replied): ${totalProcessed - results.length}`);
|
||||||
|
console.log('='.repeat(60) + '\n');
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
summary: {
|
||||||
|
totalProcessed,
|
||||||
|
totalReplied,
|
||||||
|
failed: results.filter(r => r.status === 'failed').length,
|
||||||
|
skipped: totalProcessed - results.length,
|
||||||
|
postsChecked: mediaList.length
|
||||||
|
},
|
||||||
|
results
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ AUTOMATION ERROR:', error.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error.message,
|
||||||
|
details: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/social/automation/replied-comments
|
||||||
|
* Get list of replied comment IDs for current user
|
||||||
|
*/
|
||||||
|
async function getRepliedComments(req, res) {
|
||||||
|
const userId =req.query.userId
|
||||||
|
const replied = loadRepliedComments(userId);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
count: replied.size,
|
||||||
|
commentIds: Array.from(replied)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/social/automation/replied-comments
|
||||||
|
* Clear replied comments history for current user
|
||||||
|
*/
|
||||||
|
async function clearRepliedComments(req, res) {
|
||||||
|
const userId =req.query.userId
|
||||||
|
repliedCommentsStore.set(userId, new Set());
|
||||||
|
|
||||||
|
console.log(`🗑️ Cleared replied comments for user: ${userId}`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Replied comments history cleared'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
autoReplyComments,
|
||||||
|
getRepliedComments,
|
||||||
|
clearRepliedComments
|
||||||
|
};
|
||||||
45
src/controllers/channels.controller.js
Normal file
45
src/controllers/channels.controller.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
const { listChannelsForUser, getChannelDetails } = require('../services/channels.service');
|
||||||
|
const { getUserToken, savePageConnection } = require('../services/tokenStore.service');
|
||||||
|
|
||||||
|
async function listChannels(req, res) {
|
||||||
|
const userId = req.query.userId
|
||||||
|
const userToken = await getUserToken(userId);
|
||||||
|
if (!userToken) return res.status(401).json({ error: 'Connect first' });
|
||||||
|
|
||||||
|
const channels = await listChannelsForUser(userToken);
|
||||||
|
|
||||||
|
const rows = await Promise.all(
|
||||||
|
channels.map(async (c) => {
|
||||||
|
const det = await getChannelDetails(c.id, userToken);
|
||||||
|
return {
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
has_linked_account: Boolean(det.instagram_business_account?.id),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.json(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectChannel(req, res) {
|
||||||
|
const { channel_id } = req.body;
|
||||||
|
if (!channel_id) return res.status(400).json({ error: 'channel_id required' });
|
||||||
|
|
||||||
|
const userId = req.query.userId
|
||||||
|
const userToken = await getUserToken(userId);
|
||||||
|
if (!userToken) return res.status(401).json({ error: 'Connect first' });
|
||||||
|
|
||||||
|
const det = await getChannelDetails(channel_id, userToken);
|
||||||
|
const pageToken = det.access_token;
|
||||||
|
const businessAccountId = det.instagram_business_account?.id;
|
||||||
|
|
||||||
|
if (!pageToken || !businessAccountId) {
|
||||||
|
return res.status(400).json({ error: 'Selected channel missing linked business account or token' });
|
||||||
|
}
|
||||||
|
console.log('🔗 Connecting channel:', { userId, channel_id, pageToken, businessAccountId });
|
||||||
|
await savePageConnection(userId, channel_id, pageToken, businessAccountId);
|
||||||
|
return res.json({ ok: true, channel_id, business_account_id: businessAccountId });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listChannels, connectChannel };
|
||||||
215
src/controllers/comments.controller.js
Normal file
215
src/controllers/comments.controller.js
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
// src/controllers/comments.controller.js
|
||||||
|
const { getPageConnection } = require('../services/tokenStore.service');
|
||||||
|
const {
|
||||||
|
getIGComments,
|
||||||
|
replyToIGComment,
|
||||||
|
deleteIGComment,
|
||||||
|
hideIGComment,
|
||||||
|
postIGComment
|
||||||
|
} = require('../services/comments.service');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/social/media/:mediaId/comments
|
||||||
|
* Get comments on a specific media post
|
||||||
|
* Query params: after (pagination cursor)
|
||||||
|
*/
|
||||||
|
async function listComments(req, res) {
|
||||||
|
console.log('💬 Comments list endpoint hit');
|
||||||
|
const userId = req.query.userId
|
||||||
|
const { mediaId } = req.params;
|
||||||
|
const { after } = req.query;
|
||||||
|
|
||||||
|
if (!mediaId) {
|
||||||
|
return res.status(400).json({ error: 'mediaId parameter required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
|
||||||
|
if (!pageToken || !igUserId) {
|
||||||
|
return res.status(400).json({ error: 'Connect a channel first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const comments = await getIGComments(mediaId, pageToken, after);
|
||||||
|
console.log(`✅ Fetched ${comments.data.length} comments`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
data: comments.data,
|
||||||
|
paging: comments.paging,
|
||||||
|
count: comments.data.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Comments list error:', error.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error.message,
|
||||||
|
details: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/social/comments/:commentId/reply
|
||||||
|
* Reply to a specific comment
|
||||||
|
* Body: { message: "reply text" }
|
||||||
|
*/
|
||||||
|
async function replyComment(req, res) {
|
||||||
|
console.log('💬 Reply to comment endpoint hit');
|
||||||
|
const userId = req.query.userId
|
||||||
|
const { commentId } = req.params;
|
||||||
|
const { message } = req.body;
|
||||||
|
|
||||||
|
if (!commentId) {
|
||||||
|
return res.status(400).json({ error: 'commentId parameter required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message || message.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'message is required in body' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
|
||||||
|
if (!pageToken || !igUserId) {
|
||||||
|
return res.status(400).json({ error: 'Connect a channel first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = await replyToIGComment(commentId, message, pageToken);
|
||||||
|
console.log('✅ Reply posted successfully');
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
reply_id: reply.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Reply error:', error.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error.message,
|
||||||
|
details: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/social/comments/:commentId
|
||||||
|
* Delete a comment
|
||||||
|
*/
|
||||||
|
async function deleteComment(req, res) {
|
||||||
|
console.log('🗑️ Delete comment endpoint hit');
|
||||||
|
const userId = req.query.userId
|
||||||
|
const { commentId } = req.params;
|
||||||
|
|
||||||
|
if (!commentId) {
|
||||||
|
return res.status(400).json({ error: 'commentId parameter required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
|
||||||
|
if (!pageToken || !igUserId) {
|
||||||
|
return res.status(400).json({ error: 'Connect a channel first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteIGComment(commentId, pageToken);
|
||||||
|
console.log('✅ Comment deleted successfully');
|
||||||
|
|
||||||
|
return res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Delete comment error:', error.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error.message,
|
||||||
|
details: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/social/comments/:commentId/hide
|
||||||
|
* Hide or unhide a comment
|
||||||
|
* Body: { hide: true/false }
|
||||||
|
*/
|
||||||
|
async function toggleHideComment(req, res) {
|
||||||
|
console.log('👁️ Toggle hide comment endpoint hit');
|
||||||
|
const userId = req.query.userId
|
||||||
|
const { commentId } = req.params;
|
||||||
|
const { hide } = req.body;
|
||||||
|
|
||||||
|
if (!commentId) {
|
||||||
|
return res.status(400).json({ error: 'commentId parameter required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof hide !== 'boolean') {
|
||||||
|
return res.status(400).json({ error: 'hide must be true or false in body' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
|
||||||
|
if (!pageToken || !igUserId) {
|
||||||
|
return res.status(400).json({ error: 'Connect a channel first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await hideIGComment(commentId, hide, pageToken);
|
||||||
|
console.log(`✅ Comment ${hide ? 'hidden' : 'unhidden'} successfully`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
hidden: hide
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Hide comment error:', error.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error.message,
|
||||||
|
details: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/social/media/:mediaId/comments
|
||||||
|
* Post a new top-level comment on a media post
|
||||||
|
* Body: { message: "comment text" }
|
||||||
|
*/
|
||||||
|
async function createComment(req, res) {
|
||||||
|
console.log('💬 Create top-level comment endpoint hit');
|
||||||
|
const userId = req.query.userId;
|
||||||
|
const { mediaId } = req.params;
|
||||||
|
const { message } = req.body;
|
||||||
|
|
||||||
|
if (!mediaId) {
|
||||||
|
return res.status(400).json({ error: 'mediaId parameter required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message || message.trim() === '') {
|
||||||
|
return res.status(400).json({ error: 'message is required in body' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
|
||||||
|
if (!pageToken || !igUserId) {
|
||||||
|
return res.status(400).json({ error: 'Connect a channel first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await postIGComment(mediaId, message, pageToken);
|
||||||
|
console.log('✅ Top-level comment posted successfully');
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
comment_id: comment.id
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Create comment error:', error.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error.message,
|
||||||
|
details: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listComments,
|
||||||
|
replyComment,
|
||||||
|
deleteComment,
|
||||||
|
toggleHideComment,
|
||||||
|
createComment
|
||||||
|
};
|
||||||
169
src/controllers/dm_messages.controller.js
Normal file
169
src/controllers/dm_messages.controller.js
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
const messagesService = require('../services/dm_messages.service');
|
||||||
|
const autoreplyService = require('../services/dm_autoreply.service');
|
||||||
|
const { getPageConnection } = require('../services/tokenStore.service');
|
||||||
|
|
||||||
|
async function resolveTokens(req) {
|
||||||
|
const userId = req?.query?.userId || req?.body?.userId;
|
||||||
|
const bodyToken = req?.body?.pageAccessToken;
|
||||||
|
const bodyIgUserId = req?.body?.igUserId;
|
||||||
|
|
||||||
|
console.log('🔍 Resolving tokens for userId:', userId, bodyToken, bodyIgUserId);
|
||||||
|
|
||||||
|
if (bodyToken) {
|
||||||
|
return {
|
||||||
|
pageAccessToken: bodyToken,
|
||||||
|
igUserId: bodyIgUserId || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error('userId is required as query parameter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
|
||||||
|
if (!pageToken || !igUserId) {
|
||||||
|
throw new Error('Connect a channel first');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageAccessToken: pageToken,
|
||||||
|
igUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getConversations(req, res) {
|
||||||
|
console.log('💬 Get conversations endpoint hit');
|
||||||
|
const userId = req.query.userId;
|
||||||
|
console.log('👤 User ID:', userId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { after } = req.query;
|
||||||
|
|
||||||
|
const tokens = await resolveTokens(req);
|
||||||
|
|
||||||
|
if (!tokens) throw new Error('Connect a channel first');
|
||||||
|
const { pageAccessToken, igUserId } = tokens;
|
||||||
|
|
||||||
|
const conversations = await messagesService.getConversations(
|
||||||
|
igUserId,
|
||||||
|
pageAccessToken,
|
||||||
|
after
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ Fetched ${conversations.data?.length || 0} conversations`);
|
||||||
|
res.json(conversations);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Get conversations error:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMessages(req, res) {
|
||||||
|
console.log('💬 Get messages endpoint hit');
|
||||||
|
const userId = req.query.userId;
|
||||||
|
const { conversationId } = req.params;
|
||||||
|
console.log('👤 User ID:', userId);
|
||||||
|
console.log('💭 Conversation ID:', conversationId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { after } = req.query;
|
||||||
|
const tokens = await resolveTokens(req);
|
||||||
|
if (!tokens) throw new Error('Connect a channel first');
|
||||||
|
const { pageAccessToken } = tokens;
|
||||||
|
|
||||||
|
const messages = await messagesService.getMessages(
|
||||||
|
conversationId,
|
||||||
|
pageAccessToken,
|
||||||
|
after
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ Fetched ${messages.data?.length || 0} messages`);
|
||||||
|
res.json(messages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Get messages error:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage(req, res) {
|
||||||
|
console.log('📤 Send message endpoint hit');
|
||||||
|
const userId = req.query.userId || req.body.userId;
|
||||||
|
const { recipientId, message } = req.body;
|
||||||
|
console.log('👤 User ID:', userId);
|
||||||
|
console.log('📨 Recipient ID:', recipientId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!recipientId || !message) {
|
||||||
|
return res.status(400).json({ error: 'recipientId and message are required in body' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await resolveTokens(req);
|
||||||
|
if (!tokens) throw new Error('Connect a channel first');
|
||||||
|
const { pageAccessToken } = tokens;
|
||||||
|
|
||||||
|
const result = await messagesService.sendMessage(
|
||||||
|
recipientId,
|
||||||
|
message,
|
||||||
|
pageAccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ Message sent successfully`);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Send message error:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAsRead(req, res) {
|
||||||
|
console.log('👁️ Mark as read endpoint hit');
|
||||||
|
const userId = req.query.userId || req.body.userId;
|
||||||
|
const { conversationId } = req.params;
|
||||||
|
console.log('👤 User ID:', userId);
|
||||||
|
console.log('💭 Conversation ID:', conversationId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokens = await resolveTokens(req);
|
||||||
|
if (!tokens) throw new Error('Connect a channel first');
|
||||||
|
const { pageAccessToken } = tokens;
|
||||||
|
|
||||||
|
const result = await messagesService.markAsRead(
|
||||||
|
conversationId,
|
||||||
|
pageAccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ Conversation marked as read`);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Mark as read error:', error.message);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testAutoReply(req, res) {
|
||||||
|
try {
|
||||||
|
const { messageText } = req.body;
|
||||||
|
|
||||||
|
if (!messageText) {
|
||||||
|
return res.status(400).json({ error: 'messageText is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = autoreplyService.generateStaticReply(messageText);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
originalMessage: messageText,
|
||||||
|
autoReply: reply,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getConversations,
|
||||||
|
getMessages,
|
||||||
|
sendMessage,
|
||||||
|
markAsRead,
|
||||||
|
testAutoReply,
|
||||||
|
};
|
||||||
51
src/controllers/dm_webhook.controller.js
Normal file
51
src/controllers/dm_webhook.controller.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
const webhookService = require('../services/dm_webhook.service');
|
||||||
|
const autoreplyService = require('../services/dm_autoreply.service');
|
||||||
|
|
||||||
|
function verifyWebhook(req, res) {
|
||||||
|
try {
|
||||||
|
const challenge = webhookService.verifyWebhook(req.query);
|
||||||
|
|
||||||
|
if (challenge) {
|
||||||
|
return res.status(200).send(challenge);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(403).send('Verification failed');
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWebhook(req, res) {
|
||||||
|
try {
|
||||||
|
const signature = req.headers['x-hub-signature-256'];
|
||||||
|
const rawBody = req.rawBody;
|
||||||
|
|
||||||
|
if (!webhookService.verifySignature(rawBody, signature)) {
|
||||||
|
return res.status(403).send('Invalid signature');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).send('EVENT_RECEIVED');
|
||||||
|
|
||||||
|
const messages = webhookService.processWebhookEvent(req.body);
|
||||||
|
if (!messages.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageAccessToken = process.env.PAGE_ACCESS_TOKEN;
|
||||||
|
if (!pageAccessToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
await autoreplyService.handleIncomingMessage(message, pageAccessToken);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Already acknowledged the webhook; log only
|
||||||
|
console.error('Webhook handling error:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
verifyWebhook,
|
||||||
|
handleWebhook,
|
||||||
|
};
|
||||||
75
src/controllers/media.controller.js
Normal file
75
src/controllers/media.controller.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
// src/controllers/media.controller.js
|
||||||
|
const { getPageConnection } = require('../services/tokenStore.service');
|
||||||
|
const { getIGMedia, getMediaDetails } = require('../services/media.service');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/social/media
|
||||||
|
* Get Instagram media posts for connected account
|
||||||
|
* Query params: limit (default 50), after (pagination cursor)
|
||||||
|
*/
|
||||||
|
async function listMedia(req, res) {
|
||||||
|
console.log('📸 Media list endpoint hit');
|
||||||
|
const userId =req.query.userId
|
||||||
|
const { limit = 50, after } = req.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
|
||||||
|
if (!pageToken || !igUserId) {
|
||||||
|
return res.status(400).json({ error: 'Connect a channel first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = await getIGMedia(igUserId, pageToken, parseInt(limit), after);
|
||||||
|
console.log(`✅ Fetched ${media.data.length} media items`);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
data: media.data,
|
||||||
|
paging: media.paging,
|
||||||
|
count: media.data.length
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Media list error:', error.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error.message,
|
||||||
|
details: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/social/media/:mediaId
|
||||||
|
* Get details of a specific media post
|
||||||
|
*/
|
||||||
|
async function getMedia(req, res) {
|
||||||
|
console.log('📸 Single media endpoint hit');
|
||||||
|
const userId =req.query.userId
|
||||||
|
const { mediaId } = req.params;
|
||||||
|
|
||||||
|
if (!mediaId) {
|
||||||
|
return res.status(400).json({ error: 'mediaId parameter required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { pageToken, igUserId } = await getPageConnection(userId);
|
||||||
|
|
||||||
|
if (!pageToken || !igUserId) {
|
||||||
|
return res.status(400).json({ error: 'Connect a channel first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = await getMediaDetails(mediaId, pageToken);
|
||||||
|
console.log('✅ Media details fetched');
|
||||||
|
|
||||||
|
return res.json(media);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💥 Media details error:', error.message);
|
||||||
|
return res.status(500).json({
|
||||||
|
error: error.message,
|
||||||
|
details: error.response?.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listMedia,
|
||||||
|
getMedia
|
||||||
|
};
|
||||||
465
src/controllers/payment.controller.js
Normal file
465
src/controllers/payment.controller.js
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
const Stripe = require("stripe");
|
||||||
|
const Payment = require("../models/payment.module");
|
||||||
|
const User = require("../models/user.model");
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
|
apiVersion: "2022-11-15",
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -----------------------------------------------
|
||||||
|
PLAN → PRICE ID MAPPING
|
||||||
|
------------------------------------------------ */
|
||||||
|
const PLAN_PRICE_MAP = {
|
||||||
|
basic_monthly: process.env.STRIPE_PRICE_SB_BASIC_MONTHLY,
|
||||||
|
standard_monthly: process.env.STRIPE_PRICE_SB_STANDARD_MONTHLY,
|
||||||
|
premium_monthly: process.env.STRIPE_PRICE_SB_PREMIUM_MONTHLY,
|
||||||
|
|
||||||
|
basic_yearly: process.env.STRIPE_PRICE_SB_BASIC_YEARLY,
|
||||||
|
standard_yearly: process.env.STRIPE_PRICE_SB_STANDARD_YEARLY,
|
||||||
|
premium_yearly: process.env.STRIPE_PRICE_SB_PREMIUM_YEARLY,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* -----------------------------------------------------
|
||||||
|
CREATE CHECKOUT SESSION — SUBSCRIPTIONS
|
||||||
|
------------------------------------------------------ */
|
||||||
|
async function createCheckoutSession(req, res) {
|
||||||
|
try {
|
||||||
|
const { email, planId, userId } = req.body;
|
||||||
|
|
||||||
|
if (!email || !planId || !userId) {
|
||||||
|
return res.status(400).json({ error: "email, planId & userId required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceId = PLAN_PRICE_MAP[planId];
|
||||||
|
if (!priceId)
|
||||||
|
return res.status(400).json({ error: "Invalid planId" });
|
||||||
|
|
||||||
|
const price = await stripe.prices.retrieve(priceId);
|
||||||
|
|
||||||
|
const session = await stripe.checkout.sessions.create({
|
||||||
|
mode: "subscription",
|
||||||
|
customer_email: email,
|
||||||
|
line_items: [{ price: priceId, quantity: 1 }],
|
||||||
|
success_url: `${process.env.FRONTEND_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
|
||||||
|
cancel_url: `${process.env.FRONTEND_URL}/payment/cancel`,
|
||||||
|
metadata: { email, planId, userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Payment.create({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
planId,
|
||||||
|
amount: price.unit_amount || 0,
|
||||||
|
stripeSessionId: session.id,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ url: session.url });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Checkout Error:", err);
|
||||||
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------
|
||||||
|
PAYMENT INTENT (ONE-TIME ADD-ON)
|
||||||
|
------------------------------------------------------ */
|
||||||
|
async function createPaymentIntent(req, res) {
|
||||||
|
try {
|
||||||
|
const { email, planId, userId } = req.body;
|
||||||
|
|
||||||
|
if (!email || !planId || !userId) {
|
||||||
|
return res.status(400).json({ error: "email, planId & userId required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceId = PLAN_PRICE_MAP[planId];
|
||||||
|
if (!priceId)
|
||||||
|
return res.status(400).json({ error: "Invalid planId" });
|
||||||
|
|
||||||
|
const price = await stripe.prices.retrieve(priceId);
|
||||||
|
|
||||||
|
const paymentIntent = await stripe.paymentIntents.create({
|
||||||
|
amount: price.unit_amount,
|
||||||
|
currency: price.currency,
|
||||||
|
metadata: { email, planId, userId },
|
||||||
|
automatic_payment_methods: { enabled: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Payment.create({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
amount: price.unit_amount,
|
||||||
|
planId,
|
||||||
|
stripePaymentIntentId: paymentIntent.id,
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ clientSecret: paymentIntent.client_secret });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("PaymentIntent Error:", err);
|
||||||
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------
|
||||||
|
STRIPE WEBHOOK HANDLER
|
||||||
|
------------------------------------------------------ */
|
||||||
|
async function handleWebhook(req, res) {
|
||||||
|
let event;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signature = req.headers["stripe-signature"];
|
||||||
|
event = stripe.webhooks.constructEvent(
|
||||||
|
req.rawBody,
|
||||||
|
signature,
|
||||||
|
process.env.STRIPE_WEBHOOK_SECRET
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
|
||||||
|
/* -------------------------
|
||||||
|
CHECKOUT SUCCESS
|
||||||
|
---------------------------- */
|
||||||
|
case "checkout.session.completed": {
|
||||||
|
const session = event.data.object;
|
||||||
|
const customerId = session.customer;
|
||||||
|
|
||||||
|
// Fetch subscription details from Stripe
|
||||||
|
const subscription = await stripe.subscriptions.retrieve(
|
||||||
|
session.subscription
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update Payment Record
|
||||||
|
const updatedPayment = await Payment.findOneAndUpdate(
|
||||||
|
{ stripeSessionId: session.id },
|
||||||
|
{
|
||||||
|
subscriptionId: session.subscription,
|
||||||
|
status: "active",
|
||||||
|
subscriptionStartDate: new Date(subscription.current_period_start * 1000),
|
||||||
|
subscriptionEndDate: new Date(subscription.current_period_end * 1000),
|
||||||
|
},
|
||||||
|
{ new: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update User Record with Stripe Customer ID
|
||||||
|
if (updatedPayment && updatedPayment.userId && customerId) {
|
||||||
|
await User.findByIdAndUpdate(updatedPayment.userId, {
|
||||||
|
stripeCustomerId: customerId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------
|
||||||
|
PAYMENT FAILED
|
||||||
|
---------------------------- */
|
||||||
|
case "invoice.payment_failed":
|
||||||
|
await Payment.findOneAndUpdate(
|
||||||
|
{ subscriptionId: event.data.object.subscription },
|
||||||
|
{ status: "failed" }
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
/* -------------------------
|
||||||
|
SUBSCRIPTION CANCELLED
|
||||||
|
---------------------------- */
|
||||||
|
case "customer.subscription.deleted": {
|
||||||
|
const sub = event.data.object;
|
||||||
|
|
||||||
|
await Payment.findOneAndUpdate(
|
||||||
|
{ subscriptionId: sub.id },
|
||||||
|
{
|
||||||
|
status: "canceled",
|
||||||
|
subscriptionEndDate: new Date(sub.canceled_at * 1000),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------
|
||||||
|
CANCEL SUBSCRIPTION (MANUAL CANCEL FROM UI)
|
||||||
|
------------------------------------------------------ */
|
||||||
|
async function cancelSubscription(req, res) {
|
||||||
|
try {
|
||||||
|
const { session_id } = req.body;
|
||||||
|
if (!session_id)
|
||||||
|
return res.status(400).json({ error: "session_id required" });
|
||||||
|
|
||||||
|
const payment = await Payment.findOne({ stripeSessionId: session_id });
|
||||||
|
if (!payment)
|
||||||
|
return res.status(404).json({ error: "Subscription not found" });
|
||||||
|
|
||||||
|
// If it's a Stripe subscription
|
||||||
|
if (payment.subscriptionId && payment.planId !== "free_trial") {
|
||||||
|
const canceledSub = await stripe.subscriptions.cancel(payment.subscriptionId);
|
||||||
|
|
||||||
|
await Payment.findOneAndUpdate(
|
||||||
|
{ stripeSessionId: session_id },
|
||||||
|
{
|
||||||
|
status: "canceled",
|
||||||
|
subscriptionEndDate: new Date(canceledSub.canceled_at * 1000),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// If it's a Free Trial (Mock)
|
||||||
|
else if (payment.planId === "free_trial") {
|
||||||
|
await Payment.findOneAndUpdate(
|
||||||
|
{ stripeSessionId: session_id },
|
||||||
|
{
|
||||||
|
status: "canceled",
|
||||||
|
subscriptionEndDate: new Date(), // End immediately
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also update User model
|
||||||
|
await User.findByIdAndUpdate(payment.userId, {
|
||||||
|
isTrialActive: false,
|
||||||
|
trialEndsAt: new Date() // Expire immediately
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ message: "Subscription/Trial cancelled successfully" });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Cancel subscription error:", err);
|
||||||
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------
|
||||||
|
GET PAYMENT DETAILS
|
||||||
|
------------------------------------------------------ */
|
||||||
|
async function getPaymentDetails(req, res) {
|
||||||
|
try {
|
||||||
|
const { session_id } = req.query;
|
||||||
|
|
||||||
|
if (!session_id)
|
||||||
|
return res.status(400).json({ error: "session_id required" });
|
||||||
|
|
||||||
|
const payment = await Payment.findOne({ stripeSessionId: session_id });
|
||||||
|
|
||||||
|
if (!payment)
|
||||||
|
return res.status(404).json({ error: "Payment not found" });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "Payment details fetched successfully",
|
||||||
|
data: {
|
||||||
|
id: payment._id,
|
||||||
|
userId: payment.userId,
|
||||||
|
email: payment.email,
|
||||||
|
planId: payment.planId,
|
||||||
|
amount: payment.amount / 100 || 0,
|
||||||
|
status: payment.status,
|
||||||
|
stripeSessionId: payment.stripeSessionId,
|
||||||
|
subscriptionId: payment.subscriptionId,
|
||||||
|
subscriptionStartDate: payment.subscriptionStartDate,
|
||||||
|
subscriptionEndDate: payment.subscriptionEndDate,
|
||||||
|
createdAt: payment.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Payment details fetch error:", err);
|
||||||
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------
|
||||||
|
ACTIVATE FREE TRIAL (7 DAYS)
|
||||||
|
------------------------------------------------------ */
|
||||||
|
async function activateTrial(req, res) {
|
||||||
|
try {
|
||||||
|
const { userId, email } = req.body;
|
||||||
|
|
||||||
|
if (!userId || !email) {
|
||||||
|
return res.status(400).json({ error: "userId & email required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already had a trial
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
if (!user) return res.status(404).json({ error: "User not found" });
|
||||||
|
|
||||||
|
// If user has already used trial logic (if you want to restrict it once)
|
||||||
|
// For now, let's assume if they have a 'free_trial' payment record, they can't do it again.
|
||||||
|
const existingTrial = await Payment.findOne({ userId, planId: "free_trial" });
|
||||||
|
if (existingTrial) {
|
||||||
|
return res.status(400).json({ error: "Trial already activated previously." });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 7-day trial dates
|
||||||
|
const startDate = new Date();
|
||||||
|
const endDate = new Date();
|
||||||
|
endDate.setDate(startDate.getDate() + 7);
|
||||||
|
|
||||||
|
// Create Payment Record (dummy so frontend sees it as active subscription)
|
||||||
|
const payment = await Payment.create({
|
||||||
|
userId,
|
||||||
|
email,
|
||||||
|
planId: "free_trial",
|
||||||
|
amount: 0,
|
||||||
|
status: "active",
|
||||||
|
subscriptionStartDate: startDate,
|
||||||
|
subscriptionEndDate: endDate,
|
||||||
|
stripeSessionId: `trial_${userId}_${Date.now()}` // Mock ID
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update User model
|
||||||
|
user.trialEndsAt = endDate;
|
||||||
|
user.isTrialActive = true;
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: "7-day free trial activated!",
|
||||||
|
payment: {
|
||||||
|
id: payment._id,
|
||||||
|
planId: payment.planId,
|
||||||
|
amount: payment.amount,
|
||||||
|
status: payment.status,
|
||||||
|
sessionId: payment.stripeSessionId,
|
||||||
|
createdAt: payment.createdAt
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Activate trial error:", err);
|
||||||
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------
|
||||||
|
GET USER SUBSCRIPTION STATUS (For Pricing Page)
|
||||||
|
------------------------------------------------------ */
|
||||||
|
async function getUserSubscriptionStatus(req, res) {
|
||||||
|
try {
|
||||||
|
const { userId } = req.query;
|
||||||
|
if (!userId) return res.status(400).json({ error: "userId required" });
|
||||||
|
|
||||||
|
// Check if user ever had a free trial
|
||||||
|
const trialRecord = await Payment.findOne({ userId, planId: "free_trial" });
|
||||||
|
const hasUsedTrial = !!trialRecord;
|
||||||
|
|
||||||
|
// Check for currently active subscription (Trial or Paid)
|
||||||
|
// We look for status 'active' and end date in the future
|
||||||
|
const activeSub = await Payment.findOne({
|
||||||
|
userId,
|
||||||
|
status: "active",
|
||||||
|
subscriptionEndDate: { $gt: new Date() }
|
||||||
|
}).sort({ createdAt: -1 });
|
||||||
|
|
||||||
|
let isTrialActive = false;
|
||||||
|
let trialEndsAt = null;
|
||||||
|
let currentPlan = null;
|
||||||
|
let stripeSessionId = null;
|
||||||
|
|
||||||
|
if (activeSub) {
|
||||||
|
currentPlan = activeSub.planId;
|
||||||
|
stripeSessionId = activeSub.stripeSessionId;
|
||||||
|
if (activeSub.planId === "free_trial") {
|
||||||
|
isTrialActive = true;
|
||||||
|
trialEndsAt = activeSub.subscriptionEndDate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
hasUsedTrial,
|
||||||
|
isTrialActive,
|
||||||
|
trialStartDate: trialRecord ? trialRecord.subscriptionStartDate : null,
|
||||||
|
trialEndsAt,
|
||||||
|
currentPlan,
|
||||||
|
stripeSessionId
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Get sub status error:", err);
|
||||||
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------
|
||||||
|
CREATE CUSTOMER PORTAL SESSION (Manage Billing)
|
||||||
|
------------------------------------------------------ */
|
||||||
|
async function createPortalSession(req, res) {
|
||||||
|
try {
|
||||||
|
const { userId } = req.body;
|
||||||
|
if (!userId) return res.status(400).json({ error: "userId required" });
|
||||||
|
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
if (!user || !user.stripeCustomerId) {
|
||||||
|
return res.status(404).json({ error: "No Stripe customer found for this user" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await stripe.billingPortal.sessions.create({
|
||||||
|
customer: user.stripeCustomerId,
|
||||||
|
return_url: `${process.env.FRONTEND_URL}/account-settings`,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ url: session.url });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Portal Session Error:", err);
|
||||||
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------
|
||||||
|
GET BILLING INFO (Last 4 digits)
|
||||||
|
------------------------------------------------------ */
|
||||||
|
async function getBillingInfo(req, res) {
|
||||||
|
try {
|
||||||
|
const { userId } = req.query;
|
||||||
|
if (!userId) return res.status(400).json({ error: "userId required" });
|
||||||
|
|
||||||
|
const user = await User.findById(userId);
|
||||||
|
if (!user) return res.status(404).json({ error: "User not found" });
|
||||||
|
|
||||||
|
if (!user.stripeCustomerId) {
|
||||||
|
return res.json({ hasPaymentMethod: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// List payment methods
|
||||||
|
const paymentMethods = await stripe.customers.listPaymentMethods(
|
||||||
|
user.stripeCustomerId,
|
||||||
|
{ type: 'card' }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (paymentMethods.data && paymentMethods.data.length > 0) {
|
||||||
|
const pm = paymentMethods.data[0]; // Just take the first one
|
||||||
|
return res.json({
|
||||||
|
hasPaymentMethod: true,
|
||||||
|
brand: pm.card.brand,
|
||||||
|
last4: pm.card.last4,
|
||||||
|
exp_month: pm.card.exp_month,
|
||||||
|
exp_year: pm.card.exp_year
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ hasPaymentMethod: false });
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Get billing info error:", err);
|
||||||
|
res.status(500).json({ error: "Internal Server Error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------- */
|
||||||
|
module.exports = {
|
||||||
|
createCheckoutSession,
|
||||||
|
createPaymentIntent,
|
||||||
|
handleWebhook,
|
||||||
|
cancelSubscription,
|
||||||
|
getPaymentDetails,
|
||||||
|
activateTrial,
|
||||||
|
getUserSubscriptionStatus,
|
||||||
|
createPortalSession,
|
||||||
|
getBillingInfo
|
||||||
|
};
|
||||||
364
src/controllers/userauth.controller.js
Normal file
364
src/controllers/userauth.controller.js
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
const bcrypt = require("bcrypt");
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const User = require("../models/user.model.js");
|
||||||
|
const { sendResetPasswordMail, sendSignupMail } = require("../utils/mailer.js");
|
||||||
|
const crypto = require("crypto");
|
||||||
|
const passport = require("passport");
|
||||||
|
const Payment = require("../models/payment.module.js");
|
||||||
|
|
||||||
|
|
||||||
|
// ======================= SIGNUP =======================
|
||||||
|
async function signup(req, res) {
|
||||||
|
try {
|
||||||
|
const { name, email, mobileNumber, password } = req.body;
|
||||||
|
|
||||||
|
// Validate fields
|
||||||
|
if (!name || !email || !mobileNumber || !password) {
|
||||||
|
return res.status(400).json({ error: "All fields are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const exists = await User.findOne({ email });
|
||||||
|
if (exists) {
|
||||||
|
return res.status(400).json({ error: "User already exists" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await User.create({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
mobileNumber,
|
||||||
|
passwordHash,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send confirmation email (non-blocking)
|
||||||
|
sendSignupMail(email, name)
|
||||||
|
.then(() => console.log("Signup email sent to", email))
|
||||||
|
.catch((err) => console.error("Email send failed:", err));
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
message: "Signup success, email sent",
|
||||||
|
id: user._id,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: "Signup failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================= LOGIN =======================
|
||||||
|
async function login(req, res) {
|
||||||
|
try {
|
||||||
|
console.log("LOGIN REQ BODY =>", req.body);
|
||||||
|
|
||||||
|
const { email, password } = req.body;
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: "Email & password required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findOne({ email });
|
||||||
|
if (!user) return res.status(401).json({ error: "Invalid credentials" });
|
||||||
|
|
||||||
|
const match = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!match) return res.status(401).json({ error: "Invalid credentials" });
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ id: user._id, email: user.email },
|
||||||
|
process.env.JWT_SECRET,
|
||||||
|
{ expiresIn: "1h" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ⭐ Fetch payment by userId OR email
|
||||||
|
const payment = await Payment.findOne({
|
||||||
|
$or: [
|
||||||
|
{ userId: user._id },
|
||||||
|
{ email: user.email }
|
||||||
|
]
|
||||||
|
})
|
||||||
|
.sort({ createdAt: -1 })
|
||||||
|
.select("-__v");
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
message: "Login success",
|
||||||
|
token,
|
||||||
|
user: {
|
||||||
|
id: user._id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
mobileNumber: user.mobileNumber,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
payment: payment
|
||||||
|
? {
|
||||||
|
id: payment._id,
|
||||||
|
planId: payment.planId,
|
||||||
|
amount: payment.amount/100,
|
||||||
|
status: payment.status,
|
||||||
|
sessionId: payment.stripeSessionId,
|
||||||
|
subscriptionId: payment.subscriptionId,
|
||||||
|
createdAt: payment.createdAt,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("LOGIN ERROR:", err);
|
||||||
|
return res.status(500).json({ error: "Login failed" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ======================= CHANGE PASSWORD =======================
|
||||||
|
async function changePassword(req, res) {
|
||||||
|
try {
|
||||||
|
const { currentPassword, newPassword } = req.body;
|
||||||
|
|
||||||
|
if (!currentPassword || !newPassword) {
|
||||||
|
return res.status(400).json({ error: "Current password and new password are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await User.findById(req.user.id);
|
||||||
|
if (!user) return res.status(404).json({ error: "User not found" });
|
||||||
|
|
||||||
|
const isMatch = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||||
|
if (!isMatch)
|
||||||
|
return res.status(401).json({ error: "Current password is incorrect" });
|
||||||
|
|
||||||
|
user.passwordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
res.json({ message: "Password updated successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("changePassword error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to change password" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================= FORGOT PASSWORD =======================
|
||||||
|
async function forgotPassword(req, res) {
|
||||||
|
try {
|
||||||
|
const { email } = req.body;
|
||||||
|
if (!email) return res.status(400).json({ error: "Email is required" });
|
||||||
|
|
||||||
|
const user = await User.findOne({ email });
|
||||||
|
|
||||||
|
if (!user)
|
||||||
|
return res.json({
|
||||||
|
message: "If the email is registered, a reset link has been sent.",
|
||||||
|
verificationCode: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4-digit numeric code
|
||||||
|
const verificationCode = Math.floor(1000 + Math.random() * 9000).toString();
|
||||||
|
|
||||||
|
user.resetPasswordToken = verificationCode;
|
||||||
|
user.resetPasswordExpires = Date.now() + 60 * 60 * 1000; // 1 hour
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
// Send code via email
|
||||||
|
await sendResetPasswordMail(email, verificationCode);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "If the email is registered, a reset link has been sent.",
|
||||||
|
verificationCode,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("forgotPassword error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to send reset link" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ======================= RESET PASSWORD =======================
|
||||||
|
async function resetPassword(req, res) {
|
||||||
|
try {
|
||||||
|
const { token, newPassword } = req.body;
|
||||||
|
if (!token || !newPassword)
|
||||||
|
return res.status(400).json({ error: "Token and new password are required" });
|
||||||
|
|
||||||
|
const user = await User.findOne({
|
||||||
|
resetPasswordToken: token,
|
||||||
|
resetPasswordExpires: { $gt: Date.now() },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return res.status(400).json({ error: "Invalid or expired token" });
|
||||||
|
|
||||||
|
user.passwordHash = await bcrypt.hash(newPassword, 10);
|
||||||
|
user.resetPasswordToken = undefined;
|
||||||
|
user.resetPasswordExpires = undefined;
|
||||||
|
await user.save();
|
||||||
|
|
||||||
|
res.json({ message: "Password has been reset successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("resetPassword error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to reset password" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// -----------------------------------
|
||||||
|
// 🔹 Google Login Step 1
|
||||||
|
// -----------------------------------
|
||||||
|
async function googleAuth(req, res, next) {
|
||||||
|
passport.authenticate("google", {
|
||||||
|
scope: ["profile", "email"],
|
||||||
|
})(req, res, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------
|
||||||
|
// 🔹 Google Callback Step 2
|
||||||
|
// -----------------------------------
|
||||||
|
async function googleAuthCallback(req, res) {
|
||||||
|
try {
|
||||||
|
const user = req.user;
|
||||||
|
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ userid: user._id, email: user.email },
|
||||||
|
process.env.JWT_SECRET || "SECRET_KEY",
|
||||||
|
{ expiresIn: "1h" }
|
||||||
|
);
|
||||||
|
|
||||||
|
// ✅ Localhost-safe cookie
|
||||||
|
res.cookie("token", token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: false,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ✅ Redirect to frontend
|
||||||
|
const frontendUrl = new URL("https://devapp.socialbuddy.co/login");
|
||||||
|
frontendUrl.searchParams.append("userid", user._id.toString());
|
||||||
|
frontendUrl.searchParams.append("email", user.email);
|
||||||
|
|
||||||
|
return res.redirect(frontendUrl.toString());
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Google login callback error:", err);
|
||||||
|
return res.redirect(
|
||||||
|
"https://devapp.socialbuddy.co/login?error=google_auth_failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function createUser(req, res) {
|
||||||
|
try {
|
||||||
|
const { name, email, mobileNumber, password, role } = req.body;
|
||||||
|
|
||||||
|
if (!name || !email || !password)
|
||||||
|
return res.status(400).json({ error: "Name, email & password required" });
|
||||||
|
|
||||||
|
const exists = await User.findOne({ email });
|
||||||
|
if (exists) return res.status(400).json({ error: "Email already exists" });
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const user = await User.create({
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
mobileNumber,
|
||||||
|
passwordHash,
|
||||||
|
role
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "User created successfully",
|
||||||
|
user: {
|
||||||
|
id: user._id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
mobileNumber: user.mobileNumber,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Create user error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to create user" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function getUsers(req, res) {
|
||||||
|
try {
|
||||||
|
const users = await User.find()
|
||||||
|
.select("name email mobileNumber role createdAt updatedAt");
|
||||||
|
|
||||||
|
res.json(users);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Get users error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to fetch users" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function getUserById(req, res) {
|
||||||
|
try {
|
||||||
|
const user = await User.findById(req.params.id)
|
||||||
|
.select("name email mobileNumber role createdAt updatedAt");
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(404).json({ error: "User not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(user);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Get user error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to fetch user" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUser(req, res) {
|
||||||
|
try {
|
||||||
|
const { name, email, mobileNumber, role } = req.body;
|
||||||
|
|
||||||
|
const updated = await User.findByIdAndUpdate(
|
||||||
|
req.params.id,
|
||||||
|
{ name, email, mobileNumber, role },
|
||||||
|
{ new: true }
|
||||||
|
).select("name email mobileNumber role");
|
||||||
|
|
||||||
|
if (!updated) return res.status(404).json({ error: "User not found" });
|
||||||
|
|
||||||
|
res.json({ message: "User updated", user: updated });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Update user error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to update user" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function deleteUser(req, res) {
|
||||||
|
try {
|
||||||
|
const deleted = await User.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
if (!deleted) return res.status(404).json({ error: "User not found" });
|
||||||
|
|
||||||
|
res.json({ message: "User deleted successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Delete user error:", err);
|
||||||
|
res.status(500).json({ error: "Failed to delete user" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
signup,
|
||||||
|
login,
|
||||||
|
changePassword,
|
||||||
|
forgotPassword,
|
||||||
|
resetPassword,
|
||||||
|
googleAuth,
|
||||||
|
googleAuthCallback,
|
||||||
|
|
||||||
|
// CRUD
|
||||||
|
createUser,
|
||||||
|
getUsers,
|
||||||
|
getUserById,
|
||||||
|
updateUser,
|
||||||
|
deleteUser,
|
||||||
|
};
|
||||||
65
src/lib/http.js
Normal file
65
src/lib/http.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
// const axios = require('axios');
|
||||||
|
|
||||||
|
// const http = axios.create({
|
||||||
|
// timeout: 15000,
|
||||||
|
// maxRedirects: 2,
|
||||||
|
// validateStatus: (s) => s >= 200 && s < 500,
|
||||||
|
// });
|
||||||
|
|
||||||
|
// http.interceptors.response.use(async (res) => {
|
||||||
|
// if (res.status === 429 || res.status >= 500) {
|
||||||
|
// const retryAfter = Number(res.headers['retry-after'] || 1);
|
||||||
|
// await new Promise(r => setTimeout(r, retryAfter * 1000));
|
||||||
|
// }
|
||||||
|
// return res;
|
||||||
|
// });
|
||||||
|
|
||||||
|
// module.exports = { http };
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const http = axios.create({
|
||||||
|
timeout: 15000,
|
||||||
|
maxRedirects: 2,
|
||||||
|
validateStatus: (s) => s >= 200 && s < 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
http.interceptors.request.use((config) => {
|
||||||
|
console.log(`📤 ${config.method.toUpperCase()} ${config.url}`);
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
http.interceptors.response.use(async (res) => {
|
||||||
|
console.log(`📥 ${res.status} ${res.config.url}`);
|
||||||
|
|
||||||
|
// Log errors for debugging
|
||||||
|
if (res.status >= 400) {
|
||||||
|
console.error('❌ HTTP Error Response:', {
|
||||||
|
status: res.status,
|
||||||
|
url: res.config.url,
|
||||||
|
data: res.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retry logic for rate limits and server errors
|
||||||
|
if (res.status === 429 || res.status >= 500) {
|
||||||
|
const retryAfter = Number(res.headers['retry-after'] || 1);
|
||||||
|
console.log(`⏳ Retrying after ${retryAfter}s...`);
|
||||||
|
await new Promise(r => setTimeout(r, retryAfter * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}, (error) => {
|
||||||
|
// Handle network errors (DNS, timeout, connection refused)
|
||||||
|
if (error.code === 'ENOTFOUND') {
|
||||||
|
console.error('🌐 DNS Error: Cannot resolve hostname', error.config?.url);
|
||||||
|
} else if (error.code === 'ETIMEDOUT') {
|
||||||
|
console.error('⏱️ Timeout Error:', error.config?.url);
|
||||||
|
} else if (error.code === 'ECONNREFUSED') {
|
||||||
|
console.error('🚫 Connection Refused:', error.config?.url);
|
||||||
|
} else {
|
||||||
|
console.error('💥 Request Error:', error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { http };
|
||||||
8
src/middlewares/admin.middleware.js
Normal file
8
src/middlewares/admin.middleware.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
function adminOnly(req, res, next) {
|
||||||
|
if (req.user.role !== "admin") {
|
||||||
|
return res.status(403).json({ error: "Admin access required" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { adminOnly };
|
||||||
19
src/middlewares/auth.middleware.js
Normal file
19
src/middlewares/auth.middleware.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
|
||||||
|
function authMiddleware(req, res, next) {
|
||||||
|
const header = req.headers.authorization;
|
||||||
|
if (!header || !header.startsWith("Bearer ")) {
|
||||||
|
return res.status(401).json({ error: "Missing token" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = header.split(" ")[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: "Invalid or expired token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { authMiddleware };
|
||||||
24
src/middlewares/dm_rawBody.middleware.js
Normal file
24
src/middlewares/dm_rawBody.middleware.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Capture raw body for DM webhook signature verification.
|
||||||
|
* Must run before JSON parsing. Limited to DM webhook route to avoid overhead.
|
||||||
|
*/
|
||||||
|
function captureDmRawBody(req, res, next) {
|
||||||
|
const path = req.originalUrl || req.url;
|
||||||
|
if (path.startsWith('/api/dm/webhook')) {
|
||||||
|
let data = '';
|
||||||
|
req.setEncoding('utf8');
|
||||||
|
|
||||||
|
req.on('data', (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('end', () => {
|
||||||
|
req.rawBody = data;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { captureDmRawBody };
|
||||||
6
src/middlewares/pageSpeedErrorHandler.js
Normal file
6
src/middlewares/pageSpeedErrorHandler.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
const errorHandler = (err, req, res, next) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ message: err.message || "Internal Server Error" });
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = { errorHandler };
|
||||||
22
src/models/payment.module.js
Normal file
22
src/models/payment.module.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const PaymentSchema = new mongoose.Schema({
|
||||||
|
userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true },
|
||||||
|
email: { type: String, required: true },
|
||||||
|
planId: { type: String, required: true },
|
||||||
|
|
||||||
|
stripeSessionId: { type: String },
|
||||||
|
stripePaymentIntentId: { type: String },
|
||||||
|
subscriptionId: { type: String },
|
||||||
|
|
||||||
|
amount: { type: Number },
|
||||||
|
|
||||||
|
status: { type: String, default: "pending" },
|
||||||
|
|
||||||
|
// 🔥 Added fields
|
||||||
|
subscriptionStartDate: { type: Date },
|
||||||
|
subscriptionEndDate: { type: Date },
|
||||||
|
|
||||||
|
}, { timestamps: true });
|
||||||
|
|
||||||
|
module.exports = mongoose.model("Payment", PaymentSchema);
|
||||||
27
src/models/user.model.js
Normal file
27
src/models/user.model.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const mongoose = require("mongoose");
|
||||||
|
|
||||||
|
const userSchema = new mongoose.Schema(
|
||||||
|
{
|
||||||
|
name: { type: String },
|
||||||
|
email: { type: String, required: true, unique: true, lowercase: true },
|
||||||
|
mobileNumber: { type: String },
|
||||||
|
passwordHash: { type: String, required: true },
|
||||||
|
|
||||||
|
// ⭐ ADD ROLE
|
||||||
|
role: {
|
||||||
|
type: String,
|
||||||
|
enum: ["customer", "admin", "partner"],
|
||||||
|
default: "customer",
|
||||||
|
},
|
||||||
|
resetPasswordToken: { type: String },
|
||||||
|
resetPasswordExpires: { type: Date },
|
||||||
|
|
||||||
|
// ⭐ FREE TRIAL
|
||||||
|
trialEndsAt: { type: Date },
|
||||||
|
isTrialActive: { type: Boolean, default: false },
|
||||||
|
stripeCustomerId: { type: String },
|
||||||
|
},
|
||||||
|
{ timestamps: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
module.exports = mongoose.model("User", userSchema);
|
||||||
43
src/routes/auth.routes.js
Normal file
43
src/routes/auth.routes.js
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const passport = require("passport");
|
||||||
|
|
||||||
|
const {
|
||||||
|
signup,
|
||||||
|
login,
|
||||||
|
changePassword,
|
||||||
|
forgotPassword,
|
||||||
|
resetPassword,
|
||||||
|
googleAuth,
|
||||||
|
googleAuthCallback,
|
||||||
|
} = require("../controllers/userauth.controller.js");
|
||||||
|
|
||||||
|
const { authMiddleware } = require("../middlewares/auth.middleware.js");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post("/signup", signup);
|
||||||
|
router.post("/login", login);
|
||||||
|
router.post("/change-password", authMiddleware, changePassword);
|
||||||
|
router.post("/forgot-password", forgotPassword);
|
||||||
|
router.post("/reset-password", resetPassword);
|
||||||
|
|
||||||
|
// ✅ Google OAuth
|
||||||
|
router.get("/google", googleAuth);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
"/google/callback",
|
||||||
|
passport.authenticate("google", {
|
||||||
|
failureRedirect: "https://devapp.socialbuddy.co/login?error=google_auth_failed",
|
||||||
|
}),
|
||||||
|
googleAuthCallback
|
||||||
|
);
|
||||||
|
|
||||||
|
// router.get("/protected", protectedRoute);
|
||||||
|
|
||||||
|
|
||||||
|
// example protected route
|
||||||
|
router.get("/profile", authMiddleware, (req, res) => {
|
||||||
|
res.json({ user: req.user });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
11
src/routes/dm_messages.routes.js
Normal file
11
src/routes/dm_messages.routes.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const messagesController = require('../controllers/dm_messages.controller');
|
||||||
|
|
||||||
|
router.get('/conversations', messagesController.getConversations);
|
||||||
|
router.get('/conversations/:conversationId/messages', messagesController.getMessages);
|
||||||
|
router.post('/send', messagesController.sendMessage);
|
||||||
|
router.post('/conversations/:conversationId/read', messagesController.markAsRead);
|
||||||
|
router.post('/test-autoreply', messagesController.testAutoReply);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
8
src/routes/dm_webhook.routes.js
Normal file
8
src/routes/dm_webhook.routes.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const webhookController = require('../controllers/dm_webhook.controller');
|
||||||
|
|
||||||
|
router.get('/', webhookController.verifyWebhook);
|
||||||
|
router.post('/', webhookController.handleWebhook);
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
26
src/routes/payment.routes.js
Normal file
26
src/routes/payment.routes.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
const controller = require("../controllers/payment.controller");
|
||||||
|
|
||||||
|
router.post("/create-checkout-session", controller.createCheckoutSession);
|
||||||
|
router.post("/create-payment-intent", controller.createPaymentIntent);
|
||||||
|
router.post("/activate-trial", controller.activateTrial);
|
||||||
|
router.get("/status", controller.getUserSubscriptionStatus);
|
||||||
|
|
||||||
|
|
||||||
|
// Get payment details
|
||||||
|
router.get("/details", controller.getPaymentDetails);
|
||||||
|
|
||||||
|
// Cancel subscription manually
|
||||||
|
router.post("/cancel", controller.cancelSubscription);
|
||||||
|
|
||||||
|
// Stripe Webhook
|
||||||
|
router.post("/webhook", express.raw({ type: "application/json" }), controller.handleWebhook);
|
||||||
|
|
||||||
|
// Portal & Billing
|
||||||
|
router.post("/create-portal-session", controller.createPortalSession);
|
||||||
|
router.get("/billing-info", controller.getBillingInfo);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
58
src/routes/social.routes.js
Normal file
58
src/routes/social.routes.js
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
// src/routes/social.routes.js
|
||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Controllers
|
||||||
|
const { login, callback, save_oauth, status, disconnect } = require('../controllers/auth.controller');
|
||||||
|
const { listChannels, connectChannel } = require('../controllers/channels.controller');
|
||||||
|
const { getAccount } = require('../controllers/account.controller');
|
||||||
|
const { listMedia, getMedia } = require('../controllers/media.controller');
|
||||||
|
const {
|
||||||
|
listComments,
|
||||||
|
replyComment,
|
||||||
|
deleteComment,
|
||||||
|
toggleHideComment,
|
||||||
|
createComment
|
||||||
|
} = require('../controllers/comments.controller');
|
||||||
|
const {
|
||||||
|
autoReplyComments,
|
||||||
|
getRepliedComments,
|
||||||
|
clearRepliedComments
|
||||||
|
} = require('../controllers/automation.controller');
|
||||||
|
|
||||||
|
|
||||||
|
// ===== Auth Routes =====
|
||||||
|
router.get('/auth/status', status);
|
||||||
|
router.get('/auth/login', login);
|
||||||
|
router.get('/auth/callback', callback);
|
||||||
|
router.post('/auth/save-oauth', save_oauth);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
router.post('/auth/disconnect', disconnect);
|
||||||
|
|
||||||
|
|
||||||
|
// ===== Channel Routes =====
|
||||||
|
router.get('/channels', listChannels);
|
||||||
|
router.post('/connect', connectChannel);
|
||||||
|
|
||||||
|
// ===== Account Routes =====
|
||||||
|
router.get('/account', getAccount);
|
||||||
|
|
||||||
|
// ===== Media Routes =====
|
||||||
|
router.get('/media', listMedia); // GET /api/social/media?limit=50&after=cursor
|
||||||
|
router.get('/media/:mediaId', getMedia); // GET /api/social/media/{mediaId}
|
||||||
|
|
||||||
|
// ===== Comments Routes =====
|
||||||
|
router.get('/media/:mediaId/comments', listComments); // GET /api/social/media/{mediaId}/comments
|
||||||
|
router.post('/media/:mediaId/comments', createComment); // POST /api/social/media/{mediaId}/comments
|
||||||
|
router.post('/comments/:commentId/reply', replyComment); // POST /api/social/comments/{commentId}/reply
|
||||||
|
router.delete('/comments/:commentId', deleteComment); // DELETE /api/social/comments/{commentId}
|
||||||
|
router.post('/comments/:commentId/hide', toggleHideComment); // POST /api/social/comments/{commentId}/hide
|
||||||
|
|
||||||
|
// ===== Automation Routes =====
|
||||||
|
router.post('/automation/auto-reply', autoReplyComments); // POST /api/social/automation/auto-reply
|
||||||
|
router.get('/automation/replied-comments', getRepliedComments); // GET /api/social/automation/replied-comments
|
||||||
|
router.delete('/automation/replied-comments', clearRepliedComments); // DELETE /api/social/automation/replied-comments
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
19
src/routes/user.routes.js
Normal file
19
src/routes/user.routes.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const controller = require("../controllers/userauth.controller.js");
|
||||||
|
const { authMiddleware } = require("../middlewares/auth.middleware.js");
|
||||||
|
const { adminOnly } = require("../middlewares/admin.middleware.js");
|
||||||
|
|
||||||
|
// 🔐 ADMIN ONLY – USER MANAGEMENT
|
||||||
|
// router.post("/create", authMiddleware, adminOnly, controller.createUser);
|
||||||
|
// router.get("/", controller.getUsers);
|
||||||
|
// router.get("/:id", controller.getUserById);
|
||||||
|
// router.put("/:id", authMiddleware, adminOnly, controller.updateUser);
|
||||||
|
// router.delete("/:id", authMiddleware, adminOnly, controller.deleteUser);
|
||||||
|
router.post("/create", controller.createUser);
|
||||||
|
router.get("/", controller.getUsers);
|
||||||
|
router.get("/:id", controller.getUserById);
|
||||||
|
router.put("/:id", controller.updateUser);
|
||||||
|
router.delete("/:id", controller.deleteUser);
|
||||||
|
module.exports = router;
|
||||||
17
src/services/account.service.js
Normal file
17
src/services/account.service.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const { http } = require('../lib/http');
|
||||||
|
const GRAPH = 'https://graph.facebook.com';
|
||||||
|
const GRAPH_VER = process.env.GRAPH_VER || 'v21.0';
|
||||||
|
|
||||||
|
async function getBusinessAccountProfile(businessAccountId, pageAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${businessAccountId}`;
|
||||||
|
const { data } = await http.get(url, {
|
||||||
|
params: {
|
||||||
|
fields: 'id,username,biography,profile_picture_url,website,followers_count,follows_count,media_count',
|
||||||
|
access_token: pageAccessToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getBusinessAccountProfile };
|
||||||
0
src/services/automation
Normal file
0
src/services/automation
Normal file
93
src/services/automation.service.js
Normal file
93
src/services/automation.service.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
// src/services/automation.service.js
|
||||||
|
const axios = require("axios");
|
||||||
|
|
||||||
|
// Your AI Core endpoint
|
||||||
|
const AI_CORE_URL =
|
||||||
|
process.env.AI_CORE_URL || "https://llm.thedomainnest.com/chat-json";
|
||||||
|
|
||||||
|
// Optional: if you protect your endpoint with a token later, set AI_CORE_API_KEY in env
|
||||||
|
const AI_CORE_API_KEY = process.env.AI_CORE_API_KEY || "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate AI reply using your AI Core endpoint
|
||||||
|
* @param {string} commentText - The comment text
|
||||||
|
* @param {string} accountBio - Instagram account biography
|
||||||
|
* @param {string} accountUsername - Instagram username
|
||||||
|
* @param {string} postCaption - Post caption
|
||||||
|
* @returns {Promise<string>} Generated reply text
|
||||||
|
*/
|
||||||
|
async function generateReply(commentText, accountBio, accountUsername, postCaption, commentedby="") {
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const prompt = `You are managing an Instagram account.
|
||||||
|
- Biography: "${accountBio}"
|
||||||
|
- Username: "${accountUsername}"
|
||||||
|
- Post Caption: "${postCaption}"
|
||||||
|
A user commented: "${commentText}"
|
||||||
|
- Commenter: "${commentedby}"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Generate a warm, on-brand, concise, and helpful reply and be more friendly with the commenter.. while addressing the commenter, always wish in a friendly and differnet manner not only with hi and hello and at starting and with a little bit sarcasm (max 2-3 sentences) addressing the commenter. and dont include the source of information in the reply.`;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
AI_CORE_URL,
|
||||||
|
{
|
||||||
|
message: prompt,
|
||||||
|
mode: "fast", // you can change to "quality" etc. if your server supports it
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(AI_CORE_API_KEY ? { Authorization: `Bearer ${AI_CORE_API_KEY}` } : {}),
|
||||||
|
},
|
||||||
|
timeout: Number(process.env.AI_CORE_TIMEOUT_MS || 600000),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Your response format:
|
||||||
|
// { mode_used, model_used, reply, image_context, file_used }
|
||||||
|
const reply =
|
||||||
|
response.data?.reply ||
|
||||||
|
response.data?.message ||
|
||||||
|
response.data?.text ||
|
||||||
|
"Thanks for your comment!";
|
||||||
|
console.log("🤖 AI Core reply:", response.data);
|
||||||
|
return String(reply).trim().replace(/\s+/g, " ");
|
||||||
|
} catch (error) {
|
||||||
|
const errPayload = error.response?.data || error.message;
|
||||||
|
console.error("❌ AI Core error:", errPayload);
|
||||||
|
return "Thank you for your comment! We appreciate your engagement. 💙";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter comments from last 24 hours
|
||||||
|
* @param {Array} comments - Array of comment objects
|
||||||
|
* @returns {Array} Filtered comments
|
||||||
|
*/
|
||||||
|
function filterRecentComments(comments) {
|
||||||
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 hours ago
|
||||||
|
return (comments || []).filter((c) => {
|
||||||
|
const commentTime = new Date(c.timestamp).getTime();
|
||||||
|
return commentTime >= cutoff;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep helper
|
||||||
|
* @param {number} ms - Milliseconds to sleep
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateReply,
|
||||||
|
filterRecentComments,
|
||||||
|
sleep,
|
||||||
|
};
|
||||||
85
src/services/automation.service_openrouter.js
Normal file
85
src/services/automation.service_openrouter.js
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
// src/services/automation.service.js
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || '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';
|
||||||
|
const OPENROUTER_URL = 'https://openrouter.ai/api/v1/chat/completions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate AI reply using OpenRouter
|
||||||
|
* @param {string} commentText - The comment text
|
||||||
|
* @param {string} accountBio - Instagram account biography
|
||||||
|
* @param {string} accountUsername - Instagram username
|
||||||
|
* @param {string} postCaption - Post caption
|
||||||
|
* @returns {Promise<string>} Generated reply text
|
||||||
|
*/
|
||||||
|
async function generateReply(commentText, accountBio, accountUsername, postCaption) {
|
||||||
|
await sleep(500);
|
||||||
|
|
||||||
|
const prompt = `You are managing an Instagram account.
|
||||||
|
- Biography: "${accountBio}"
|
||||||
|
- Username: "${accountUsername}"
|
||||||
|
- Post Caption: "${postCaption}"
|
||||||
|
A user commented: "${commentText}"
|
||||||
|
|
||||||
|
Generate a warm, on-brand, concise, and helpful reply (max 2-3 sentences).`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(
|
||||||
|
OPENROUTER_URL,
|
||||||
|
{
|
||||||
|
model: "openai/gpt-4o-mini",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
max_tokens: 150,
|
||||||
|
temperature: 0.7,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
||||||
|
'HTTP-Referer': process.env.APP_URL || 'https://socialbuddy.co',
|
||||||
|
'X-Title': 'Instagram Reply Generator',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const reply = response.data?.choices?.[0]?.message?.content || 'Thanks for your comment!';
|
||||||
|
return reply.trim().replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ OpenRouter error:', error.response?.data || error.message);
|
||||||
|
return 'Thank you for your comment! We appreciate your engagement. 💙';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter comments from last 24 hours
|
||||||
|
* @param {Array} comments - Array of comment objects
|
||||||
|
* @returns {Array} Filtered comments
|
||||||
|
*/
|
||||||
|
function filterRecentComments(comments) {
|
||||||
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000; // 24 hours ago
|
||||||
|
return (comments || []).filter(c => {
|
||||||
|
const commentTime = new Date(c.timestamp).getTime();
|
||||||
|
return commentTime >= cutoff;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleep helper
|
||||||
|
* @param {number} ms - Milliseconds to sleep
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateReply,
|
||||||
|
filterRecentComments,
|
||||||
|
sleep
|
||||||
|
};
|
||||||
24
src/services/channels.service.js
Normal file
24
src/services/channels.service.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const { http } = require('../lib/http');
|
||||||
|
const GRAPH = 'https://graph.facebook.com';
|
||||||
|
const GRAPH_VER = process.env.GRAPH_VER || 'v21.0';
|
||||||
|
|
||||||
|
async function listChannelsForUser(userAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/me/accounts`;
|
||||||
|
const { data } = await http.get(url, { params: { access_token: userAccessToken }});
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getChannelDetails(channelId, userAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${channelId}`;
|
||||||
|
const { data } = await http.get(url, {
|
||||||
|
params: {
|
||||||
|
fields: 'name,access_token,instagram_business_account',
|
||||||
|
access_token: userAccessToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listChannelsForUser, getChannelDetails };
|
||||||
105
src/services/comments.service.js
Normal file
105
src/services/comments.service.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
// src/services/comments.service.js
|
||||||
|
const { http } = require('../lib/http');
|
||||||
|
const GRAPH = 'https://graph.facebook.com';
|
||||||
|
const GRAPH_VER = process.env.GRAPH_VER || 'v21.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get comments on an Instagram media post
|
||||||
|
* @param {string} mediaId - Instagram media ID
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @param {string|null} after - Cursor for pagination
|
||||||
|
* @returns {Promise<Object>} Comments data with pagination
|
||||||
|
*/
|
||||||
|
async function getIGComments(mediaId, pageAccessToken, after = null) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${mediaId}/comments`;
|
||||||
|
const params = {
|
||||||
|
fields: 'id,text,timestamp,username,like_count,hidden,replies{id,text,timestamp,username,like_count,hidden}',
|
||||||
|
limit: 100,
|
||||||
|
access_token: pageAccessToken
|
||||||
|
};
|
||||||
|
if (after) params.after = after;
|
||||||
|
|
||||||
|
const { data } = await http.get(url, { params });
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data; // { data: [...], paging: { cursors, next } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reply to an Instagram comment
|
||||||
|
* @param {string} commentId - Instagram comment ID
|
||||||
|
* @param {string} message - Reply message text
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @returns {Promise<Object>} Reply response with new comment ID
|
||||||
|
*/
|
||||||
|
async function replyToIGComment(commentId, message, pageAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${commentId}/replies`;
|
||||||
|
const { data } = await http.post(url, null, {
|
||||||
|
params: {
|
||||||
|
message,
|
||||||
|
access_token: pageAccessToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data; // { id: 'new_comment_id' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an Instagram comment
|
||||||
|
* @param {string} commentId - Instagram comment ID
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @returns {Promise<Object>} Deletion response
|
||||||
|
*/
|
||||||
|
async function deleteIGComment(commentId, pageAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${commentId}`;
|
||||||
|
const { data } = await http.delete(url, {
|
||||||
|
params: { access_token: pageAccessToken }
|
||||||
|
});
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data; // { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide/Unhide an Instagram comment
|
||||||
|
* @param {string} commentId - Instagram comment ID
|
||||||
|
* @param {boolean} hide - true to hide, false to unhide
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @returns {Promise<Object>} Response
|
||||||
|
*/
|
||||||
|
async function hideIGComment(commentId, hide, pageAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${commentId}`;
|
||||||
|
const { data } = await http.post(url, null, {
|
||||||
|
params: {
|
||||||
|
hide,
|
||||||
|
access_token: pageAccessToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data; // { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post a top-level comment on an Instagram media post
|
||||||
|
* @param {string} mediaId - Instagram media ID
|
||||||
|
* @param {string} message - Comment text
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @returns {Promise<Object>} Response with new comment ID
|
||||||
|
*/
|
||||||
|
async function postIGComment(mediaId, message, pageAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${mediaId}/comments`;
|
||||||
|
const { data } = await http.post(url, null, {
|
||||||
|
params: {
|
||||||
|
message,
|
||||||
|
access_token: pageAccessToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data; // { id: 'new_comment_id' }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getIGComments,
|
||||||
|
replyToIGComment,
|
||||||
|
deleteIGComment,
|
||||||
|
hideIGComment,
|
||||||
|
postIGComment
|
||||||
|
};
|
||||||
78
src/services/dm_autoreply.service.js
Normal file
78
src/services/dm_autoreply.service.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
const messagesService = require('./dm_messages.service');
|
||||||
|
|
||||||
|
// Static reply templates (ASCII only)
|
||||||
|
const REPLY_TEMPLATES = {
|
||||||
|
GREETING: 'Hi! Thanks for reaching out. I will get back to you soon.',
|
||||||
|
BUSINESS_HOURS: 'Thanks for your message. Our business hours are 9 AM - 6 PM. We will respond during business hours.',
|
||||||
|
DEFAULT: 'Thank you for your message. We have received it and will reply shortly.',
|
||||||
|
KEYWORDS: {
|
||||||
|
price: 'For pricing information, please check our website or DM us with specific requirements.',
|
||||||
|
hours: "We are open Monday-Friday, 9 AM - 6 PM.",
|
||||||
|
support: 'Our support team will assist you shortly. Please describe your issue in detail.',
|
||||||
|
order: 'To check your order status, please provide your order number.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function detectKeyword(text) {
|
||||||
|
const lowerText = text.toLowerCase();
|
||||||
|
for (const keyword of Object.keys(REPLY_TEMPLATES.KEYWORDS)) {
|
||||||
|
if (lowerText.includes(keyword)) {
|
||||||
|
return keyword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateStaticReply(messageText) {
|
||||||
|
const keyword = detectKeyword(messageText);
|
||||||
|
if (keyword) {
|
||||||
|
return REPLY_TEMPLATES.KEYWORDS[keyword];
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const hour = now.getHours();
|
||||||
|
const isBusinessHours = hour >= 9 && hour < 18;
|
||||||
|
|
||||||
|
if (!isBusinessHours) {
|
||||||
|
return REPLY_TEMPLATES.BUSINESS_HOURS;
|
||||||
|
}
|
||||||
|
|
||||||
|
return REPLY_TEMPLATES.DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleIncomingMessage(incomingMessage, pageAccessToken) {
|
||||||
|
try {
|
||||||
|
const { senderId, text, messageId } = incomingMessage;
|
||||||
|
const replyText = generateStaticReply(text);
|
||||||
|
|
||||||
|
const result = await messagesService.sendMessage(
|
||||||
|
senderId,
|
||||||
|
replyText,
|
||||||
|
pageAccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
originalMessageId: messageId,
|
||||||
|
replyMessageId: result.message_id,
|
||||||
|
replyText,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.message,
|
||||||
|
originalMessageId: incomingMessage.messageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateLLMReply() {
|
||||||
|
throw new Error('LLM integration not yet implemented');
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generateStaticReply,
|
||||||
|
handleIncomingMessage,
|
||||||
|
generateLLMReply,
|
||||||
|
REPLY_TEMPLATES,
|
||||||
|
};
|
||||||
113
src/services/dm_messages.service.js
Normal file
113
src/services/dm_messages.service.js
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
const { http } = require('../lib/http');
|
||||||
|
const GRAPH = 'https://graph.facebook.com';
|
||||||
|
const GRAPH_VER = process.env.GRAPH_VER || 'v21.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get conversations for an Instagram account
|
||||||
|
* @param {string} igUserId - Instagram Business Account ID
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @param {string|null} after - Cursor for pagination
|
||||||
|
* @returns {Promise<Object>} Conversations data
|
||||||
|
*/
|
||||||
|
async function getConversations(igUserId, pageAccessToken, after = null) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${igUserId}/conversations`;
|
||||||
|
const params = {
|
||||||
|
fields: 'id,updated_time,participants,messages{id,created_time,from,to,message}',
|
||||||
|
platform: 'instagram',
|
||||||
|
limit: 25,
|
||||||
|
access_token: pageAccessToken,
|
||||||
|
};
|
||||||
|
if (after) params.after = after;
|
||||||
|
|
||||||
|
const { data } = await http.get(url, { params });
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get messages in a conversation
|
||||||
|
* @param {string} conversationId - Conversation ID
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @param {string|null} after - Cursor for pagination
|
||||||
|
* @returns {Promise<Object>} Messages data
|
||||||
|
*/
|
||||||
|
async function getMessages(conversationId, pageAccessToken, after = null) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${conversationId}/messages`;
|
||||||
|
const params = {
|
||||||
|
fields: 'id,created_time,from,to,message',
|
||||||
|
limit: 50,
|
||||||
|
access_token: pageAccessToken,
|
||||||
|
};
|
||||||
|
if (after) params.after = after;
|
||||||
|
|
||||||
|
const { data } = await http.get(url, { params });
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message reply
|
||||||
|
* @param {string} recipientId - Instagram-scoped user ID (IGSID)
|
||||||
|
* @param {string} message - Message text to send
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @returns {Promise<Object>} Response with message ID
|
||||||
|
*/
|
||||||
|
async function sendMessage(recipientId, message, pageAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/me/messages`;
|
||||||
|
const { data } = await http.post(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
recipient: { id: recipientId },
|
||||||
|
message: { text: message },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: { access_token: pageAccessToken },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a conversation as read
|
||||||
|
* @param {string} conversationId - Conversation ID
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @returns {Promise<Object>} Response
|
||||||
|
*/
|
||||||
|
async function markAsRead(conversationId, pageAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${conversationId}`;
|
||||||
|
const { data } = await http.post(url, null, {
|
||||||
|
params: {
|
||||||
|
action: 'mark_read',
|
||||||
|
access_token: pageAccessToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single message details
|
||||||
|
* @param {string} messageId - Message ID
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @returns {Promise<Object>} Message details
|
||||||
|
*/
|
||||||
|
async function getMessageDetails(messageId, pageAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${messageId}`;
|
||||||
|
const { data } = await http.get(url, {
|
||||||
|
params: {
|
||||||
|
fields: 'id,created_time,from,to,message,story,attachments',
|
||||||
|
access_token: pageAccessToken,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getConversations,
|
||||||
|
getMessages,
|
||||||
|
sendMessage,
|
||||||
|
markAsRead,
|
||||||
|
getMessageDetails,
|
||||||
|
};
|
||||||
80
src/services/dm_webhook.service.js
Normal file
80
src/services/dm_webhook.service.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const VERIFY_TOKEN = process.env.WEBHOOK_VERIFY_TOKEN || 'your_verify_token_123';
|
||||||
|
const APP_SECRET = process.env.OAUTH_APP_SECRET;
|
||||||
|
|
||||||
|
function verifyWebhook(query) {
|
||||||
|
const mode = query['hub.mode'];
|
||||||
|
const token = query['hub.verify_token'];
|
||||||
|
const challenge = query['hub.challenge'];
|
||||||
|
|
||||||
|
if (mode === 'subscribe' && token === VERIFY_TOKEN) {
|
||||||
|
return challenge;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySignature(rawBody, signature) {
|
||||||
|
if (!rawBody || !signature || !APP_SECRET) return false;
|
||||||
|
|
||||||
|
const signatureHash = signature.split('sha256=')[1];
|
||||||
|
if (!signatureHash) return false;
|
||||||
|
|
||||||
|
const expectedSignature = crypto
|
||||||
|
.createHmac('sha256', APP_SECRET)
|
||||||
|
.update(rawBody)
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
return crypto.timingSafeEqual(
|
||||||
|
Buffer.from(signatureHash, 'hex'),
|
||||||
|
Buffer.from(expectedSignature, 'hex')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processWebhookEvent(body) {
|
||||||
|
const processedMessages = [];
|
||||||
|
|
||||||
|
if (body.object !== 'instagram') {
|
||||||
|
return processedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.entry?.forEach((entry) => {
|
||||||
|
entry.messaging?.forEach((messagingEvent) => {
|
||||||
|
if (messagingEvent.message) {
|
||||||
|
const message = {
|
||||||
|
senderId: messagingEvent.sender.id,
|
||||||
|
recipientId: messagingEvent.recipient.id,
|
||||||
|
timestamp: messagingEvent.timestamp,
|
||||||
|
messageId: messagingEvent.message.mid,
|
||||||
|
text: messagingEvent.message.text || '',
|
||||||
|
isEcho: messagingEvent.message.is_echo || false,
|
||||||
|
attachments: messagingEvent.message.attachments || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!message.isEcho) {
|
||||||
|
processedMessages.push(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (messagingEvent.message?.reply_to) {
|
||||||
|
const storyReply = {
|
||||||
|
senderId: messagingEvent.sender.id,
|
||||||
|
recipientId: messagingEvent.recipient.id,
|
||||||
|
timestamp: messagingEvent.timestamp,
|
||||||
|
messageId: messagingEvent.message.mid,
|
||||||
|
text: messagingEvent.message.text || '',
|
||||||
|
replyToStoryId: messagingEvent.message.reply_to.story.id,
|
||||||
|
};
|
||||||
|
processedMessages.push(storyReply);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return processedMessages;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
verifyWebhook,
|
||||||
|
verifySignature,
|
||||||
|
processWebhookEvent,
|
||||||
|
};
|
||||||
49
src/services/media.service.js
Normal file
49
src/services/media.service.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// src/services/media.service.js
|
||||||
|
const { http } = require('../lib/http');
|
||||||
|
const GRAPH = 'https://graph.facebook.com';
|
||||||
|
const GRAPH_VER = process.env.GRAPH_VER || 'v21.0';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Instagram media posts
|
||||||
|
* @param {string} igUserId - Instagram Business Account ID
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @param {number} limit - Number of posts to fetch (default 50)
|
||||||
|
* @param {string|null} after - Cursor for pagination
|
||||||
|
* @returns {Promise<Object>} Media data with pagination
|
||||||
|
*/
|
||||||
|
async function getIGMedia(igUserId, pageAccessToken, limit = 50, after = null) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${igUserId}/media`;
|
||||||
|
const params = {
|
||||||
|
fields: 'id,caption,media_type,media_url,permalink,thumbnail_url,timestamp,comments_count,like_count',
|
||||||
|
limit,
|
||||||
|
access_token: pageAccessToken
|
||||||
|
};
|
||||||
|
if (after) params.after = after;
|
||||||
|
|
||||||
|
const { data } = await http.get(url, { params });
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data; // { data: [...], paging: { cursors, next } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single media item details
|
||||||
|
* @param {string} mediaId - Instagram media ID
|
||||||
|
* @param {string} pageAccessToken - Page access token
|
||||||
|
* @returns {Promise<Object>} Media details
|
||||||
|
*/
|
||||||
|
async function getMediaDetails(mediaId, pageAccessToken) {
|
||||||
|
const url = `${GRAPH}/${GRAPH_VER}/${mediaId}`;
|
||||||
|
const { data } = await http.get(url, {
|
||||||
|
params: {
|
||||||
|
fields: 'id,caption,media_type,media_url,permalink,thumbnail_url,timestamp,comments_count,like_count,children{id,media_type,media_url}',
|
||||||
|
access_token: pageAccessToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (data.error) throw new Error(data.error.message);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getIGMedia,
|
||||||
|
getMediaDetails
|
||||||
|
};
|
||||||
76
src/services/oauth.service.js
Normal file
76
src/services/oauth.service.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
const fetch = global.fetch || require('node-fetch');
|
||||||
|
const { URLSearchParams } = require('url');
|
||||||
|
|
||||||
|
const APP_ID = process.env.OAUTH_APP_ID;
|
||||||
|
const APP_SECRET = process.env.OAUTH_APP_SECRET;
|
||||||
|
const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI;
|
||||||
|
const GRAPH_VER = process.env.GRAPH_VER || 'v21.0';
|
||||||
|
|
||||||
|
function buildLoginUrl() {
|
||||||
|
// const scope = [
|
||||||
|
// 'pages_show_list',
|
||||||
|
// 'instagram_basic',
|
||||||
|
// 'instagram_manage_comments',
|
||||||
|
// 'instagram_manage_messages',
|
||||||
|
// 'instagram_manage_insights',
|
||||||
|
// 'instagram_content_publish',
|
||||||
|
// 'pages_manage_metadata',
|
||||||
|
// 'pages_read_engagement',
|
||||||
|
// 'business_management',
|
||||||
|
// ].join(',');
|
||||||
|
|
||||||
|
|
||||||
|
const scope = [
|
||||||
|
'pages_show_list',
|
||||||
|
'pages_read_engagement',
|
||||||
|
'pages_read_user_content',
|
||||||
|
'pages_manage_posts',
|
||||||
|
'pages_manage_engagement',
|
||||||
|
'pages_manage_metadata',
|
||||||
|
'pages_messaging', // ✅ ADD THIS!
|
||||||
|
'instagram_basic',
|
||||||
|
'instagram_content_publish',
|
||||||
|
'instagram_manage_comments',
|
||||||
|
'instagram_manage_insights',
|
||||||
|
'instagram_manage_messages',
|
||||||
|
'business_management'
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
client_id: APP_ID,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
scope,
|
||||||
|
response_type: 'code',
|
||||||
|
state: 'oauth_state',
|
||||||
|
});
|
||||||
|
|
||||||
|
return `https://www.facebook.com/${GRAPH_VER}/dialog/oauth?${qs.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getShortLivedUserToken(code) {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
client_id: APP_ID,
|
||||||
|
client_secret: APP_SECRET,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
const res = await fetch(`https://graph.facebook.com/${GRAPH_VER}/oauth/access_token?${qs}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error?.message || 'Failed to get user token');
|
||||||
|
return data; // { access_token, token_type, expires_in }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeToLongLivedUserToken(shortToken) {
|
||||||
|
const qs = new URLSearchParams({
|
||||||
|
grant_type: 'fb_exchange_token',
|
||||||
|
client_id: APP_ID,
|
||||||
|
client_secret: APP_SECRET,
|
||||||
|
fb_exchange_token: shortToken,
|
||||||
|
});
|
||||||
|
const res = await fetch(`https://graph.facebook.com/${GRAPH_VER}/oauth/access_token?${qs}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error?.message || 'Failed to exchange long-lived token');
|
||||||
|
return data; // { access_token, token_type, expires_in }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildLoginUrl, getShortLivedUserToken, exchangeToLongLivedUserToken };
|
||||||
22
src/services/tokenStore.service copy.js
Normal file
22
src/services/tokenStore.service copy.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
const store = new Map(); // swap with DB when ready
|
||||||
|
|
||||||
|
async function saveUserToken(userId, longUserToken, expiresAt) {
|
||||||
|
const cur = store.get(userId) || {};
|
||||||
|
store.set(userId, { ...cur, longUserToken, longUserTokenExpiresAt: expiresAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserToken(userId) {
|
||||||
|
return store.get(userId)?.longUserToken || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePageConnection(userId, pageId, pageToken, businessAccountId) {
|
||||||
|
const cur = store.get(userId) || {};
|
||||||
|
store.set(userId, { ...cur, pageId, pageToken, igUserId: businessAccountId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPageConnection(userId) {
|
||||||
|
const cur = store.get(userId) || {};
|
||||||
|
return { pageId: cur.pageId, pageToken: cur.pageToken, igUserId: cur.igUserId };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { saveUserToken, getUserToken, savePageConnection, getPageConnection };
|
||||||
105
src/services/tokenStore.service.js
Normal file
105
src/services/tokenStore.service.js
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
// src/services/tokenStore.service.js
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const STORE_FILE = path.resolve("data/store.json");
|
||||||
|
|
||||||
|
// Ensure file + folder exists
|
||||||
|
function ensureStoreFile() {
|
||||||
|
fs.mkdirSync(path.dirname(STORE_FILE), { recursive: true });
|
||||||
|
if (!fs.existsSync(STORE_FILE)) {
|
||||||
|
fs.writeFileSync(STORE_FILE, "{}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read JSON fresh every time
|
||||||
|
function readStore() {
|
||||||
|
ensureStoreFile();
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(STORE_FILE, "utf8");
|
||||||
|
if (!raw.trim()) return {}; // empty file
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error reading store.json:", err);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write JSON fresh every time
|
||||||
|
function writeStore(data) {
|
||||||
|
try {
|
||||||
|
ensureStoreFile();
|
||||||
|
fs.writeFileSync(STORE_FILE, JSON.stringify(data, null, 2));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error writing store.json:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------
|
||||||
|
// USER TOKEN FUNCTIONS — ALWAYS READ JSON FRESH
|
||||||
|
// --------------------------------------------------
|
||||||
|
async function saveUserToken(userId, longUserToken, expiresAt) {
|
||||||
|
console.log("Saving user token for userId:", userId);
|
||||||
|
console.log("Token expires at:",expiresAt);
|
||||||
|
console.log("Token value:", longUserToken);
|
||||||
|
const store = readStore();
|
||||||
|
|
||||||
|
store[userId] = store[userId] || {};
|
||||||
|
store[userId].longUserToken = longUserToken;
|
||||||
|
store[userId].longUserTokenExpiresAt = expiresAt;
|
||||||
|
|
||||||
|
writeStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserToken(userId) {
|
||||||
|
const store = readStore();
|
||||||
|
return store[userId]?.longUserToken || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------
|
||||||
|
// PAGE CONNECTION FUNCTIONS — ALWAYS READ JSON FRESH
|
||||||
|
// --------------------------------------------------
|
||||||
|
async function savePageConnection(userId, pageId, pageToken, businessAccountId) {
|
||||||
|
const store = readStore();
|
||||||
|
|
||||||
|
store[userId] = store[userId] || {};
|
||||||
|
store[userId].pageId = pageId;
|
||||||
|
store[userId].pageToken = pageToken;
|
||||||
|
store[userId].igUserId = businessAccountId;
|
||||||
|
|
||||||
|
writeStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getPageConnection(userId) {
|
||||||
|
const store = readStore();
|
||||||
|
const cur = store[userId] || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageId: cur.pageId,
|
||||||
|
pageToken: cur.pageToken,
|
||||||
|
igUserId: cur.igUserId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
async function deleteUserCompletely(userId) {
|
||||||
|
const store = readStore();
|
||||||
|
|
||||||
|
if (store[userId]) {
|
||||||
|
delete store[userId];
|
||||||
|
writeStore(store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
saveUserToken,
|
||||||
|
getUserToken,
|
||||||
|
savePageConnection,
|
||||||
|
getPageConnection,
|
||||||
|
deleteUserCompletely
|
||||||
|
};
|
||||||
84
src/utils/mailer.js
Normal file
84
src/utils/mailer.js
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
const nodemailer = require("nodemailer");
|
||||||
|
|
||||||
|
//
|
||||||
|
// Create reusable transporter object
|
||||||
|
//
|
||||||
|
const mailer = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: 587, // STARTTLS
|
||||||
|
secure: false, // must be false for port 587
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS,
|
||||||
|
},
|
||||||
|
name: "mail.socialbuddy.co",
|
||||||
|
tls: {
|
||||||
|
rejectUnauthorized: false,
|
||||||
|
},
|
||||||
|
logger: true,
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
//
|
||||||
|
// Send welcome / signup email
|
||||||
|
//
|
||||||
|
async function sendSignupMail(toEmail, name= "user") {
|
||||||
|
try {
|
||||||
|
await mailer.sendMail({
|
||||||
|
from: `"Social Buddy" <${process.env.SMTP_USER}>`,
|
||||||
|
to: toEmail,
|
||||||
|
subject: "Welcome to Social Buddy",
|
||||||
|
html: `
|
||||||
|
<div style="font-family:Arial;max-width:600px;margin:auto;padding:20px;border:1px solid #eee;">
|
||||||
|
<h2 style="color:#007bff;">Welcome to Social Buddy 🎉</h2>
|
||||||
|
<p>Hello <b>${name}</b>,</p>
|
||||||
|
<p>Your signup was successful! You can now log in and start using the app.</p>
|
||||||
|
|
||||||
|
<div style="margin-top:20px;">
|
||||||
|
<a href="https://devapp.socialbuddy.co/login"
|
||||||
|
style="background:#007bff;color:white;padding:10px 20px;text-decoration:none;border-radius:5px;">
|
||||||
|
Login Now
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="margin-top:20px;color:#666;font-size:12px;">
|
||||||
|
If you didn’t create this account, please ignore this email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
console.log(`✅ Signup email sent to ${toEmail}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Error sending signup email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Send reset-password email with code or link
|
||||||
|
//
|
||||||
|
async function sendResetPasswordMail(email, token) {
|
||||||
|
try {
|
||||||
|
const resetURL = `${process.env.FRONTEND_URL}/reset-password?email=${email}&token=${token}`;
|
||||||
|
|
||||||
|
await mailer.sendMail({
|
||||||
|
from: `"Social Buddy" <${process.env.SMTP_USER}>`,
|
||||||
|
to: email,
|
||||||
|
subject: "Reset your password",
|
||||||
|
html: `
|
||||||
|
<p>You requested a password reset.</p>
|
||||||
|
<p>Click here to reset: <a href="${resetURL}">${resetURL}</a></p>
|
||||||
|
<p>This link is valid for 1 hour.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Reset password email sent to ${email}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Error sending reset password email:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
mailer,
|
||||||
|
sendSignupMail,
|
||||||
|
sendResetPasswordMail,
|
||||||
|
};
|
||||||
10
src/utils/stripe.js
Normal file
10
src/utils/stripe.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
const Stripe = require("stripe");
|
||||||
|
const dotenv = require("dotenv");
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
|
||||||
|
apiVersion: "2024-06-20",
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = { stripe };
|
||||||
25
src/utils/templates/welcomeTemplate.js
Normal file
25
src/utils/templates/welcomeTemplate.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// utils/templates/welcomeTemplate.js
|
||||||
|
export const welcomeTemplate = (name) => `
|
||||||
|
<div style="font-family:Arial,sans-serif;line-height:1.6;background:#f9f9f9;padding:20px;">
|
||||||
|
<div style="max-width:600px;margin:auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 2px 6px rgba(0,0,0,0.1);">
|
||||||
|
<div style="background:#00d1ff;color:#fff;padding:15px 25px;text-align:center;">
|
||||||
|
<h2>Welcome to Social Buddy 🎉</h2>
|
||||||
|
</div>
|
||||||
|
<div style="padding:25px;">
|
||||||
|
<h3>Hello ${name},</h3>
|
||||||
|
<p>Thank you for registering with <strong>Our App</strong>!</p>
|
||||||
|
<p>Your account has been successfully created. We're thrilled to have you on board.</p>
|
||||||
|
|
||||||
|
<div style="margin:30px 0;text-align:center;">
|
||||||
|
<a href="https://devapp.socialbuddy.co/login"
|
||||||
|
style="background:#00d1ff;color:#fff;padding:10px 20px;text-decoration:none;border-radius:5px;">
|
||||||
|
Go to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:13px;color:#777;">If you didn’t create this account, please ignore this email.</p>
|
||||||
|
<p style="font-size:13px;color:#777;">— The Team</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
Loading…
x
Reference in New Issue
Block a user