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
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"dompurify": "^3.1.6",
"driver.js": "^1.4.0",
"fflate": "^0.8.3",
"html-to-image": "^1.11.13",
"idb-keyval": "^6.2.4",
Expand Down
118 changes: 60 additions & 58 deletions src/app/api/user/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ import { cacheGet, cacheSet, cacheDelete } from "@/lib/metrics-cache";
export const dynamic = "force-dynamic";

async function fetchUserSettings(userId: string) {
// Tier 1: All columns
const res1 = await supabaseAdmin
.from("users")
.select("id, github_login, bio, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone, webhook_url, discord_muted_until")
.select("id, github_login, bio, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, weekly_digest_opt_in, discord_webhook_url, timezone, webhook_url, discord_muted_until, seen_onboarding")
.eq("id", userId)
.single();

Expand Down Expand Up @@ -66,10 +65,9 @@ async function fetchUserSettings(userId: string) {
};
}

// Tier 2: Without bio, for deployments that have not run the latest migration.
const res2 = await supabaseAdmin
.from("users")
.select("id, github_login, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, webhook_url")
.select("id, github_login, is_public, public_since, show_weekly_goals, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, webhook_url")
.eq("id", userId)
.single();

Expand Down Expand Up @@ -121,10 +119,9 @@ async function fetchUserSettings(userId: string) {
};
}

// Tier 3: Without public_since and show_weekly_goals (added by migrations)
const res3 = await supabaseAdmin
.from("users")
.select("id, github_login, is_public, public_since, show_weekly_goals")
.select("id, github_login, is_public, public_since, show_weekly_goals")
.eq("id", userId)
.single();

Expand All @@ -139,13 +136,15 @@ async function fetchUserSettings(userId: string) {
hasDiscordSettings: false,
hasBio: false,
hasDiscordMutedUntil: false,
hasWebhookUrl: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
wakatime_api_key_encrypted: null,
wakatime_api_key_iv: null,
discord_webhook_url: null,
timezone: "UTC",
webhook_url: null,
discord_muted_until: null,
};
}
Expand All @@ -161,21 +160,22 @@ async function fetchUserSettings(userId: string) {
hasDiscordSettings: false,
hasBio: false,
hasDiscordMutedUntil: false,
hasWebhookUrl: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
wakatime_api_key_encrypted: null,
wakatime_api_key_iv: null,
discord_webhook_url: null,
timezone: "UTC",
webhook_url: null,
discord_muted_until: null,
};
}

// Tier 4: Absolute minimum — columns guaranteed in every schema version
const res4 = await supabaseAdmin
.from("users")
.select("id, github_login, is_public")
.select("id, github_login, is_public")
.eq("id", userId)
.single();

Expand All @@ -190,13 +190,15 @@ async function fetchUserSettings(userId: string) {
hasDiscordSettings: false,
hasBio: false,
hasDiscordMutedUntil: false,
hasWebhookUrl: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
wakatime_api_key_encrypted: null,
wakatime_api_key_iv: null,
discord_webhook_url: null,
timezone: "UTC",
webhook_url: null,
discord_muted_until: null,
};
}
Expand All @@ -211,13 +213,15 @@ async function fetchUserSettings(userId: string) {
hasDiscordSettings: false,
hasBio: false,
hasDiscordMutedUntil: false,
hasWebhookUrl: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
wakatime_api_key_encrypted: null,
wakatime_api_key_iv: null,
discord_webhook_url: null,
timezone: "UTC",
webhook_url: null,
discord_muted_until: null,
};
}
Expand All @@ -231,14 +235,11 @@ export async function GET(req: NextRequest) {

const user = await resolveAppUser(session.githubId, session.githubLogin);
if (!user) {
return NextResponse.json(
{ error: "Failed to fetch user settings" },
{ status: 500 }
);
return NextResponse.json({ error: "Failed to fetch user settings" }, { status: 500 });
}

const cacheKey = `settings:${user.id}`;
const SETTINGS_TTL = 5 * 60; // 5 minutes
const SETTINGS_TTL = 5 * 60;

const cached = await cacheGet<Record<string, unknown>>(cacheKey, SETTINGS_TTL);
if (cached) {
Expand Down Expand Up @@ -267,13 +268,13 @@ export async function GET(req: NextRequest) {
timezone: result.timezone,
webhook_url: result.webhook_url ?? null,
discord_muted_until: result.discord_muted_until ?? null,
seen_onboarding: (result.data as any).seen_onboarding ?? false,
};

await cacheSet(cacheKey, response, SETTINGS_TTL);
return NextResponse.json(response);
}


export async function PATCH(req: NextRequest) {
const session = await getServerSession(authOptions);

Expand All @@ -282,32 +283,57 @@ export async function PATCH(req: NextRequest) {
}

const user = await resolveAppUser(session.githubId, session.githubLogin);

if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
return NextResponse.json({ error: "User not found" }, { status: 404 });
}

let body: { is_public?: boolean; show_weekly_goals?: boolean; leaderboard_opt_in?: boolean; weekly_digest_opt_in?: boolean; pinned_repos?: string[]; wakatime_api_key?: string; discord_webhook_url?: string | null; timezone?: string; bio?: string; webhook_url?: string | null; discord_muted_until?: string | null };
let body: {
is_public?: boolean;
show_weekly_goals?: boolean;
leaderboard_opt_in?: boolean;
weekly_digest_opt_in?: boolean;
pinned_repos?: string[];
wakatime_api_key?: string;
discord_webhook_url?: string | null;
timezone?: string;
bio?: string;
webhook_url?: string | null;
discord_muted_until?: string | null;
seen_onboarding?: boolean;
};

try {
body = await req.json();
} catch (e) {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const { is_public, show_weekly_goals, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key, discord_webhook_url, timezone, bio, webhook_url, discord_muted_until } = body;
const { is_public, show_weekly_goals, leaderboard_opt_in, weekly_digest_opt_in, pinned_repos, wakatime_api_key, discord_webhook_url, timezone, bio, webhook_url, discord_muted_until, seen_onboarding } = body;

// Retrieve supported columns first
const settingsResult = await fetchUserSettings(user.id);
if (settingsResult.error || !settingsResult.data) {
console.error("Error fetching settings during PATCH:", settingsResult.error);
return NextResponse.json({ error: "Failed to update settings" }, { status: 500 });
}

const { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey, hasWeeklyDigestOptIn, hasDiscordSettings, hasBio, hasWebhookUrl, hasDiscordMutedUntil } = settingsResult;
const updates: { is_public?: boolean; public_since?: string | null; show_weekly_goals?: 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; discord_webhook_url?: string | null; timezone?: string; bio?: string; webhook_url?: string | null; discord_muted_until?: string | null } = {};

const updates: {
is_public?: boolean;
public_since?: string | null;
show_weekly_goals?: 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;
discord_webhook_url?: string | null;
timezone?: string;
bio?: string;
webhook_url?: string | null;
discord_muted_until?: string | null;
seen_onboarding?: boolean;
} = {};

if (is_public !== undefined && is_public !== null && typeof is_public === "boolean") {
updates.is_public = is_public;
Expand All @@ -318,17 +344,13 @@ export async function PATCH(req: NextRequest) {
}
}

if (
hasLeaderboardOptIn &&
leaderboard_opt_in !== undefined &&
leaderboard_opt_in !== null &&
typeof leaderboard_opt_in === "boolean"
) {
if (hasLeaderboardOptIn && leaderboard_opt_in !== undefined && leaderboard_opt_in !== null && typeof leaderboard_opt_in === "boolean") {
updates.leaderboard_opt_in = leaderboard_opt_in;
if (leaderboard_opt_in) {
updates.is_public = true;
}
}

if (show_weekly_goals !== undefined && show_weekly_goals !== null && typeof show_weekly_goals === "boolean") {
updates.show_weekly_goals = show_weekly_goals;
}
Expand All @@ -337,12 +359,7 @@ export async function PATCH(req: NextRequest) {
updates.webhook_url = webhook_url;
}

if (
hasWeeklyDigestOptIn &&
weekly_digest_opt_in !== undefined &&
weekly_digest_opt_in !== null &&
typeof weekly_digest_opt_in === "boolean"
) {
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;
}

Expand All @@ -353,23 +370,19 @@ export async function PATCH(req: NextRequest) {
updates.pinned_repos = pinned_repos;
}

if (typeof seen_onboarding === "boolean") {
updates.seen_onboarding = seen_onboarding;
}

if (!hasBio && bio !== undefined) {
return NextResponse.json(
{ error: "Bio settings are not available until the latest database migration is applied" },
{ status: 400 }
);
return NextResponse.json({ error: "Bio settings are not available until the latest database migration is applied" }, { status: 400 });
}

if (hasBio && bio !== undefined) {
const result = validateTextInput(bio, "Bio", 500);

if (!result.ok) {
return NextResponse.json(
{ error: result.error },
{ status: 400 }
);
return NextResponse.json({ error: result.error }, { status: 400 });
}

updates.bio = result.value;
}

Expand All @@ -394,7 +407,6 @@ export async function PATCH(req: NextRequest) {
}
}

// Handle Discord settings (only if the discord columns exist in the schema)
if (hasDiscordSettings && discord_webhook_url !== undefined) {
if (discord_webhook_url === "") {
updates.discord_webhook_url = null;
Expand Down Expand Up @@ -422,7 +434,6 @@ export async function PATCH(req: NextRequest) {
}
}

// If there are no updates (or none that are supported by the schema)
if (Object.keys(updates).length === 0) {
return NextResponse.json({
id: (settingsResult.data as any).id,
Expand All @@ -442,7 +453,6 @@ export async function PATCH(req: NextRequest) {
});
}

// Query only supported columns in the returning select statement
const selectCols = ["id", "github_login", "is_public", "public_since", "show_weekly_goals"];
if (hasBio) selectCols.push("bio");
if (hasLeaderboardOptIn) selectCols.push("leaderboard_opt_in");
Expand All @@ -468,21 +478,13 @@ export async function PATCH(req: NextRequest) {
return NextResponse.json({ error: "Failed to update settings" }, { status: 500 });
}

// Bust settings cache so next GET returns fresh data.
await cacheDelete(`settings:${user.id}`);

// If is_public or leaderboard_opt_in changed, the cached leaderboard would
// show stale eligibility until it expires (up to 1 hour). Bust the cache
// immediately so the next request reflects the updated preference.
const leaderboardEligibilityChanged =
"is_public" in updates || "leaderboard_opt_in" in updates;

const leaderboardEligibilityChanged = "is_public" in updates || "leaderboard_opt_in" in updates;
if (leaderboardEligibilityChanged) {
try {
await clearLeaderboardCache();
} catch {
// Cache invalidation is best-effort — a failure must not prevent the
// settings response from reaching the client.
console.error("[settings] Failed to invalidate leaderboard cache after visibility change");
}
}
Expand All @@ -503,4 +505,4 @@ export async function PATCH(req: NextRequest) {
webhook_url: (updated as any).webhook_url ?? null,
discord_muted_until: (updated as any).discord_muted_until ?? null,
});
}
}
Loading
Loading