← back to SmokeShop

server.js

568 lines

#!/usr/bin/env node
/**
 * SmokeShop — Instagram Content Manager for Smoke & Vape Depot
 * Port: 8401 | PM2: smokeshop
 */

require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const Database = require('better-sqlite3');
const imageGen = require('./lib/image-generator');
const calendar = require('./lib/content-calendar');
const ig = require('./lib/instagram-api');
const scheduler = require('./lib/scheduler');
const gemini = require('./lib/gemini');

const PORT = process.env.PORT || 8401;
const AUTH_USER = process.env.AUTH_USER || 'admin';
const AUTH_PASS = process.env.AUTH_PASS || 'DWSecure2024!';

const app = express();
// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
app.use(helmet({ contentSecurityPolicy: false }));
app.use(cors());
app.use(express.json());

// ─── Auth Middleware ──────────────────────────────────────────────────
function basicAuth(req, res, next) {
  if (req.path === '/health') return next();

  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Basic ')) {
    res.setHeader('WWW-Authenticate', 'Basic realm="SmokeShop"');
    return res.status(401).json({ error: 'Authentication required' });
  }

  const decoded = Buffer.from(authHeader.split(' ')[1], 'base64').toString();
  const [user, pass] = decoded.split(':');
  if (user === AUTH_USER && pass === AUTH_PASS) return next();

  res.setHeader('WWW-Authenticate', 'Basic realm="SmokeShop"');
  return res.status(401).json({ error: 'Invalid credentials' });
}

app.use(basicAuth);

// 404-guard: refuse to serve snapshot/backup files even if they leak into public/
const SNAPSHOT_RE = /\.(bak|orig|rej|swp)$|\.(bak|pre-)[^/]*$|~$/i;
app.use((req, res, next) => {
  if (SNAPSHOT_RE.test(req.path)) return res.status(404).end();
  next();
});

app.use(express.static(path.join(__dirname, 'public'), { etag: false, maxAge: 0, setHeaders: (res) => { res.set('Cache-Control', 'no-store'); } }));
app.use('/generated', express.static(path.join(__dirname, 'generated')));

// ─── Database Setup ──────────────────────────────────────────────────
const DB_PATH = path.join(__dirname, 'db', 'smokeshop.db');
const dbDir = path.dirname(DB_PATH);
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });

const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');

db.exec(`
  CREATE TABLE IF NOT EXISTS posts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT,
    caption TEXT,
    image_path TEXT,
    template TEXT DEFAULT 'a',
    category TEXT,
    status TEXT DEFAULT 'draft',
    scheduled_at TEXT,
    posted_at TEXT,
    ig_media_id TEXT,
    engagement TEXT,
    created_at TEXT DEFAULT (datetime('now'))
  );

  CREATE TABLE IF NOT EXISTS settings (
    key TEXT PRIMARY KEY,
    value TEXT
  );
`);

// Initialize default settings
const settingsCount = db.prepare('SELECT COUNT(*) as cnt FROM settings').get().cnt;
if (settingsCount === 0) {
  const defaults = {
    store_name: 'Smoke & Vape Depot',
    store_address: '6100 Reseda Blvd, Reseda CA',
    app_url: process.env.APP_URL || 'http://45.61.58.125:8401',
  };
  const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)');
  for (const [k, v] of Object.entries(defaults)) {
    insertSetting.run(k, v);
  }
}

// ─── File Upload ─────────────────────────────────────────────────────
const uploadDir = path.join(__dirname, 'public', 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });

const upload = multer({
  storage: multer.diskStorage({
    destination: uploadDir,
    filename: (req, file, cb) => {
      const ext = path.extname(file.originalname);
      cb(null, 'upload-' + Date.now() + ext);
    },
  }),
  limits: { fileSize: 10 * 1024 * 1024 },
  fileFilter: (req, file, cb) => {
    const allowed = /jpeg|jpg|png|webp|gif/;
    if (allowed.test(path.extname(file.originalname).toLowerCase())) {
      cb(null, true);
    } else {
      cb(new Error('Only image files allowed'));
    }
  },
});

// ─── Health ──────────────────────────────────────────────────────────
app.get('/health', (req, res) => {
  res.json({ status: 'ok', agent: 'SmokeShop', port: PORT, uptime: process.uptime() });
});

// ─── Public Spreadsheet Download (no auth) ──────────────────────────
app.get('/spreadsheet', (req, res) => {
  const filePath = path.join(__dirname, 'Smoke Shop.xlsx');
  if (!fs.existsSync(filePath)) return res.status(404).send('Spreadsheet not generated yet');
  res.download(filePath, 'Smoke Shop.xlsx');
});

// ─── Posts CRUD ──────────────────────────────────────────────────────
app.get('/api/posts', (req, res) => {
  const { status, category } = req.query;
  let sql = 'SELECT * FROM posts';
  const conditions = [];
  const params = [];

  if (status) { conditions.push('status = ?'); params.push(status); }
  if (category) { conditions.push('category = ?'); params.push(category); }

  if (conditions.length) sql += ' WHERE ' + conditions.join(' AND ');
  sql += ' ORDER BY scheduled_at ASC, created_at DESC';

  res.json(db.prepare(sql).all(...params));
});

app.get('/api/posts/:id', (req, res) => {
  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
  if (!post) return res.status(404).json({ error: 'Post not found' });
  res.json(post);
});

app.post('/api/posts', (req, res) => {
  const { title, caption, template, category, scheduled_at } = req.body;
  if (!title) return res.status(400).json({ error: 'Title is required' });

  const result = db.prepare(
    'INSERT INTO posts (title, caption, template, category, scheduled_at) VALUES (?, ?, ?, ?, ?)'
  ).run(title, caption || '', template || 'a', category || 'product', scheduled_at || null);

  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(result.lastInsertRowid);
  res.status(201).json(post);
});

app.put('/api/posts/:id', (req, res) => {
  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
  if (!post) return res.status(404).json({ error: 'Post not found' });

  const { title, caption, template, category, scheduled_at, status } = req.body;
  db.prepare(`
    UPDATE posts SET
      title = COALESCE(?, title),
      caption = COALESCE(?, caption),
      template = COALESCE(?, template),
      category = COALESCE(?, category),
      scheduled_at = COALESCE(?, scheduled_at),
      status = COALESCE(?, status)
    WHERE id = ?
  `).run(title, caption, template, category, scheduled_at, status, req.params.id);

  res.json(db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id));
});

app.delete('/api/posts/:id', (req, res) => {
  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
  if (!post) return res.status(404).json({ error: 'Post not found' });

  if (post.image_path && fs.existsSync(post.image_path)) {
    fs.unlinkSync(post.image_path);
  }

  db.prepare('DELETE FROM posts WHERE id = ?').run(req.params.id);
  res.json({ deleted: true, id: Number(req.params.id) });
});

// ─── Image Generation ────────────────────────────────────────────────
app.post('/api/posts/:id/generate', async (req, res) => {
  try {
    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
    if (!post) return res.status(404).json({ error: 'Post not found' });

    const { backgroundImage } = req.body;
    const result = await imageGen.generatePost({
      title: post.title,
      backgroundImage: backgroundImage || null,
      template: post.template,
      category: post.category,
      postId: post.id,
    });

    db.prepare('UPDATE posts SET image_path = ? WHERE id = ?').run(result.path, post.id);
    res.json({ success: true, filename: result.filename, path: result.path });
  } catch (err) {
    console.error('[Generate]', err);
    res.status(500).json({ error: err.message });
  }
});

app.get('/api/preview/:id', (req, res) => {
  const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
  if (!post || !post.image_path || !fs.existsSync(post.image_path)) {
    return res.status(404).json({ error: 'No generated image' });
  }
  res.sendFile(post.image_path);
});

// ─── Photo Upload to Post ───────────────────────────────────────────
app.post('/api/posts/:id/upload-photo', upload.single('image'), async (req, res) => {
  try {
    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
    if (!post) return res.status(404).json({ error: 'Post not found' });
    if (!req.file) return res.status(400).json({ error: 'No file uploaded' });

    const sharp = require('sharp');
    const genDir = path.join(__dirname, 'generated');
    if (!fs.existsSync(genDir)) fs.mkdirSync(genDir, { recursive: true });

    const outPath = path.join(genDir, 'post-' + post.id + '.png');
    await sharp(req.file.path)
      .resize(1080, 1080, { fit: 'cover' })
      .png()
      .toFile(outPath);

    // Clean up upload
    fs.unlinkSync(req.file.path);

    db.prepare('UPDATE posts SET image_path = ? WHERE id = ?').run(outPath, post.id);
    res.json({ success: true, path: outPath });
  } catch (err) {
    console.error('[Upload Photo]', err);
    res.status(500).json({ error: err.message });
  }
});

// ─── AI From Photo (analyze image → title + caption) ────────────────
const STORE_CONTEXT = `
ABOUT THE STORE:
Smoke & Vape Depot — 6100 Reseda Blvd, Reseda, CA 91335
Located in the heart of Reseda on Reseda Boulevard in the San Fernando Valley (818 area code).
Open late every night (until 12 AM). Neighborhood smoke shop serving the local community.

PRODUCTS WE CARRY (Instagram-compliant items ONLY):
- Rolling Papers & Wraps: RAW (our top seller), Elements, Juicy Jay's, Zig-Zag, King Palm
- Lighters: Clipper (collectible designs), Zippo, BIC, torch lighters
- Glassware & Art Pieces: GRAV Labs, MAV Glass, Diamond Glass — hand-blown bubblers, beakers, rigs
- CBD & Wellness: Charlotte's Web, cbdMD — tinctures, gummies, topicals (fully compliant, no health claims)
- Grinders: Santa Cruz Shredder, Cali Crusher, SharpStone
- Accessories: Rolling trays, stash jars, odor-proof bags, cleaning solutions (Formula 420)
- Incense & candles, tapestries, posters, stickers

NEIGHBORHOOD & COMMUNITY:
- Right next to Wingstop (6048 Reseda Blvd) — regulars grab wings then stop by
- Reseda is a diverse, working-class SFValley neighborhood with a growing foodie scene
- Nearby: local barbershops, taquerias, Reseda Park, the Sherman Way corridor
- We support local businesses and community events

BRAND VOICE:
- Friendly, authentic, neighborhood vibe — NOT corporate
- We're "your local smoke shop" — approachable, knowledgeable staff
- Emphasize the craft/art side of glassware, the community connection
- Use casual language, feel like a friend recommending something cool

INSTAGRAM COMPLIANCE (MANDATORY):
- NEVER reference smoking, vaping, tobacco use, or inhaling
- NEVER make health claims about CBD or any product
- NEVER target minors — always include "21+ only" at the end
- OK to show: product packaging, store shelves, glassware as art, CBD products, accessories, store exterior/interior
- OK to tag: community, local businesses, neighborhood pride
`.trim();

app.post('/api/posts/:id/ai-from-photo', async (req, res) => {
  try {
    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
    if (!post) return res.status(404).json({ error: 'Post not found' });
    if (!post.image_path || !fs.existsSync(post.image_path)) {
      return res.status(400).json({ error: 'No photo uploaded yet' });
    }

    const imageData = fs.readFileSync(post.image_path).toString('base64');

    const prompt = `You are the social media manager for Smoke & Vape Depot. Use the store background below to create an Instagram post based on this photo.

${STORE_CONTEXT}

INSTRUCTIONS:
1. Analyze the photo — identify what's shown (products, store interior, neighborhood, etc.)
2. Create a post that connects what's in the photo to the store's actual offerings and community
3. If the photo shows products we carry, mention the specific brands by name
4. If it's a store/neighborhood shot, play up the Reseda community angle
5. Make the caption feel authentic and local — like a real neighborhood shop owner wrote it

Respond with JSON only:
{
  "title": "4 words max — punchy, attention-grabbing",
  "caption": "2-3 sentences max. Engaging, authentic tone. Include a call to action (stop by, check it out, come see). End with 21+ only. Then a new line with 8-10 hashtags: #SmokeAndVapeDepot #ResedaCA #SFValley #818 #ShopLocal plus category-relevant ones",
  "category": "community OR product OR promo"
}

Category guide:
- "product" if the photo shows merchandise, glassware, accessories, CBD
- "community" if it shows the neighborhood, local spots, people, events
- "promo" if it shows the storefront, sales, hours, deals`;

    const result = await gemini.generateText(prompt, {
      images: [{ mimeType: 'image/png', data: imageData }],
      temperature: 0.9,
    });

    let parsed;
    try {
      const jsonMatch = result.match(/\{[\s\S]*\}/);
      parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : { title: 'New Photo Post', caption: result, category: 'product' };
    } catch {
      parsed = { title: 'New Photo Post', caption: result, category: 'product' };
    }

    // Also update the post in DB with the generated content
    db.prepare('UPDATE posts SET title = ?, caption = ?, category = ? WHERE id = ?')
      .run(parsed.title, parsed.caption, parsed.category, post.id);

    res.json(parsed);
  } catch (err) {
    console.error('[AI From Photo]', err);
    res.status(500).json({ error: err.message });
  }
});

// ─── Calendar Generation ─────────────────────────────────────────────
app.post('/api/posts/generate-calendar', (req, res) => {
  const { startDate, clearExisting } = req.body;

  if (clearExisting) {
    db.prepare("DELETE FROM posts WHERE status = 'draft'").run();
  }

  const posts = calendar.generateCalendar(startDate);
  const insert = db.prepare(
    'INSERT INTO posts (title, caption, template, category, status, scheduled_at) VALUES (?, ?, ?, ?, ?, ?)'
  );

  const insertMany = db.transaction((posts) => {
    for (const p of posts) {
      insert.run(p.title, p.caption, p.template, p.category, p.status, p.scheduled_at);
    }
  });

  insertMany(posts);

  const allPosts = db.prepare('SELECT * FROM posts ORDER BY scheduled_at ASC').all();
  res.json({ generated: posts.length, total: allPosts.length, posts: allPosts });
});

// ─── Bulk Generate Images ────────────────────────────────────────────
app.post('/api/posts/generate-all-images', async (req, res) => {
  const posts = db.prepare("SELECT * FROM posts WHERE image_path IS NULL OR image_path = ''").all();
  const results = [];

  for (const post of posts) {
    try {
      const result = await imageGen.generatePost({
        title: post.title,
        template: post.template,
        category: post.category,
        postId: post.id,
      });
      db.prepare('UPDATE posts SET image_path = ? WHERE id = ?').run(result.path, post.id);
      results.push({ id: post.id, success: true, filename: result.filename });
    } catch (err) {
      results.push({ id: post.id, success: false, error: err.message });
    }
  }

  res.json({ processed: results.length, results });
});

// ─── File Upload ─────────────────────────────────────────────────────
app.post('/api/upload', upload.single('image'), (req, res) => {
  if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
  res.json({
    success: true,
    filename: req.file.filename,
    path: req.file.path,
    url: '/uploads/' + req.file.filename,
  });
});

// ─── Instagram OAuth ─────────────────────────────────────────────────
app.get('/api/instagram/status', (req, res) => {
  const appId = getSetting('ig_app_id') || process.env.META_APP_ID;
  const appSecret = getSetting('ig_app_secret') || process.env.META_APP_SECRET;
  const token = getSetting('ig_access_token');
  const accountId = getSetting('ig_business_account_id');

  res.json({
    configured: !!(appId && appSecret),
    connected: !!(token && accountId),
    hasAppId: !!appId,
    hasAppSecret: !!appSecret,
  });
});

app.get('/auth/instagram', (req, res) => {
  const appId = getSetting('ig_app_id') || process.env.META_APP_ID;
  if (!appId) {
    return res.redirect('/?ig=error&msg=' + encodeURIComponent('Configure Meta App ID in Settings first'));
  }

  const redirectUri = (getSetting('app_url') || process.env.APP_URL) + '/auth/instagram/callback';
  const authUrl = ig.getAuthUrl(appId, redirectUri);
  res.redirect(authUrl);
});

app.get('/auth/instagram/callback', async (req, res) => {
  try {
    const { code } = req.query;
    if (!code) return res.status(400).send('No authorization code received');

    const appId = getSetting('ig_app_id') || process.env.META_APP_ID;
    const appSecret = getSetting('ig_app_secret') || process.env.META_APP_SECRET;
    const redirectUri = (getSetting('app_url') || process.env.APP_URL) + '/auth/instagram/callback';

    const tokenData = await ig.exchangeCodeForToken(code, appId, appSecret, redirectUri);
    const longToken = await ig.getLongLivedToken(tokenData.access_token, appId, appSecret);
    const igAccount = await ig.getIGBusinessAccount(longToken.access_token);

    upsertSetting('ig_access_token', longToken.access_token);
    upsertSetting('ig_business_account_id', igAccount.igAccountId);

    res.redirect('/?ig=connected');
  } catch (err) {
    console.error('[OAuth]', err);
    res.redirect('/?ig=error&msg=' + encodeURIComponent(err.message));
  }
});

// ─── Manual Publish ──────────────────────────────────────────────────
app.post('/api/posts/:id/publish', async (req, res) => {
  try {
    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
    if (!post) return res.status(404).json({ error: 'Post not found' });
    if (!post.image_path) return res.status(400).json({ error: 'Generate image first' });

    const accessToken = getSetting('ig_access_token');
    const igAccountId = getSetting('ig_business_account_id');
    if (!accessToken || !igAccountId) {
      return res.status(400).json({ error: 'Instagram not connected' });
    }

    const appUrl = getSetting('app_url') || process.env.APP_URL;
    const imageUrl = appUrl + '/generated/' + path.basename(post.image_path);

    const result = await ig.publishPost(igAccountId, accessToken, imageUrl, post.caption);

    db.prepare(
      "UPDATE posts SET status = 'posted', posted_at = ?, ig_media_id = ? WHERE id = ?"
    ).run(new Date().toISOString(), result.mediaId, post.id);

    res.json({ success: true, mediaId: result.mediaId });
  } catch (err) {
    console.error('[Publish]', err);
    db.prepare("UPDATE posts SET status = 'failed' WHERE id = ?").run(req.params.id);
    res.status(500).json({ error: err.message });
  }
});

// ─── Gemini AI Caption Generation ────────────────────────────────────
app.post('/api/posts/:id/generate-caption', async (req, res) => {
  try {
    const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(req.params.id);
    if (!post) return res.status(404).json({ error: 'Post not found' });

    const caption = await gemini.generateCaption({
      title: post.title,
      category: post.category,
      storeName: 'Smoke & Vape Depot',
    });

    db.prepare('UPDATE posts SET caption = ? WHERE id = ?').run(caption, post.id);
    res.json({ success: true, caption });
  } catch (err) {
    console.error('[Gemini Caption]', err);
    res.status(500).json({ error: err.message });
  }
});

// ─── Settings ────────────────────────────────────────────────────────
app.get('/api/settings', (req, res) => {
  const rows = db.prepare('SELECT * FROM settings').all();
  const settings = {};
  rows.forEach(r => {
    if (r.key.includes('token') || r.key.includes('secret')) {
      settings[r.key] = r.value ? '****' + r.value.slice(-4) : '';
    } else {
      settings[r.key] = r.value;
    }
  });
  res.json(settings);
});

app.put('/api/settings', (req, res) => {
  const updates = req.body;
  for (const [key, value] of Object.entries(updates)) {
    if (typeof value === 'string' && value.startsWith('****')) continue;
    upsertSetting(key, value);
  }
  res.json({ success: true });
});

// ─── Stats ───────────────────────────────────────────────────────────
app.get('/api/stats', (req, res) => {
  const total = db.prepare('SELECT COUNT(*) as cnt FROM posts').get().cnt;
  const byStatus = db.prepare('SELECT status, COUNT(*) as cnt FROM posts GROUP BY status').all();
  const byCategory = db.prepare('SELECT category, COUNT(*) as cnt FROM posts GROUP BY category').all();
  const upcoming = db.prepare(
    "SELECT * FROM posts WHERE status = 'scheduled' AND scheduled_at > datetime('now') ORDER BY scheduled_at ASC LIMIT 5"
  ).all();

  res.json({ total, byStatus, byCategory, upcoming });
});

// ─── Helpers ─────────────────────────────────────────────────────────
function getSetting(key) {
  const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
  return row ? row.value : null;
}

function upsertSetting(key, value) {
  db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(key, value);
}

// ─── Start Scheduler + Server ────────────────────────────────────────
scheduler.init(db);
scheduler.start();

app.listen(PORT, () => {
  console.log('[SmokeShop] Running on port ' + PORT);
  console.log('[SmokeShop] Dashboard: http://45.61.58.125:' + PORT);
});