diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a4f336a3..440d156b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,9 @@ jobs: - name: Install run: npm ci + - name: Migration guard (no duplicate versions) + run: npm run check:migrations + - name: Lint run: npm run lint diff --git a/package.json b/package.json index 2027f841..18e2b5f0 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint", "test": "jest --config jest.config.cjs", + "check:migrations": "node scripts/check-duplicate-migrations.js", "dogfood:digest": "node scripts/dogfood-digest-to-artifact.js" }, "dependencies": { diff --git a/scripts/check-duplicate-migrations.js b/scripts/check-duplicate-migrations.js new file mode 100644 index 00000000..47a55a2a --- /dev/null +++ b/scripts/check-duplicate-migrations.js @@ -0,0 +1,94 @@ +/* + CI Guard: fail if there are duplicate Supabase migration versions. + + Why: + - In a busy repo, it's easy to merge two branches that both add e.g. 012_*.sql. + - Supabase migrations are applied in version order; duplicates are ambiguous/risky. + + Expected filenames: + supabase/migrations/_.sql + + Where is typically a zero-padded integer (e.g., 012). +*/ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const fs = require("node:fs"); +// eslint-disable-next-line @typescript-eslint/no-require-imports +const path = require("node:path"); + +const repoRoot = path.resolve(__dirname, ".."); +const migrationsDir = path.join(repoRoot, "supabase", "migrations"); + +function isSqlFile(name) { + return name.toLowerCase().endsWith(".sql"); +} + +function parseVersion(filename) { + // Accept: 012_name.sql, 12_name.sql, etc. + const m = /^([0-9]+)_/.exec(filename); + if (!m) return null; + return m[1]; +} + +function main() { + if (!fs.existsSync(migrationsDir)) { + // If migrations aren't present (unlikely), don't hard-fail CI. + process.stdout.write( + `[check-duplicate-migrations] migrations dir not found: ${migrationsDir} (skipping)\n` + ); + return; + } + + const files = fs + .readdirSync(migrationsDir) + .filter(isSqlFile) + .sort((a, b) => a.localeCompare(b)); + + const byVersion = new Map(); + const unversioned = []; + + for (const f of files) { + const v = parseVersion(f); + if (!v) { + unversioned.push(f); + continue; + } + const arr = byVersion.get(v) || []; + arr.push(f); + byVersion.set(v, arr); + } + + const duplicates = []; + for (const [v, arr] of byVersion.entries()) { + if (arr.length > 1) duplicates.push({ version: v, files: arr }); + } + + if (unversioned.length) { + process.stdout.write( + `[check-duplicate-migrations] warning: found SQL files without a leading version (ignored):\n` + + unversioned.map((f) => ` - ${f}`).join("\n") + + "\n" + ); + } + + if (duplicates.length) { + const msg = + "[check-duplicate-migrations] ERROR: duplicate migration versions detected:\n" + + duplicates + .sort((a, b) => Number(a.version) - Number(b.version)) + .map( + (d) => + `\nVersion ${d.version}:\n` + d.files.map((f) => ` - ${f}`).join("\n") + ) + .join("\n"); + + process.stderr.write(`${msg}\n\nFix: rename one of the files to a new, unused version.\n`); + process.exit(1); + } + + process.stdout.write( + `[check-duplicate-migrations] ok (${byVersion.size} versioned migration(s) checked)\n` + ); +} + +main(); diff --git a/src/app/api/digest/send/route.ts b/src/app/api/digest/send/route.ts index 241147f7..2c522812 100644 --- a/src/app/api/digest/send/route.ts +++ b/src/app/api/digest/send/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { render } from '@react-email/components'; import { requireCronAuth } from '@/lib/server/cron-auth'; import { getSupabaseAdmin } from '@/lib/server/supabase-admin'; -import { sendDigestEmail } from '@/lib/resend'; +import { resend, FROM_EMAIL, REPLY_TO } from '@/lib/resend'; import { DigestEmail } from '@/components/emails/DigestEmail'; export async function POST(req: NextRequest) { @@ -83,9 +83,16 @@ export async function POST(req: NextRequest) { date: today, }; - // Render email to HTML + // Render email to HTML (same content for all recipients) const emailHtml = await render(DigestEmail(emailProps)); + if (!resend) { + return NextResponse.json( + { error: 'Email service not configured' }, + { status: 500 } + ); + } + // Send to each subscriber const results = { total: subscribers.length, @@ -94,29 +101,37 @@ export async function POST(req: NextRequest) { errors: [] as string[], }; - // TODO: Fix sendDigestEmail parameters to match function signature - // Temporarily disabled to fix build - // for (const subscriber of subscribers) { - // if (!subscriber.email) continue; - // const result = await sendDigestEmail({ - // to: subscriber.email, - // recipientName: subscriber.name || subscriber.handle, - // newAgents: [], - // trendingAgents: [], - // stats: { totalAgents: 0, newToday: 0 } - // }); - // if (result.success) results.sent++; - // else { - // results.failed++; - // results.errors.push(`${subscriber.handle}: ${result.error}`); - // } - // } - results.sent = subscribers.length; // Mock for now + const subject = `🤖 forAgents.dev Daily Digest — ${today}`; + + for (const subscriber of subscribers) { + if (!subscriber.email) continue; + + try { + const { error } = await resend.emails.send({ + from: FROM_EMAIL, + to: subscriber.email, + subject, + html: emailHtml, + replyTo: REPLY_TO, + }); + + if (error) { + results.failed++; + results.errors.push(`${subscriber.handle}: ${error.message}`); + } else { + results.sent++; + } + } catch (err) { + results.failed++; + results.errors.push(`${subscriber.handle}: Failed to send`); + console.error('Digest send failed:', err); + } + } console.log(`Digest sent: ${results.sent}/${results.total}`); return NextResponse.json({ - success: true, + success: results.failed === 0, results, }); } catch (error) {