Skip to content

Commit 8ff41fc

Browse files
Merge pull request #356 from CapSoftware/new-product-emails
feat: New product emails + Resend version bump
2 parents 828570b + c91ad09 commit 8ff41fc

File tree

10 files changed

+543
-143
lines changed

10 files changed

+543
-143
lines changed

apps/web/app/api/desktop/video/create/route.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@ import { getCurrentUser } from "@cap/database/auth/session";
55
import { nanoId } from "@cap/database/helpers";
66
import { cookies } from "next/headers";
77
import { dub } from "@/utils/dub";
8-
import { eq } from "drizzle-orm";
8+
import { eq, count, and } from "drizzle-orm";
99
import { getS3Bucket, getS3Config } from "@/utils/s3";
1010
import { clientEnv, NODE_ENV } from "@cap/env";
11+
import { sendEmail } from "@cap/database/emails/config";
12+
import { FirstShareableLink } from "@cap/database/emails/first-shareable-link";
1113

1214
const allowedOrigins = [
1315
clientEnv.NEXT_PUBLIC_WEB_URL,
@@ -196,6 +198,39 @@ export async function GET(req: NextRequest) {
196198
});
197199
}
198200

201+
// Check if this is the user's first video and send the first shareable link email
202+
try {
203+
const videoCount = await db
204+
.select({ count: count() })
205+
.from(videos)
206+
.where(eq(videos.ownerId, user.id));
207+
208+
if (videoCount && videoCount[0] && videoCount[0].count === 1 && user.email) {
209+
console.log("[SendFirstShareableLinkEmail] Sending first shareable link email with 5-minute delay");
210+
211+
const videoUrl = clientEnv.NEXT_PUBLIC_IS_CAP
212+
? `https://cap.link/${id}`
213+
: `${clientEnv.NEXT_PUBLIC_WEB_URL}/s/${id}`;
214+
215+
// Send email with 5-minute delay using Resend's scheduling feature
216+
await sendEmail({
217+
email: user.email,
218+
subject: "You created your first Cap! 🥳",
219+
react: FirstShareableLink({
220+
email: user.email,
221+
url: videoUrl,
222+
videoName: videoData.name,
223+
}),
224+
marketing: true,
225+
scheduledAt: "in 5 min"
226+
});
227+
228+
console.log("[SendFirstShareableLinkEmail] First shareable link email scheduled to be sent in 5 minutes");
229+
}
230+
} catch (error) {
231+
console.error("Error checking for first video or sending email:", error);
232+
}
233+
199234
return new Response(
200235
JSON.stringify({
201236
id,
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import { type NextRequest } from "next/server";
2+
import { getCurrentUser } from "@cap/database/auth/session";
3+
import { videos, comments, users } from "@cap/database/schema";
4+
import { db } from "@cap/database";
5+
import { eq, and, gt, ne } from "drizzle-orm";
6+
import { sendEmail } from "@cap/database/emails/config";
7+
import { NewComment } from "@cap/database/emails/new-comment";
8+
import { clientEnv } from "@cap/env";
9+
10+
// Cache to store the last email sent time for each user
11+
const lastEmailSentCache = new Map<string, Date>();
12+
13+
export async function POST(request: NextRequest) {
14+
console.log("Processing new comment email notification");
15+
const { commentId } = await request.json();
16+
17+
if (!commentId) {
18+
console.error("Missing required field: commentId");
19+
return Response.json(
20+
{ error: "Missing required fields: commentId" },
21+
{ status: 400 }
22+
);
23+
}
24+
25+
try {
26+
console.log(`Fetching comment details for commentId: ${commentId}`);
27+
// Get the comment details
28+
const commentDetails = await db
29+
.select({
30+
id: comments.id,
31+
content: comments.content,
32+
type: comments.type,
33+
videoId: comments.videoId,
34+
authorId: comments.authorId,
35+
createdAt: comments.createdAt,
36+
})
37+
.from(comments)
38+
.where(eq(comments.id, commentId))
39+
.limit(1);
40+
41+
if (!commentDetails || commentDetails.length === 0) {
42+
console.error(`Comment not found for commentId: ${commentId}`);
43+
return Response.json(
44+
{ error: "Comment not found" },
45+
{ status: 404 }
46+
);
47+
}
48+
49+
const comment = commentDetails[0];
50+
if (comment) {
51+
console.log(`Found comment: ${comment.id}, type: ${comment.type}, videoId: ${comment.videoId}`);
52+
}
53+
54+
// Only send email notifications for text comments
55+
if (!comment || comment.type !== "text" || !comment.videoId || !comment.content) {
56+
console.log("Skipping email notification - invalid comment data or non-text comment");
57+
return Response.json(
58+
{ success: false, reason: "Invalid comment data" },
59+
{ status: 200 }
60+
);
61+
}
62+
63+
console.log(`Fetching video details for videoId: ${comment.videoId}`);
64+
// Get the video details
65+
const videoDetails = await db
66+
.select({
67+
id: videos.id,
68+
name: videos.name,
69+
ownerId: videos.ownerId,
70+
})
71+
.from(videos)
72+
.where(eq(videos.id, comment.videoId))
73+
.limit(1);
74+
75+
if (!videoDetails || videoDetails.length === 0) {
76+
console.error(`Video not found for videoId: ${comment.videoId}`);
77+
return Response.json(
78+
{ error: "Video not found" },
79+
{ status: 404 }
80+
);
81+
}
82+
83+
const video = videoDetails[0];
84+
if (video) {
85+
console.log(`Found video: ${video.id}, name: ${video.name}, ownerId: ${video.ownerId}`);
86+
}
87+
88+
if (!video || !video.ownerId || !video.id || !video.name) {
89+
console.error("Invalid video data");
90+
return Response.json(
91+
{ error: "Invalid video data" },
92+
{ status: 500 }
93+
);
94+
}
95+
96+
console.log(`Fetching owner details for userId: ${video.ownerId}`);
97+
// Get the video owner's email
98+
const ownerDetails = await db
99+
.select({
100+
id: users.id,
101+
email: users.email,
102+
})
103+
.from(users)
104+
.where(eq(users.id, video.ownerId))
105+
.limit(1);
106+
107+
if (!ownerDetails || !ownerDetails.length || !ownerDetails[0] || !ownerDetails[0].email) {
108+
console.error(`Video owner not found for userId: ${video.ownerId}`);
109+
return Response.json(
110+
{ error: "Video owner not found" },
111+
{ status: 404 }
112+
);
113+
}
114+
115+
const owner = ownerDetails[0];
116+
console.log(`Found owner: ${owner.id}, email: ${owner.email}`);
117+
118+
if (!owner || !owner.email || !owner.id) {
119+
console.error("Invalid owner data");
120+
return Response.json(
121+
{ error: "Invalid owner data" },
122+
{ status: 500 }
123+
);
124+
}
125+
126+
// Get the commenter's name
127+
let commenterName = "Anonymous";
128+
if (comment.authorId) {
129+
console.log(`Fetching commenter details for userId: ${comment.authorId}`);
130+
const commenterDetails = await db
131+
.select({
132+
id: users.id,
133+
name: users.name,
134+
})
135+
.from(users)
136+
.where(eq(users.id, comment.authorId))
137+
.limit(1);
138+
139+
if (commenterDetails && commenterDetails.length > 0 && commenterDetails[0] && commenterDetails[0].name) {
140+
commenterName = commenterDetails[0].name;
141+
console.log(`Found commenter name: ${commenterName}`);
142+
} else {
143+
console.log("Commenter details not found, using 'Anonymous'");
144+
}
145+
} else {
146+
console.log("No authorId provided, using 'Anonymous'");
147+
}
148+
149+
// Check if we've sent an email to this user in the last 15 minutes
150+
const now = new Date();
151+
const lastEmailSent = lastEmailSentCache.get(owner.id);
152+
153+
if (lastEmailSent) {
154+
const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000);
155+
156+
if (lastEmailSent > fifteenMinutesAgo) {
157+
console.log(`Rate limiting email to user ${owner.id} - last email sent at ${lastEmailSent.toISOString()}`);
158+
return Response.json(
159+
{ success: false, reason: "Email rate limited" },
160+
{ status: 200 }
161+
);
162+
}
163+
}
164+
165+
// Also check the database for recent comments that might have triggered emails
166+
// This handles cases where the server restarts and the cache is cleared
167+
const fifteenMinutesAgo = new Date(now.getTime() - 15 * 60 * 1000);
168+
console.log(`Checking for recent comments since ${fifteenMinutesAgo.toISOString()}`);
169+
const recentComments = await db
170+
.select({
171+
id: comments.id,
172+
})
173+
.from(comments)
174+
.where(
175+
and(
176+
eq(comments.videoId, comment.videoId),
177+
eq(comments.type, "text"),
178+
gt(comments.createdAt, fifteenMinutesAgo),
179+
ne(comments.id, commentId) // Exclude the current comment
180+
)
181+
)
182+
.limit(1);
183+
184+
// If there are recent comments (other than this one), don't send another email
185+
if (recentComments && recentComments.length > 0 && recentComments[0]) {
186+
console.log(`Found recent comment ${recentComments[0].id}, skipping email notification`);
187+
return Response.json(
188+
{ success: false, reason: "Recent comment found" },
189+
{ status: 200 }
190+
);
191+
}
192+
193+
// Generate the video URL
194+
const videoUrl = clientEnv.NEXT_PUBLIC_IS_CAP
195+
? `https://cap.link/${video.id}`
196+
: `${clientEnv.NEXT_PUBLIC_WEB_URL}/s/${video.id}`;
197+
console.log(`Generated video URL: ${videoUrl}`);
198+
199+
// Send the email
200+
console.log(`Sending email to ${owner.email} about comment on video "${video.name}"`);
201+
202+
try {
203+
const emailResult = await sendEmail({
204+
email: owner.email,
205+
subject: `New comment on your Cap: ${video.name}`,
206+
react: NewComment({
207+
email: owner.email,
208+
url: videoUrl,
209+
videoName: video.name,
210+
commenterName,
211+
commentContent: comment.content,
212+
}),
213+
marketing: true,
214+
});
215+
216+
console.log("Email send result:", emailResult);
217+
console.log("Email sent successfully");
218+
219+
// Update the cache
220+
lastEmailSentCache.set(owner.id, now);
221+
console.log(`Updated email cache for user ${owner.id}`);
222+
223+
return Response.json({ success: true }, { status: 200 });
224+
} catch (emailError) {
225+
console.error("Error sending email via Resend:", emailError);
226+
return Response.json(
227+
{ error: "Failed to send email", details: String(emailError) },
228+
{ status: 500 }
229+
);
230+
}
231+
} catch (error) {
232+
console.error("Error sending new comment email:", error);
233+
return Response.json(
234+
{ error: "Failed to send email" },
235+
{ status: 500 }
236+
);
237+
}
238+
}

apps/web/app/api/video/comment/route.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { comments } from "@cap/database/schema";
55
import { db } from "@cap/database";
66
import { rateLimitMiddleware } from "@/utils/helpers";
77
import { headers } from "next/headers";
8+
import { clientEnv } from "@cap/env";
89

910
async function handlePost(request: NextRequest) {
1011
const user = await getCurrentUser();
@@ -45,6 +46,26 @@ async function handlePost(request: NextRequest) {
4546

4647
await db.insert(comments).values(newComment);
4748

49+
// Trigger email notification for new comment
50+
if (type === "text" && userId !== "anonymous") {
51+
try {
52+
// Don't await this to avoid blocking the response
53+
const absoluteUrl = new URL("/api/email/new-comment", clientEnv.NEXT_PUBLIC_WEB_URL).toString();
54+
fetch(absoluteUrl, {
55+
method: "POST",
56+
headers: {
57+
"Content-Type": "application/json",
58+
},
59+
body: JSON.stringify({
60+
commentId: id,
61+
}),
62+
});
63+
} catch (error) {
64+
console.error("Error triggering comment notification:", error);
65+
// Don't fail the comment creation if notification fails
66+
}
67+
}
68+
4869
return Response.json(
4970
{
5071
...newComment,
@@ -68,3 +89,7 @@ export const POST = (request: NextRequest) => {
6889
const headersList = headers();
6990
return rateLimitMiddleware(10, handlePost(request), headersList);
7091
};
92+
93+
export async function GET() {
94+
return Response.json({ error: true }, { status: 405 });
95+
}

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@
7272
"react-rnd": "^10.4.1",
7373
"react-scroll-parallax": "^3.4.5",
7474
"react-tooltip": "^5.26.3",
75-
"resend": "1.1.0",
75+
"resend": "4.1.2",
7676
"server-only": "^0.0.1",
7777
"subtitles-parser-vtt": "^0.1.0",
7878
"tailwind-merge": "^2.1.0",

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
"dev:manual": "pnpm run docker:up && trap 'pnpm run docker:stop' EXIT && dotenv -e .env -- turbo run dev --filter=!@cap/storybook --no-cache --concurrency 1",
1111
"lint": "turbo run lint",
1212
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
13-
"db:push": "(pnpm run docker:up &) && sleep 5 && trap 'pnpm run docker:stop' EXIT && dotenv -e .env -- turbo run db:push",
14-
"db:generate": "dotenv -e .env -- turbo run db:generate",
13+
"db:push": "dotenv -e .env -- pnpm --dir packages/database db:push",
14+
"db:generate": "dotenv -e .env -- pnpm --dir packages/database db:generate",
1515
"tauri:build": "dotenv -e .env -- pnpm --dir apps/desktop tauri build --verbose",
1616
"typecheck": "pnpm tsc -b",
1717
"docker:up": "turbo run docker:up",

0 commit comments

Comments
 (0)