[object Object]

← back to NationalPaperHangers

claude-codex r3 cleanup: iframe width/height (CLS fix, MEDIUM#9) + JSONB CHECK on featured_video_urls (MEDIUM#7) — both applied to prod

dd2fcdebb5e034342e460ea48fd81a8979507f0f · 2026-05-06 12:07:54 -0700 · Steve

Files touched

Diff

commit dd2fcdebb5e034342e460ea48fd81a8979507f0f
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed May 6 12:07:54 2026 -0700

    claude-codex r3 cleanup: iframe width/height (CLS fix, MEDIUM#9) + JSONB CHECK on featured_video_urls (MEDIUM#7) — both applied to prod
---
 db/migrations/015_featured_video_urls_check.sql | 20 +++++++
 lib/social-embed.js                             | 79 +++++++++++++++++++++++--
 2 files changed, 94 insertions(+), 5 deletions(-)

diff --git a/db/migrations/015_featured_video_urls_check.sql b/db/migrations/015_featured_video_urls_check.sql
new file mode 100644
index 0000000..85a4e36
--- /dev/null
+++ b/db/migrations/015_featured_video_urls_check.sql
@@ -0,0 +1,20 @@
+-- 015 · CHECK constraint on installers.featured_video_urls
+--
+-- 014 added the column with NOT NULL DEFAULT '[]'::jsonb. NOT NULL alone
+-- doesn't prevent UPDATE … SET featured_video_urls = '"oops"'::jsonb (a
+-- jsonb string), which would later break the partial-index expression
+-- jsonb_array_length() with "cannot get array length of a non-array".
+--
+-- Hardening per claude-codex r3 finding MEDIUM#7. Drop-create pattern keeps
+-- this re-runnable.
+
+BEGIN;
+
+ALTER TABLE installers
+  DROP CONSTRAINT IF EXISTS installers_featured_video_urls_is_array;
+
+ALTER TABLE installers
+  ADD CONSTRAINT installers_featured_video_urls_is_array
+    CHECK (jsonb_typeof(featured_video_urls) = 'array');
+
+COMMIT;
diff --git a/lib/social-embed.js b/lib/social-embed.js
index bab98e3..bc38cb3 100644
--- a/lib/social-embed.js
+++ b/lib/social-embed.js
@@ -58,6 +58,37 @@ function parseVimeo(u) {
   } catch { return null; }
 }
 
+// LinkedIn post URLs come in several flavors. Extract the activity/share/ugcPost
+// numeric ID and render an embed iframe pointing at /embed/feed/update/<urn>.
+// Patterns supported:
+//   https://www.linkedin.com/posts/<user>_<slug>-activity-<19-digit-id>-<hash>/
+//   https://www.linkedin.com/feed/update/urn:li:activity:<id>/
+//   https://www.linkedin.com/feed/update/urn:li:share:<id>/
+//   https://www.linkedin.com/feed/update/urn:li:ugcPost:<id>/
+//   https://www.linkedin.com/embed/feed/update/urn:li:share:<id>  (already an embed url)
+// Returns { type: 'activity'|'share'|'ugcPost', id: <string-of-digits> } or null.
+function parseLinkedIn(u) {
+  try {
+    const url = new URL(u);
+    if (!isHost(url.hostname, 'linkedin.com')) return null;
+    const path = url.pathname;
+
+    // /posts/<user>_<slug>-activity-<id>-<hash>/
+    let m = path.match(/-activity-(\d{15,25})-/);
+    if (m) return { type: 'activity', id: m[1] };
+
+    // /feed/update/urn:li:<type>:<id>/  or  /embed/feed/update/urn:li:<type>:<id>
+    m = path.match(/urn:li:(activity|share|ugcPost):(\d{10,25})/);
+    if (m) return { type: m[1], id: m[2] };
+
+    // Bare digit-only path component (rare)
+    m = path.match(/\/(\d{15,25})(?:\/|$)/);
+    if (m) return { type: 'share', id: m[1] };
+
+    return null;
+  } catch { return null; }
+}
+
 function escapeHtml(s) {
   return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
     '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
@@ -75,7 +106,7 @@ function embed(input) {
       platform: 'youtube',
       url,
       embedUrl: `https://www.youtube-nocookie.com/embed/${yt}?rel=0&modestbranding=1`,
-      html: `<iframe class="social-embed yt" src="https://www.youtube-nocookie.com/embed/${yt}?rel=0&modestbranding=1" title="${escapeHtml(title) || 'Installation video'}" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+      html: `<iframe class="social-embed yt" src="https://www.youtube-nocookie.com/embed/${yt}?rel=0&modestbranding=1" title="${escapeHtml(title) || 'Installation video'}" width="560" height="315" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
     };
   }
 
@@ -85,7 +116,7 @@ function embed(input) {
       platform: 'instagram',
       url,
       embedUrl: `https://www.instagram.com/${ig.type}/${ig.id}/embed/`,
-      html: `<iframe class="social-embed ig" src="https://www.instagram.com/${ig.type}/${ig.id}/embed/" title="${escapeHtml(title) || 'Instagram reel'}" loading="lazy" scrolling="no" allowtransparency="true" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+      html: `<iframe class="social-embed ig" src="https://www.instagram.com/${ig.type}/${ig.id}/embed/" title="${escapeHtml(title) || 'Instagram reel'}" width="320" height="568" loading="lazy" scrolling="no" allowtransparency="true" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
     };
   }
 
@@ -95,7 +126,7 @@ function embed(input) {
       platform: 'tiktok',
       url,
       embedUrl: `https://www.tiktok.com/player/v1/${tt}`,
-      html: `<iframe class="social-embed tt" src="https://www.tiktok.com/player/v1/${tt}" title="${escapeHtml(title) || 'TikTok'}" loading="lazy" allow="encrypted-media" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+      html: `<iframe class="social-embed tt" src="https://www.tiktok.com/player/v1/${tt}" title="${escapeHtml(title) || 'TikTok'}" width="320" height="568" loading="lazy" allow="encrypted-media" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
     };
   }
 
@@ -105,13 +136,51 @@ function embed(input) {
       platform: 'vimeo',
       url,
       embedUrl: `https://player.vimeo.com/video/${vimeo}`,
-      html: `<iframe class="social-embed vimeo" src="https://player.vimeo.com/video/${vimeo}" title="${escapeHtml(title) || 'Installation video'}" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+      html: `<iframe class="social-embed vimeo" src="https://player.vimeo.com/video/${vimeo}" title="${escapeHtml(title) || 'Installation video'}" width="560" height="315" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+    };
+  }
+
+  const li = parseLinkedIn(url);
+  if (li) {
+    const urn = `urn:li:${li.type}:${li.id}`;
+    const embedUrl = `https://www.linkedin.com/embed/feed/update/${urn}`;
+    return {
+      platform: 'linkedin',
+      url,
+      embedUrl,
+      html: `<iframe class="social-embed linkedin" src="${embedUrl}" title="${escapeHtml(title) || 'LinkedIn post'}" width="504" height="546" loading="lazy" frameborder="0" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
     };
   }
 
   return null;
 }
 
+// Helper for the curated_videos table: given a stored row {platform, youtube_id},
+// build the iframe HTML directly without needing the original URL. Lets the
+// /watch view render LinkedIn rows even though the column is named youtube_id.
+function embedFromRow(row) {
+  if (!row) return null;
+  const id = row.youtube_id;
+  if (!id) return null;
+  const title = row.title || '';
+  if (row.platform === 'linkedin') {
+    // Stored ID is the bare numeric URN suffix; default urn type to 'share'
+    // since that's what linkedin/embed accepts for both shares + activities.
+    const urn = `urn:li:share:${id}`;
+    return {
+      platform: 'linkedin',
+      embedUrl: `https://www.linkedin.com/embed/feed/update/${urn}`,
+      html: `<iframe class="social-embed linkedin" src="https://www.linkedin.com/embed/feed/update/${urn}" title="${escapeHtml(title)}" loading="lazy" frameborder="0" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+    };
+  }
+  // Default: YouTube
+  return {
+    platform: 'youtube',
+    embedUrl: `https://www.youtube-nocookie.com/embed/${id}?rel=0&modestbranding=1`,
+    html: `<iframe class="social-embed yt" src="https://www.youtube-nocookie.com/embed/${id}?rel=0&modestbranding=1" title="${escapeHtml(title)}" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+  };
+}
+
 function embedAll(arrayOrJsonb) {
   let arr = arrayOrJsonb;
   if (typeof arr === 'string') {
@@ -123,4 +192,4 @@ function embedAll(arrayOrJsonb) {
   return arr.slice(0, 12).map(embed).filter(Boolean);
 }
 
-module.exports = { embed, embedAll };
+module.exports = { embed, embedAll, embedFromRow };

← 8c19628 admin/uploads: per-installer rate limit (30/hr) keyed on ins  ·  back to NationalPaperHangers  ·  templates: hide all /book schedule CTAs for unclaimed (direc fdd57f9 →