From c978154258af7110d877eafa93900d5bbfd83804 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 17 Oct 2026 20:12:32 +0000 Subject: [PATCH] fix(blog,stores): redirect old blog slugs to the post they meant; hide expired deals Old /blog/ URLs 308 to the closest published slug (edit distance, then same store), else /blog. Store page and /api/stores/:slug skip deals whose expiry_date has passed. Co-Authored-By: Claude Sonnet 5.5 --- apps/web/app/api/stores/[slug]/route.ts | 1 + apps/web/app/blog/[slug]/page.tsx | 21 ++++++++++-- apps/web/app/stores/[slug]/page.tsx | 1 + apps/web/lib/blog-slugs.ts | 44 +++++++++++++++++++++++++ test/blog-slugs.test.mjs | 20 +++++++++++ 5 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 apps/web/lib/blog-slugs.ts create mode 100644 test/blog-slugs.test.mjs diff --git a/apps/web/app/api/stores/[slug]/route.ts b/apps/web/app/api/stores/[slug]/route.ts index 94b1f3b..35f2946 100644 --- a/apps/web/app/api/stores/[slug]/route.ts +++ b/apps/web/app/api/stores/[slug]/route.ts @@ -12,6 +12,7 @@ export async function GET(_req: NextRequest, { params }: { params: Promise<{ slu const store = stores[0]; const coupons = await db.sql` SELECT * FROM coupons WHERE store_id = ${store.id} + AND (expiry_date IS NULL OR expiry_date = '' OR expiry_date >= date('now')) ORDER BY votes DESC, created_at DESC `; return NextResponse.json({ store, coupons }); diff --git a/apps/web/app/blog/[slug]/page.tsx b/apps/web/app/blog/[slug]/page.tsx index f3154cf..531ad4a 100644 --- a/apps/web/app/blog/[slug]/page.tsx +++ b/apps/web/app/blog/[slug]/page.tsx @@ -1,10 +1,11 @@ export const dynamic = 'force-dynamic'; import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; +import { notFound, permanentRedirect } from 'next/navigation'; import Image from 'next/image'; import Link from 'next/link'; import { getDb } from '@/lib/db'; +import { closestSlug } from '@/lib/blog-slugs'; import { sanitizeBlogHtml } from '@/lib/sanitize-blog-html'; interface Post { @@ -35,6 +36,16 @@ async function getPost(slug: string): Promise { } } +async function publishedSlugs(): Promise { + try { + const rows = await getDb().sql`SELECT slug FROM blog_posts WHERE status = 'published'`; + return rows.map((r: { slug: string }) => r.slug); + } catch (error) { + console.error('[blog] failed to list slugs', error); + return []; + } +} + export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise { const { slug } = await params; const post = await getPost(slug); @@ -61,7 +72,13 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) { const { slug } = await params; const post = await getPost(slug); - if (!post) notFound(); + if (!post) { + // An old slug goes to the post it meant (308), else the blog index. + const meant = closestSlug(slug, await publishedSlugs()); + if (meant && meant !== slug) permanentRedirect(`/blog/${meant}`); + if (/^[a-z0-9-]+$/.test(slug)) permanentRedirect('/blog'); + notFound(); + } const sanitizedContent = sanitizeBlogHtml(post.content); return ( diff --git a/apps/web/app/stores/[slug]/page.tsx b/apps/web/app/stores/[slug]/page.tsx index 00ee063..e03ddde 100644 --- a/apps/web/app/stores/[slug]/page.tsx +++ b/apps/web/app/stores/[slug]/page.tsx @@ -18,6 +18,7 @@ const getStore = cache(async (slug: string): Promise<{ store: Store; coupons: Co const store = stores[0]; const coupons = await db.sql` SELECT * FROM coupons WHERE store_id = ${store.id} + AND (expiry_date IS NULL OR expiry_date = '' OR expiry_date >= date('now')) ORDER BY votes DESC, created_at DESC `; return { store, coupons }; diff --git a/apps/web/lib/blog-slugs.ts b/apps/web/lib/blog-slugs.ts new file mode 100644 index 0000000..1ee76b0 --- /dev/null +++ b/apps/web/lib/blog-slugs.ts @@ -0,0 +1,44 @@ +/** Levenshtein distance, stopping early once it exceeds `max`. */ +export function editDistance(a: string, b: string, max = Infinity): number { + if (Math.abs(a.length - b.length) > max) return max + 1; + let prev = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + const cur = [i]; + let best = i; + for (let j = 1; j <= b.length; j++) { + cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); + best = Math.min(best, cur[j]); + } + if (best > max) return max + 1; + prev = cur; + } + return prev[b.length]; +} + +/** Store-name token: the first dash-separated word ("doordash-promo-..." -> "doordash"). */ +const lead = (s: string) => s.split('-')[0]; + +/** + * The published slug an old or mistyped one meant: the closest by edit distance + * within a third of its length; failing that, the only slug sharing the leading + * store name (doordash-..., shutterfly-..., walgreens-...), else the shortest + * edit distance among those sharing it. Null when nothing plausible exists. + */ +export function closestSlug(slug: string, slugs: Iterable): string | null { + const all = [...slugs]; + if (all.includes(slug)) return slug; + const max = Math.max(3, Math.floor(slug.length / 3)); + let best: string | null = null; + let bestD = max + 1; + for (const s of all) { + const d = editDistance(slug, s, bestD - 1); + if (d < bestD) { + best = s; + bestD = d; + } + } + if (best) return best; + const sameStore = all.filter((s) => lead(s) === lead(slug) && lead(slug).length >= 4); + if (!sameStore.length) return null; + return sameStore.sort((a, b) => editDistance(slug, a) - editDistance(slug, b))[0]; +} diff --git a/test/blog-slugs.test.mjs b/test/blog-slugs.test.mjs new file mode 100644 index 0000000..be478bb --- /dev/null +++ b/test/blog-slugs.test.mjs @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'bun:test'; +import { closestSlug } from '../apps/web/lib/blog-slugs.ts'; + +const SLUGS = [ + 'amazon-promo-code-workflow', + 'cvs-photo-promo-code-workflow', + 'walgreens-photo-coupon-code-workflow-2026', + 'walgreens-photo-coupon-code-workflow-2026-guide', + 'papa-johns-promo-codes-2026-checkout-workflow', +]; +describe('closestSlug', () => { + it('maps an old slug to the post of the same store', () => { + expect(closestSlug('walgreens-coupon-code-workflow', SLUGS)).toBe('walgreens-photo-coupon-code-workflow-2026'); + }); + it('keeps an existing slug and returns null when no post is close', () => { + expect(closestSlug('cvs-photo-promo-code-workflow', SLUGS)).toBe('cvs-photo-promo-code-workflow'); + expect(closestSlug('doordash-promo-codes-for-existing-users-2026-workflow', SLUGS)).toBeNull(); + expect(closestSlug('encrypted-messaging-coupon-codes-private-deal-sharing', SLUGS)).toBeNull(); + }); +});