← back to Dw Launches

media.js

187 lines

'use strict';
/*
 * Media library — surfaces every image source we have local access to, so the
 * composer's "attach" picker can pull from real assets instead of file upload.
 *
 * Sources (all from the local dw_unified mirror, read-only):
 *   shopify   — shopify_products.image_url   (~139k product images in our acct)
 *   instagram — instagram_posts              (our own DW Instagram posts)
 *   vendor    — social_media_posts (IG) + social_trending_posts + vendor_images
 *               (vendor posts/imagery captured for us)
 *
 * Each row is normalised to: { url, title, source, vendor, caption, link }.
 */
const fs = require('fs');
const path = require('path');
const { q } = require('./db');
const vendorIg = require('./vendor-ig');

const SOURCES = ['shopify', 'instagram', 'vendor'];

// content-hash map { image_url: md5 } from hash-images.js — lets the client
// dedup byte-identical photos (colorway reuploads) precisely. Hot-reloaded on
// mtime change so a fresh hashing run is picked up without a restart.
const HASH_FILE = path.join(__dirname, 'data', 'img-hashes.json');
let _hashes = {}, _hashMtime = 0;
function imgHashes() {
  try {
    const m = fs.statSync(HASH_FILE).mtimeMs;
    if (m !== _hashMtime) { _hashes = JSON.parse(fs.readFileSync(HASH_FILE, 'utf8')); _hashMtime = m; }
  } catch { _hashes = {}; }
  return _hashes;
}

// --- count per source (cheap, drives the source tabs + honest totals) -------
async function counts() {
  const out = { shopify: 0, instagram: 0, vendor: 0 };
  const safe = async (sql) => {
    try { const r = await q(sql); return Number(r.rows[0].c) || 0; }
    catch { return 0; }
  };
  out.shopify = await safe(
    `select count(*) c from shopify_products where image_url ilike 'http%'`);
  out.instagram = await safe(
    `select count(*) c from instagram_posts where image_url ilike 'http%'`);
  // vendor IG = the self-contained vendor-ig store (real cached posts) +
  // whatever IG-platform rows exist in the DB social tables.
  const vIg = await safe(
    `select count(*) c from social_media_posts where image_url ilike 'http%' and platform='instagram'`);
  const vTrend = await safe(
    `select count(*) c from social_trending_posts where post_image_url ilike 'http%'`);
  let vStore = 0; try { vStore = vendorIg.count(); } catch {}
  out.vendor = vStore + vIg + vTrend;
  return out;
}

// --- one source -> normalised rows ------------------------------------------
// q-search is applied per source on its meaningful text columns.
async function fetchShopify({ search, limit, offset }) {
  const params = [];
  let where = `image_url ilike 'http%'`;
  if (search) {
    params.push(`%${search}%`);
    where += ` and (title ilike $${params.length} or vendor ilike $${params.length} or handle ilike $${params.length})`;
  }
  params.push(limit, offset);
  const r = await q(
    `select id, image_url, title, vendor
       from shopify_products
      where ${where}
      order by id desc
      limit $${params.length - 1} offset $${params.length}`, params);
  const H = imgHashes();
  return r.rows.map(row => ({
    url: row.image_url,
    title: row.title || '(untitled)',
    source: 'shopify',
    vendor: row.vendor || '',
    caption: '',
    link: '',
    imgHash: H[row.image_url] || null
  }));
}

async function fetchInstagram({ search, limit, offset }) {
  const params = [];
  let where = `image_url ilike 'http%'`;
  if (search) {
    params.push(`%${search}%`);
    where += ` and (coalesce(caption,'') ilike $${params.length} or coalesce(product_title,'') ilike $${params.length} or coalesce(vendor,'') ilike $${params.length})`;
  }
  params.push(limit, offset);
  const r = await q(
    `select image_url, caption, product_title, vendor, instagram_post_url, posted_at, created_at
       from instagram_posts
      where ${where}
      order by coalesce(posted_at, created_at) desc nulls last
      limit $${params.length - 1} offset $${params.length}`, params);
  return r.rows.map(row => ({
    url: row.image_url,
    title: row.product_title || (row.caption ? row.caption.slice(0, 60) : 'Instagram post'),
    source: 'instagram',
    vendor: row.vendor || '',
    caption: row.caption || '',
    link: row.instagram_post_url || ''
  }));
}

// vendor IG = the local vendor-ig store (real cached posts, images that paint),
// then any IG-platform rows from the DB social tables. We deliberately DROP the
// old `vendor_images` website scrapes — they WAF-block on fetch and aren't IG.
async function fetchVendor({ search, limit, offset }) {
  let store = [];
  try { store = vendorIg.list({ search, limit, offset }); } catch {}
  if (store.length >= limit) return store;

  // top up from DB social IG rows (usually empty today, but real if present)
  const need = limit - store.length;
  const params = [];
  let s1 = '', s2 = '';
  if (search) {
    params.push(`%${search}%`);
    const p = `$${params.length}`;
    s1 = ` and (coalesce(caption,'') ilike ${p} or coalesce(vendor,'') ilike ${p} or coalesce(account_username,'') ilike ${p})`;
    s2 = ` and (coalesce(post_snippet,'') ilike ${p} or coalesce(author_handle,'') ilike ${p})`;
  }
  const dbOffset = Math.max(offset - vendorIg.count(), 0);
  params.push(need, dbOffset);
  const lim = `$${params.length - 1}`, off = `$${params.length}`;
  let rows = [];
  try {
    const r = await q(`
      select * from (
        select image_url as url,
               coalesce(product_title, account_username, 'Vendor Instagram') as title,
               coalesce(vendor, account_username, '') as vendor,
               coalesce(caption,'') as caption, coalesce(post_url,'') as link,
               coalesce(posted_at, created_at) as ts
          from social_media_posts
         where image_url ilike 'http%' and platform='instagram'${s1}
        union all
        select post_image_url as url,
               coalesce(author_display_name, author_handle, 'Trending') as title,
               coalesce(author_handle,'') as vendor,
               coalesce(post_snippet,'') as caption, coalesce(post_url,'') as link,
               last_seen_at as ts
          from social_trending_posts
         where post_image_url ilike 'http%'${s2}
      ) u order by ts desc nulls last limit ${lim} offset ${off}`, params);
    rows = r.rows.map(row => ({
      url: row.url, title: row.title || 'Vendor', source: 'vendor',
      vendor: row.vendor || '', caption: row.caption || '', link: row.link || ''
    }));
  } catch {}
  return [...store, ...rows];
}

// --- public: paged media across one or all sources -------------------------
async function media({ source = 'all', search = '', limit = 60, offset = 0 }) {
  limit = Math.min(Math.max(parseInt(limit, 10) || 60, 1), 200);
  offset = Math.max(parseInt(offset, 10) || 0, 0);
  search = (search || '').trim();

  if (source !== 'all' && SOURCES.includes(source)) {
    const fn = { shopify: fetchShopify, instagram: fetchInstagram, vendor: fetchVendor }[source];
    const items = await fn({ search, limit, offset });
    return { items, source, search, limit, offset, hasMore: items.length === limit };
  }

  // "all": interleave — small sources first (so 3 IG + 20 vendor aren't buried
  // under 139k shopify), then fill the rest from shopify with the same offset.
  if (offset === 0) {
    const [ig, vendor] = await Promise.all([
      fetchInstagram({ search, limit: 200, offset: 0 }),
      fetchVendor({ search, limit: 200, offset: 0 })
    ]);
    const head = [...ig, ...vendor];
    const shopify = await fetchShopify({ search, limit: Math.max(limit - head.length, 0), offset: 0 });
    const items = [...head, ...shopify];
    return { items, source: 'all', search, limit, offset, hasMore: true };
  }
  // subsequent pages of "all" come from shopify (the deep source)
  const items = await fetchShopify({ search, limit, offset });
  return { items, source: 'all', search, limit, offset, hasMore: items.length === limit };
}

module.exports = { media, counts, SOURCES };