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
176 changes: 150 additions & 26 deletions src/app/api/user/settings/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,21 @@ import { cacheGet, cacheSet, cacheDelete } from "@/lib/metrics-cache";

export const dynamic = "force-dynamic";

const VALID_WIDGETS = ["streak", "contributions", "languages", "prs"] as const;
type WidgetKey = (typeof VALID_WIDGETS)[number];

function sanitizePublicWidgets(input: unknown): WidgetKey[] {
if (!Array.isArray(input)) return ["streak", "contributions"];
return input.filter((w): w is WidgetKey =>
typeof w === "string" && (VALID_WIDGETS as readonly string[]).includes(w)
);
}

async function fetchUserSettings(userId: string) {
// Tier 1: All columns
// Tier 1: All columns (including public_widgets added by 20260608000000 migration)
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, public_widgets")
.eq("id", userId)
.single();

Expand All @@ -30,6 +40,7 @@ async function fetchUserSettings(userId: string) {
hasBio: true,
hasWebhookUrl: true,
hasDiscordMutedUntil: true,
hasPublicWidgets: 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 || [],
Expand All @@ -39,6 +50,7 @@ async function fetchUserSettings(userId: string) {
timezone: (res1.data as any).timezone || "UTC",
webhook_url: (res1.data as any).webhook_url || null,
discord_muted_until: (res1.data as any).discord_muted_until || null,
public_widgets: sanitizePublicWidgets((res1.data as any).public_widgets),
};
}

Expand All @@ -54,6 +66,7 @@ async function fetchUserSettings(userId: string) {
hasBio: false,
hasWebhookUrl: false,
hasDiscordMutedUntil: false,
hasPublicWidgets: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
Expand All @@ -63,13 +76,14 @@ async function fetchUserSettings(userId: string) {
timezone: "UTC",
webhook_url: null,
discord_muted_until: null,
public_widgets: ["streak", "contributions"] as WidgetKey[],
};
}

// Tier 2: Without bio, for deployments that have not run the latest migration.
// Tier 2: Without public_widgets (deployments that haven't run the latest migration yet)
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, 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")
.eq("id", userId)
.single();

Expand All @@ -80,20 +94,22 @@ async function fetchUserSettings(userId: string) {
hasLeaderboardOptIn: true,
hasPinnedRepos: true,
hasWakatimeKey: true,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
hasWeeklyDigestOptIn: true,
hasDiscordSettings: true,
hasBio: true,
hasWebhookUrl: true,
hasDiscordMutedUntil: false,
hasDiscordMutedUntil: true,
hasPublicWidgets: false,
leaderboard_opt_in: (res2.data as any).leaderboard_opt_in ?? false,
weekly_digest_opt_in: false,
weekly_digest_opt_in: (res2.data as any).weekly_digest_opt_in ?? false,
pinned_repos: (res2.data as any).pinned_repos || [],
wakatime_api_key_encrypted: (res2.data as any).wakatime_api_key_encrypted || null,
wakatime_api_key_iv: (res2.data as any).wakatime_api_key_iv || null,
discord_webhook_url: null,
timezone: "UTC",
discord_webhook_url: (res2.data as any).discord_webhook_url || null,
timezone: (res2.data as any).timezone || "UTC",
webhook_url: (res2.data as any).webhook_url || null,
discord_muted_until: null,
discord_muted_until: (res2.data as any).discord_muted_until || null,
public_widgets: ["streak", "contributions"] as WidgetKey[],
};
}

Expand All @@ -109,6 +125,7 @@ async function fetchUserSettings(userId: string) {
hasBio: false,
hasWebhookUrl: false,
hasDiscordMutedUntil: false,
hasPublicWidgets: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
Expand All @@ -118,49 +135,112 @@ async function fetchUserSettings(userId: string) {
timezone: "UTC",
webhook_url: null,
discord_muted_until: null,
public_widgets: ["streak", "contributions"] as WidgetKey[],
};
}

// Tier 3: Without public_since and show_weekly_goals (added by migrations)
// Tier 3: Without bio, for deployments that have not run the latest migration.
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, leaderboard_opt_in, pinned_repos, wakatime_api_key_encrypted, wakatime_api_key_iv, webhook_url")
.eq("id", userId)
.single();

if (!res3.error) {
return {
data: res3.data as any,
error: null,
hasLeaderboardOptIn: true,
hasPinnedRepos: true,
hasWakatimeKey: true,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
hasWebhookUrl: true,
hasDiscordMutedUntil: false,
hasPublicWidgets: false,
leaderboard_opt_in: (res3.data as any).leaderboard_opt_in ?? false,
weekly_digest_opt_in: false,
pinned_repos: (res3.data as any).pinned_repos || [],
wakatime_api_key_encrypted: (res3.data as any).wakatime_api_key_encrypted || null,
wakatime_api_key_iv: (res3.data as any).wakatime_api_key_iv || null,
discord_webhook_url: null,
timezone: "UTC",
webhook_url: (res3.data as any).webhook_url || null,
discord_muted_until: null,
public_widgets: ["streak", "contributions"] as WidgetKey[],
};
}

if (res3.error.code !== "42703") {
return {
data: null,
error: res3.error,
hasLeaderboardOptIn: false,
hasPinnedRepos: false,
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
hasWebhookUrl: false,
hasDiscordMutedUntil: false,
hasPublicWidgets: 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,
public_widgets: ["streak", "contributions"] as WidgetKey[],
};
}

if (res3.error.code !== "42703") {
// Tier 4: Without public_since and show_weekly_goals (added by migrations)
const res4 = await supabaseAdmin
.from("users")
.select("id, github_login, is_public, public_since, show_weekly_goals")
.eq("id", userId)
.single();

if (!res4.error) {
return {
data: res4.data as any,
error: null,
hasLeaderboardOptIn: false,
hasPinnedRepos: false,
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
hasDiscordMutedUntil: false,
hasPublicWidgets: 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",
discord_muted_until: null,
public_widgets: ["streak", "contributions"] as WidgetKey[],
};
}

if (res4.error.code !== "42703") {
return {
data: null,
error: res3.error,
error: res4.error,
hasLeaderboardOptIn: false,
hasPinnedRepos: false,
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
hasDiscordMutedUntil: false,
hasPublicWidgets: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
Expand All @@ -169,19 +249,20 @@ async function fetchUserSettings(userId: string) {
discord_webhook_url: null,
timezone: "UTC",
discord_muted_until: null,
public_widgets: ["streak", "contributions"] as WidgetKey[],
};
}

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

if (!res4.error) {
if (!res5.error) {
return {
data: res4.data as any,
data: res5.data as any,
error: null,
hasLeaderboardOptIn: false,
hasPinnedRepos: false,
Expand All @@ -190,6 +271,7 @@ async function fetchUserSettings(userId: string) {
hasDiscordSettings: false,
hasBio: false,
hasDiscordMutedUntil: false,
hasPublicWidgets: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
Expand All @@ -198,19 +280,21 @@ async function fetchUserSettings(userId: string) {
discord_webhook_url: null,
timezone: "UTC",
discord_muted_until: null,
public_widgets: ["streak", "contributions"] as WidgetKey[],
};
}

return {
data: null,
error: res4.error,
error: res5.error,
hasLeaderboardOptIn: false,
hasPinnedRepos: false,
hasWakatimeKey: false,
hasWeeklyDigestOptIn: false,
hasDiscordSettings: false,
hasBio: false,
hasDiscordMutedUntil: false,
hasPublicWidgets: false,
leaderboard_opt_in: false,
weekly_digest_opt_in: false,
pinned_repos: [] as string[],
Expand All @@ -219,6 +303,7 @@ async function fetchUserSettings(userId: string) {
discord_webhook_url: null,
timezone: "UTC",
discord_muted_until: null,
public_widgets: ["streak", "contributions"] as WidgetKey[],
};
}

Expand Down Expand Up @@ -267,6 +352,7 @@ export async function GET(req: NextRequest) {
timezone: result.timezone,
webhook_url: result.webhook_url ?? null,
discord_muted_until: result.discord_muted_until ?? null,
public_widgets: result.public_widgets,
};

await cacheSet(cacheKey, response, SETTINGS_TTL);
Expand All @@ -290,14 +376,27 @@ export async function PATCH(req: NextRequest) {
);
}

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;
public_widgets?: string[];
};
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, public_widgets } = body;

// Retrieve supported columns first
const settingsResult = await fetchUserSettings(user.id);
Expand All @@ -306,8 +405,23 @@ export async function PATCH(req: NextRequest) {
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 { hasLeaderboardOptIn, hasPinnedRepos, hasWakatimeKey, hasWeeklyDigestOptIn, hasDiscordSettings, hasBio, hasWebhookUrl, hasDiscordMutedUntil, hasPublicWidgets } = 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;
public_widgets?: WidgetKey[];
} = {};

if (is_public !== undefined && is_public !== null && typeof is_public === "boolean") {
updates.is_public = is_public;
Expand Down Expand Up @@ -422,6 +536,11 @@ export async function PATCH(req: NextRequest) {
}
}

// Handle public_widgets update
if (hasPublicWidgets && public_widgets !== undefined && Array.isArray(public_widgets)) {
updates.public_widgets = sanitizePublicWidgets(public_widgets);
}

// If there are no updates (or none that are supported by the schema)
if (Object.keys(updates).length === 0) {
return NextResponse.json({
Expand All @@ -439,6 +558,7 @@ export async function PATCH(req: NextRequest) {
timezone: settingsResult.timezone,
webhook_url: settingsResult.webhook_url ?? null,
discord_muted_until: settingsResult.discord_muted_until ?? null,
public_widgets: settingsResult.public_widgets,
});
}

Expand All @@ -455,6 +575,7 @@ export async function PATCH(req: NextRequest) {
if (hasDiscordSettings) selectCols.push("discord_webhook_url", "timezone");
if (hasDiscordMutedUntil) selectCols.push("discord_muted_until");
if (hasWebhookUrl) selectCols.push("webhook_url");
if (hasPublicWidgets) selectCols.push("public_widgets");

const { data: updated, error: updateError } = await supabaseAdmin
.from("users")
Expand Down Expand Up @@ -502,5 +623,8 @@ export async function PATCH(req: NextRequest) {
timezone: (updated as any).timezone || "UTC",
webhook_url: (updated as any).webhook_url ?? null,
discord_muted_until: (updated as any).discord_muted_until ?? null,
public_widgets: hasPublicWidgets
? sanitizePublicWidgets((updated as any).public_widgets)
: settingsResult.public_widgets,
});
}
}
Loading
Loading