Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/app/api/cron/weekly-digest/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { supabaseAdmin } from "@/lib/supabase";

export const dynamic = "force-dynamic";

export async function GET(request: Request) {
// 1. Verify cron secret if provided
const authHeader = request.headers.get("authorization");
if (
process.env.CRON_SECRET &&
authHeader !== `Bearer ${process.env.CRON_SECRET}`
) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

try {
// 2. Fetch users with opt-in and an email
const { data: users, error } = await supabaseAdmin
.from("users")
.select("github_login, email")
.eq("weekly_digest_opt_in", true)
.not("email", "is", null);

if (error) {
console.error("Error fetching users for digest:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}

if (!users || users.length === 0) {
return NextResponse.json({ message: "No users opted in" });
}

// 3. For each user, send the email
// Minimal template logic to prevent timeouts and keep implementation clean
let sentCount = 0;

for (const user of users) {
if (!user.email) continue;

const htmlBody = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Your Weekly DevTrack Digest</h2>
<p>Hi ${user.github_login},</p>
<p>Here is your coding activity summary for the past week!</p>
<p><strong>Keep up the great work!</strong></p>
<hr style="border: 1px solid #eee; margin: 20px 0;" />
<p style="font-size: 12px; color: #666;">
You are receiving this because you opted into the Weekly Email Digest in your DevTrack settings.
</p>
</div>
`;

if (process.env.RESEND_API_KEY) {
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
},
body: JSON.stringify({
from: "DevTrack <digest@devtrack.com>",
to: user.email,
subject: "Your Weekly DevTrack Digest",
html: htmlBody,
}),
});
}
sentCount++;
}

return NextResponse.json({ success: true, sentCount });
} catch (err) {
console.error("Cron weekly-digest failed:", err);
return NextResponse.json({ error: "Failed to process digests" }, { status: 500 });
}
}
35 changes: 30 additions & 5 deletions src/app/api/user/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ async function fetchUserSettings(userId: string) {
// Tier 1: All columns
const res1 = await supabaseAdmin
.from("users")
.select("id, github_login, is_public, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv")
.select("id, github_login, is_public, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in")
.eq("id", userId)
.single();

Expand All @@ -22,7 +22,9 @@ async function fetchUserSettings(userId: string) {
hasLeaderboardOptIn: true,
hasPinnedRepos: true,
hasWakatimeKey: true,
hasWeeklyDigestOptIn: true,
leaderboard_opt_in: (res1.data as any).leaderboard_opt_in ?? false,
weekly_digest_opt_in: (res1.data as any).weekly_digest_opt_in ?? false,
pinned_repos: (res1.data as any).pinned_repos || [],
wakatime_api_key_encrypted: (res1.data as any).wakatime_api_key_encrypted || null,
wakatime_api_key_iv: (res1.data as any).wakatime_api_key_iv || null,
Expand All @@ -36,7 +38,9 @@ async function fetchUserSettings(userId: string) {
hasLeaderboardOptIn: false,
hasPinnedRepos: false,
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
wakatime_api_key_encrypted: null,
wakatime_api_key_iv: null,
Expand All @@ -57,7 +61,9 @@ async function fetchUserSettings(userId: string) {
hasLeaderboardOptIn: true,
hasPinnedRepos: false,
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
leaderboard_opt_in: (res2.data as any).leaderboard_opt_in ?? false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
wakatime_api_key_encrypted: null,
wakatime_api_key_iv: null,
Expand All @@ -71,7 +77,9 @@ async function fetchUserSettings(userId: string) {
hasLeaderboardOptIn: false,
hasPinnedRepos: false,
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
wakatime_api_key_encrypted: null,
wakatime_api_key_iv: null,
Expand All @@ -92,7 +100,9 @@ async function fetchUserSettings(userId: string) {
hasLeaderboardOptIn: false,
hasPinnedRepos: false,
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
wakatime_api_key_encrypted: null,
wakatime_api_key_iv: null,
Expand All @@ -105,7 +115,9 @@ async function fetchUserSettings(userId: string) {
hasLeaderboardOptIn: false,
hasPinnedRepos: false,
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
wakatime_api_key_encrypted: null,
wakatime_api_key_iv: null,
Expand Down Expand Up @@ -139,6 +151,7 @@ export async function GET(req: NextRequest) {
github_login: (result.data as any).github_login,
is_public: (result.data as any).is_public,
leaderboard_opt_in: result.leaderboard_opt_in,
weekly_digest_opt_in: result.weekly_digest_opt_in,
pinned_repos: result.pinned_repos,
has_wakatime_key: !!result.wakatime_api_key_encrypted && !!result.wakatime_api_key_iv,
});
Expand All @@ -160,14 +173,14 @@ export async function PATCH(req: NextRequest) {
);
}

let body: { is_public?: boolean; leaderboard_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key?: string };
let body: { is_public?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const { is_public, leaderboard_opt_in, pinned_repos, wakatime_api_key } = body;
const { is_public, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key } = body;

// Retrieve supported columns first
const settingsResult = await fetchUserSettings(user.id);
Expand All @@ -176,8 +189,8 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: "Failed to update settings" }, { status: 500 });
}

const { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey } = settingsResult;
const updates: { is_public?: boolean; leaderboard_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key_encrypted?: string | null; wakatime_api_key_iv?: string | null } = {};
const { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey, hasWeeklyDigestOptIn } = settingsResult;
const updates: { is_public?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key_encrypted?: string | null; wakatime_api_key_iv?: string | null } = {};

if (is_public !== undefined && is_public !== null && typeof is_public === "boolean") {
updates.is_public = is_public;
Expand All @@ -195,6 +208,15 @@ export async function PATCH(req: NextRequest) {
}
}

if (
hasWeeklyDigestOptIn &&
weekly_digest_opt_in !== undefined &&
weekly_digest_opt_in !== null &&
typeof weekly_digest_opt_in === "boolean"
) {
updates.weekly_digest_opt_in = weekly_digest_opt_in;
}

if (hasPinnedRepos && pinned_repos !== undefined && pinned_repos !== null && Array.isArray(pinned_repos)) {
if (pinned_repos.length > 3) {
return NextResponse.json({ error: "Maximum 3 pins allowed" }, { status: 400 });
Expand Down Expand Up @@ -230,6 +252,7 @@ export async function PATCH(req: NextRequest) {
github_login: (settingsResult.data as any).github_login,
is_public: (settingsResult.data as any).is_public,
leaderboard_opt_in: settingsResult.leaderboard_opt_in,
weekly_digest_opt_in: settingsResult.weekly_digest_opt_in,
pinned_repos: settingsResult.pinned_repos,
has_wakatime_key: !!settingsResult.wakatime_api_key_encrypted && !!settingsResult.wakatime_api_key_iv,
});
Expand All @@ -238,6 +261,7 @@ export async function PATCH(req: NextRequest) {
// Query only supported columns in the returning select statement
const selectCols = ["id", "github_login", "is_public"];
if (hasLeaderboardOptIn) selectCols.push("leaderboard_opt_in");
if (hasWeeklyDigestOptIn) selectCols.push("weekly_digest_opt_in");
if (hasPinnedRepos) selectCols.push("pinned_repos");
if (hasWakatimeKey) {
selectCols.push("wakatime_api_key_encrypted");
Expand All @@ -261,6 +285,7 @@ export async function PATCH(req: NextRequest) {
github_login: (updated as any).github_login,
is_public: (updated as any).is_public,
leaderboard_opt_in: (updated as any).leaderboard_opt_in ?? false,
weekly_digest_opt_in: (updated as any).weekly_digest_opt_in ?? false,
pinned_repos: (updated as any).pinned_repos || [],
has_wakatime_key: !!(updated as any).wakatime_api_key_encrypted && !!(updated as any).wakatime_api_key_iv,
});
Expand Down
62 changes: 62 additions & 0 deletions src/app/dashboard/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ interface UserSettings {
github_login: string;
is_public: boolean;
leaderboard_opt_in: boolean;
weekly_digest_opt_in: boolean;
has_wakatime_key?: boolean;
}

Expand Down Expand Up @@ -304,6 +305,30 @@ function SettingsPageContent() {
}
};

const handleToggleWeeklyDigest = async (value: boolean) => {
if (!settings) return;

setSaving(true);
try {
const res = await fetch("/api/user/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ weekly_digest_opt_in: value }),
});

if (res.ok) {
const updated = await res.json();
setSettings(updated);
} else {
console.error("Failed to update weekly digest setting");
}
} catch (error) {
console.error("Error updating weekly digest setting:", error);
} finally {
setSaving(false);
}
};

const handleSaveWakatime = async () => {
if (!settings) return;
setSavingWakatime(true);
Expand Down Expand Up @@ -607,6 +632,43 @@ function SettingsPageContent() {
</div>
</div>

<div className="mt-6 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-[var(--card-foreground)]">
Weekly Email Digest
</h2>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">
Receive an optional weekly email digest every Monday morning summarizing your coding habits.
</p>
</div>

<label className="flex items-center cursor-pointer select-none">
<div className="relative">
<input
type="checkbox"
checked={settings.weekly_digest_opt_in}
onChange={(e) => handleToggleWeeklyDigest(e.target.checked)}
disabled={saving}
className="sr-only"
/>
<div
className={`block h-6 w-10 rounded-full transition-colors ${
settings.weekly_digest_opt_in
? "bg-[var(--accent)]"
: "bg-[var(--control)]"
}`}
/>
<div
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-[var(--card)] transition-transform ${
settings.weekly_digest_opt_in ? "translate-x-4" : ""
}`}
/>
</div>
</label>
</div>
</div>

<div className="mt-6 rounded-xl border border-[var(--border)] bg-[var(--card)] p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
Expand Down
25 changes: 23 additions & 2 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,37 @@ export const authOptions: NextAuthOptions = {
callbacks: {
async signIn({ account, profile }) {
if (account?.provider === "github" && profile) {
const p = profile as { id: number; login: string };
const { data: user } = await supabaseAdmin.from("users").upsert(
const p = profile as { id: number; login: string; email?: string };

let { data: user, error } = await supabaseAdmin.from("users").upsert(
{
github_id: String(p.id),
github_login: p.login,
email: p.email || null,
updated_at: new Date().toISOString(),
},
{ onConflict: "github_id" }
).select("id").single();

// If the email column does not exist yet (migration pending),
// PostgREST returns a 42703 error. Fallback to upsert without email.
if (error && error.code === "42703") {
const fallback = await supabaseAdmin.from("users").upsert(
{
github_id: String(p.id),
github_login: p.login,
updated_at: new Date().toISOString(),
},
{ onConflict: "github_id" }
).select("id").single();
user = fallback.data;
error = fallback.error;
}

if (error) {
console.error("Supabase upsert error during sign in:", error);
}

if (user?.id && account.access_token) {
try {
await syncGitHubAchievementsForUser({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- Add weekly_digest_opt_in and email to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS weekly_digest_opt_in BOOLEAN DEFAULT false;
ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT;
5 changes: 4 additions & 1 deletion vercel.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"crons": [
{
"path": "/api/cron/weekly-digest",
"schedule": "0 9 * * 1"
},
{
"path": "/api/wakatime/sync",
"schedule": "0 0 * * *"
Expand All @@ -10,4 +14,3 @@
}
]
}

Loading