From c5d33ea2f257dc6c67117626cdb6227bcd3a81de Mon Sep 17 00:00:00 2001 From: oratis Date: Sun, 5 Jul 2026 00:37:56 +0800 Subject: [PATCH] feat(content-automation): port weekly-digest cron route + email lib Completes the content-automation convergence (after daily-sync + check-github). Ports the weekly-digest Cloud Scheduler endpoint onto the main line, which previously 404'd in prod because the route was never deployed. - add `resend` dependency (email delivery) + refresh package-lock - port src/lib/email.ts from rescue/content-automation-suite (Resend helper; sendWeeklyDigest builds new-skill/top-skill/total digest) - add src/app/api/cron/weekly-digest/route.ts, mirroring the import-hosted/check-github convention: force-dynamic, maxDuration 300, GET/POST = handle. Auth rewritten from the legacy `x-cron-secret` header to isAuthorizedCron(req) (Bearer ). RESEND_API_KEY + CRON_SECRET are already wired into the Cloud Run `takoapi` service via Secret Manager. Scheduler header switch to `Authorization: Bearer` handled out-of-band. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 50 +++++++ package.json | 1 + src/app/api/cron/weekly-digest/route.ts | 62 +++++++++ src/lib/email.ts | 166 ++++++++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 src/app/api/cron/weekly-digest/route.ts create mode 100644 src/lib/email.ts diff --git a/package-lock.json b/package-lock.json index acc07d9c..956a590d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "prisma": "^6.19.2", "react": "19.2.4", "react-dom": "19.2.4", + "resend": "^6.10.0", "tailwind-merge": "^3.5.0", "zod": "^4.3.6" }, @@ -1755,6 +1756,12 @@ "integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==", "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4884,6 +4891,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -7256,6 +7269,12 @@ "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz", + "integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", @@ -7580,6 +7599,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.17.1.tgz", + "integrity": "sha512-QhHHIRPPM9Tq2uilWmNF8C8kd6nLkUmiFgDQCt1v4eR4o+6p86qrK+Vn12jnAs0jR8Gf6ABdxI18/90LGQ7GVA==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.4", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -8021,6 +8061,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", diff --git a/package.json b/package.json index 6910add5..c4e5f460 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "prisma": "^6.19.2", "react": "19.2.4", "react-dom": "19.2.4", + "resend": "^6.10.0", "tailwind-merge": "^3.5.0", "zod": "^4.3.6" }, diff --git a/src/app/api/cron/weekly-digest/route.ts b/src/app/api/cron/weekly-digest/route.ts new file mode 100644 index 00000000..f38fa715 --- /dev/null +++ b/src/app/api/cron/weekly-digest/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { isAuthorizedCron } from "@/lib/cron-auth"; +import { sendWeeklyDigest } from "@/lib/email"; + +// Cron-only endpoint: email the weekly digest (new-skill count, top skills, +// total) to every verified subscriber. Sends are throttled to ~2/sec to stay +// within Resend limits. No verified subscribers => no-op. Wire to Cloud +// Scheduler weekly with `Authorization: Bearer `. +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +async function handle(req: NextRequest) { + if (!isAuthorizedCron(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const [newSkillsCount, topSkills, totalSkills, subscribers] = await Promise.all([ + prisma.skill.count({ where: { createdAt: { gte: oneWeekAgo } } }), + prisma.skill.findMany({ + orderBy: { downloads: "desc" }, + take: 10, + select: { name: true, slug: true, downloads: true }, + }), + prisma.skill.count(), + prisma.subscriber.findMany({ + where: { verified: true }, + select: { email: true }, + }), + ]); + + let sent = 0; + let failed = 0; + for (const sub of subscribers) { + try { + await sendWeeklyDigest(sub.email, { newSkillsCount, topSkills, totalSkills }); + sent++; + } catch { + failed++; + } + // Rate limit: ~2/sec to stay within Resend limits. + await new Promise((r) => setTimeout(r, 500)); + } + + return NextResponse.json({ + sent, + failed, + total: subscribers.length, + newSkillsCount, + ranAt: new Date().toISOString(), + }); + } catch (error) { + console.error("Weekly digest error:", error); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} + +export const GET = handle; +export const POST = handle; diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 00000000..e253b473 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,166 @@ +import { Resend } from "resend"; + +let _resend: Resend | null = null; +function getResend(): Resend | null { + if (!process.env.RESEND_API_KEY) { + console.warn("RESEND_API_KEY is not set โ€” skipping email send"); + return null; + } + if (!_resend) { + _resend = new Resend(process.env.RESEND_API_KEY); + } + return _resend; +} + +const FROM_EMAIL = "TakoAPI "; +const REPLY_TO = "wangharp@gmail.com"; + +export async function sendWelcomeEmail(to: string, name?: string) { + const resend = getResend(); + if (!resend) return null; + const displayName = name || to.split("@")[0]; + + return resend.emails.send({ + from: FROM_EMAIL, + replyTo: REPLY_TO, + to, + subject: `Welcome to TakoAPI, ${displayName}!`, + html: ` + + + + + + + + +
+
๐Ÿ™
+

Welcome to TakoAPI

+

All in One OpenClaw Skills Marketplace

+
+

Hi ${displayName},

+

Thanks for joining TakoAPI! You now have access to 5,000+ OpenClaw skills across 30+ categories.

+ +

Get Started

+ + +
+

1. Browse trending skills

+ takoapi.com/trending โ†’ +
+ + +
+

2. Install the official TakoAPI skill

+ clawhub install takoapi +
+ + +
+

3. Submit your own skill

+ takoapi.com/submit โ†’ +
+ + + +

Have questions? Just reply to this email. We'd love to hear from you.

+
+

TakoAPI ยท takoapi.com ยท GitHub

+
+ +`, + }); +} + +export async function sendWeeklyDigest( + to: string, + data: { + newSkillsCount: number; + topSkills: { name: string; slug: string; downloads: number }[]; + totalSkills: number; + } +) { + const skillRows = data.topSkills + .map( + (s, i) => + `${i + 1}. ${s.name}${s.downloads.toLocaleString()} downloads` + ) + .join(""); + + const resend = getResend(); + if (!resend) return null; + + return resend.emails.send({ + from: FROM_EMAIL, + replyTo: REPLY_TO, + to, + subject: `TakoAPI Weekly: ${data.newSkillsCount} new skills this week`, + html: ` + + + + + + + + +
+

๐Ÿ™ TakoAPI Weekly Digest

+
+
+
+
${data.newSkillsCount}
+
New This Week
+
+
+
${data.totalSkills.toLocaleString()}
+
Total Skills
+
+
+

Top Skills This Week

+ ${skillRows}
+ +
+

TakoAPI ยท takoapi.com ยท Unsubscribe

+
+ +`, + }); +} + +export async function sendKolOutreach( + to: string, + data: { name: string; skillName?: string; projectName?: string } +) { + const resend = getResend(); + if (!resend) return null; + + return resend.emails.send({ + from: FROM_EMAIL, + replyTo: REPLY_TO, + to, + subject: `Your ${data.skillName || data.projectName || "project"} on TakoAPI`, + html: ` + + + + +
+

Hi ${data.name},

+

Love your ${data.skillName || data.projectName || "work"}! We featured it on TakoAPI โ€” the OpenClaw skills marketplace with 5,000+ skills.

+

Would you like a "Featured Developer" badge on your skill page? We can also provide an "Install via TakoAPI" badge for your README.

+

No commitment needed โ€” just thought you'd appreciate the exposure to our growing community.

+

Best,
TakoAPI Team

+
+

๐Ÿ™ takoapi.com ยท GitHub

+
+
+ +`, + }); +}