From c00f2e938294060caf24aee66b9c0541d095abd1 Mon Sep 17 00:00:00 2001 From: Atul Upadhyay Date: Wed, 20 May 2026 15:42:20 +0530 Subject: [PATCH 1/2] feat: add local coding time tracking - Add database tables for local coding sessions and API keys - Add API routes for managing API keys - Add sync endpoint for VS Code extension to upload sessions - Add stats endpoint for dashboard display - Add LocalCodingTime component to dashboard - Show local coding time alongside GitHub activity --- src/app/api/local-coding/keys/route.ts | 103 ++++++++++ src/app/api/local-coding/stats/route.ts | 62 ++++++ src/app/api/local-coding/sync/route.ts | 125 ++++++++++++ src/app/dashboard/page.tsx | 5 +- src/components/LocalCodingTime.tsx | 190 ++++++++++++++++++ ...20260521000000_add_local_coding_tables.sql | 25 +++ 6 files changed, 508 insertions(+), 2 deletions(-) create mode 100644 src/app/api/local-coding/keys/route.ts create mode 100644 src/app/api/local-coding/stats/route.ts create mode 100644 src/app/api/local-coding/sync/route.ts create mode 100644 src/components/LocalCodingTime.tsx create mode 100644 supabase/migrations/20260521000000_add_local_coding_tables.sql diff --git a/src/app/api/local-coding/keys/route.ts b/src/app/api/local-coding/keys/route.ts new file mode 100644 index 00000000..57c8d238 --- /dev/null +++ b/src/app/api/local-coding/keys/route.ts @@ -0,0 +1,103 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; +import { randomBytes } from "crypto"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const { data: keys } = await supabaseAdmin + .from("local_coding_api_keys") + .select("id, name, last_used_at, created_at") + .eq("user_id", user.id) + .order("created_at", { ascending: false }); + + return Response.json({ keys: keys || [] }); +} + +export async function POST(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + let body: { name?: string }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const name = body.name?.trim(); + if (!name) { + return Response.json({ error: "Name is required" }, { status: 400 }); + } + + const apiKey = randomBytes(24).toString("base64url"); + + const { data: keyRecord, error } = await supabaseAdmin + .from("local_coding_api_keys") + .insert({ + user_id: user.id, + api_key: apiKey, + name, + }) + .select("id, name, api_key, last_used_at, created_at") + .single(); + + if (error) { + console.error("Error creating API key:", error); + return Response.json( + { error: "Failed to create API key" }, + { status: 500 } + ); + } + + return Response.json({ + key: keyRecord, + message: "Store this API key securely. It will not be shown again.", + }); +} + +export async function DELETE(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const { searchParams } = new URL(req.url); + const keyId = searchParams.get("id"); + + if (!keyId) { + return Response.json({ error: "Key ID is required" }, { status: 400 }); + } + + const { error } = await supabaseAdmin + .from("local_coding_api_keys") + .delete() + .eq("id", keyId) + .eq("user_id", user.id); + + if (error) { + console.error("Error deleting API key:", error); + return Response.json({ error: "Failed to delete key" }, { status: 500 }); + } + + return Response.json({ success: true }); +} \ No newline at end of file diff --git a/src/app/api/local-coding/stats/route.ts b/src/app/api/local-coding/stats/route.ts new file mode 100644 index 00000000..0e7afc60 --- /dev/null +++ b/src/app/api/local-coding/stats/route.ts @@ -0,0 +1,62 @@ +import { getServerSession } from "next-auth"; +import { NextRequest } from "next/server"; +import { authOptions } from "@/lib/auth"; +import { supabaseAdmin } from "@/lib/supabase"; +import { resolveAppUser } from "@/lib/resolve-user"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const session = await getServerSession(authOptions); + if (!session?.githubId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const user = await resolveAppUser(session.githubId, session.githubLogin); + if (!user) return Response.json({ error: "User not found" }, { status: 404 }); + + const { searchParams } = new URL(req.url); + const days = parseInt(searchParams.get("days") || "30", 10); + const fromDate = new Date(); + fromDate.setDate(fromDate.getDate() - days); + const fromDateStr = fromDate.toISOString().slice(0, 10); + + const { data: sessions } = await supabaseAdmin + .from("local_coding_sessions") + .select("*") + .eq("user_id", user.id) + .gte("date", fromDateStr) + .order("date", { ascending: false }); + + if (!sessions || sessions.length === 0) { + return Response.json({ + dailyData: [], + totals: { + totalSeconds: 0, + totalDays: 0, + avgSecondsPerDay: 0, + }, + hasData: false, + }); + } + + const dailyData = sessions.map((s) => ({ + date: s.date, + totalSeconds: s.total_seconds, + fileCount: s.file_count, + projectCount: s.project_count, + })); + + const totalSeconds = dailyData.reduce((sum, d) => sum + d.totalSeconds, 0); + const totalDays = dailyData.length; + + return Response.json({ + dailyData, + totals: { + totalSeconds, + totalDays, + avgSecondsPerDay: Math.round(totalSeconds / totalDays), + }, + hasData: true, + }); +} \ No newline at end of file diff --git a/src/app/api/local-coding/sync/route.ts b/src/app/api/local-coding/sync/route.ts new file mode 100644 index 00000000..d09d44fe --- /dev/null +++ b/src/app/api/local-coding/sync/route.ts @@ -0,0 +1,125 @@ +import { NextRequest } from "next/server"; +import { supabaseAdmin } from "@/lib/supabase"; + +export const dynamic = "force-dynamic"; + +interface SessionData { + date: string; + totalSeconds: number; + fileCount: number; + projectCount: number; +} + +export async function POST(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return Response.json({ error: "API key required" }, { status: 401 }); + } + + const apiKey = authHeader.slice(7); + + const { data: keyRecord } = await supabaseAdmin + .from("local_coding_api_keys") + .select("user_id") + .eq("api_key", apiKey) + .single(); + + if (!keyRecord) { + return Response.json({ error: "Invalid API key" }, { status: 401 }); + } + + await supabaseAdmin + .from("local_coding_api_keys") + .update({ last_used_at: new Date().toISOString() }) + .eq("api_key", apiKey); + + let body: { sessions?: SessionData[] }; + try { + body = await req.json(); + } catch { + return Response.json({ error: "Invalid JSON" }, { status: 400 }); + } + + const sessions = body.sessions; + if (!sessions || !Array.isArray(sessions) || sessions.length === 0) { + return Response.json( + { error: "Sessions array is required" }, + { status: 400 } + ); + } + + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + for (const session of sessions) { + if (!dateRegex.test(session.date)) { + return Response.json( + { error: "Invalid date format. Use YYYY-MM-DD" }, + { status: 400 } + ); + } + if ( + typeof session.totalSeconds !== "number" || + session.totalSeconds < 0 + ) { + return Response.json( + { error: "totalSeconds must be a non-negative number" }, + { status: 400 } + ); + } + } + + const records = sessions.map((session) => ({ + user_id: keyRecord.user_id, + date: session.date, + total_seconds: session.totalSeconds, + file_count: session.fileCount || 0, + project_count: session.projectCount || 0, + })); + + for (const record of records) { + await supabaseAdmin + .from("local_coding_sessions") + .upsert(record, { onConflict: "user_id,date" }); + } + + return Response.json({ + success: true, + synced: records.length, + message: "Sessions synced successfully", + }); +} + +export async function GET(req: NextRequest) { + const authHeader = req.headers.get("authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return Response.json({ error: "API key required" }, { status: 401 }); + } + + const apiKey = authHeader.slice(7); + + const { data: keyRecord } = await supabaseAdmin + .from("local_coding_api_keys") + .select("user_id") + .eq("api_key", apiKey) + .single(); + + if (!keyRecord) { + return Response.json({ error: "Invalid API key" }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const days = parseInt(searchParams.get("days") || "30", 10); + const fromDate = new Date(); + fromDate.setDate(fromDate.getDate() - days); + const fromDateStr = fromDate.toISOString().slice(0, 10); + + const { data: sessions } = await supabaseAdmin + .from("local_coding_sessions") + .select("*") + .eq("user_id", keyRecord.user_id) + .gte("date", fromDateStr) + .order("date", { ascending: false }); + + return Response.json({ sessions: sessions || [] }); +} \ No newline at end of file diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 9dcc11eb..9e79afe9 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -17,6 +17,7 @@ import WeeklySummaryCard from "@/components/WeeklySummaryCard"; import ExportButton from "@/components/ExportButton"; import Link from "next/link"; import PersonalRecords from "@/components/PersonalRecords"; +import LocalCodingTime from "@/components/LocalCodingTime"; import { authOptions } from "@/lib/auth"; import { cookies } from "next/headers"; import { getServerSession } from "next-auth"; @@ -56,7 +57,7 @@ export default async function DashboardPage() { - {/* Row 1: Contribution graph + Streak + Friend Comparison */} + {/* Row 1: Contribution graph + Streak + Local Coding Time */}
@@ -67,7 +68,7 @@ export default async function DashboardPage() {
- +
diff --git a/src/components/LocalCodingTime.tsx b/src/components/LocalCodingTime.tsx new file mode 100644 index 00000000..4524ff0f --- /dev/null +++ b/src/components/LocalCodingTime.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface DailyData { + date: string; + totalSeconds: number; + fileCount: number; + projectCount: number; +} + +function formatDuration(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +function formatDate(dateStr: string): string { + const date = new Date(dateStr + "T00:00:00"); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +export default function LocalCodingTime() { + const [data, setData] = useState<{ + dailyData: DailyData[]; + totals: { + totalSeconds: number; + totalDays: number; + avgSecondsPerDay: number; + }; + hasData: boolean; + } | null>(null); + const [loading, setLoading] = useState(true); + const [days, setDays] = useState(30); + + useEffect(() => { + async function loadStats() { + try { + const res = await fetch(`/api/local-coding/stats?days=${days}`); + if (res.ok) { + const json = await res.json(); + setData(json); + } + } catch { + setData(null); + } finally { + setLoading(false); + } + } + + loadStats(); + }, [days]); + + if (loading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (!data || !data.hasData) { + return ( +
+
+

+ Local Coding Time +

+ +
+
+ + + +

+ No local coding data yet +

+

+ Install the DevTrack VS Code extension to track your coding time +

+
+
+ ); + } + + const maxSeconds = Math.max(...data.dailyData.map((d) => d.totalSeconds), 1); + + return ( +
+
+

+ Local Coding Time +

+ +
+ +
+
+
+ {formatDuration(data.totals.totalSeconds)} +
+
+ Total time +
+
+
+
+ {data.totals.totalDays} +
+
+ Active days +
+
+
+
+ {formatDuration(data.totals.avgSecondsPerDay)} +
+
+ Daily avg +
+
+
+ +
+ {data.dailyData.slice(0, 14).map((day) => { + const pct = (day.totalSeconds / maxSeconds) * 100; + return ( +
+ + {formatDate(day.date)} + +
+
+
+ + {formatDuration(day.totalSeconds)} + +
+ ); + })} +
+ +
+

+ Track your coding time with the DevTrack VS Code extension +

+
+
+ ); +} \ No newline at end of file diff --git a/supabase/migrations/20260521000000_add_local_coding_tables.sql b/supabase/migrations/20260521000000_add_local_coding_tables.sql new file mode 100644 index 00000000..fea25104 --- /dev/null +++ b/supabase/migrations/20260521000000_add_local_coding_tables.sql @@ -0,0 +1,25 @@ +create table if not exists local_coding_sessions ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + date date not null, + total_seconds integer not null default 0, + file_count integer not null default 0, + project_count integer not null default 0, + created_at timestamptz default now(), + updated_at timestamptz default now(), + unique(user_id, date) +); + +create index if not exists local_coding_sessions_user_date on local_coding_sessions(user_id, date); + +create table if not exists local_coding_api_keys ( + id text primary key default gen_random_uuid()::text, + user_id text not null references users(id) on delete cascade, + api_key text not null unique, + name text not null, + last_used_at timestamptz, + created_at timestamptz default now() +); + +create index if not exists local_coding_api_keys_user on local_coding_api_keys(user_id); +create index if not exists local_coding_api_keys_key on local_coding_api_keys(api_key); \ No newline at end of file From 3ff0d28380781c1e02d09a991ee978f09fdfe063 Mon Sep 17 00:00:00 2001 From: Atul Upadhyay Date: Thu, 21 May 2026 08:32:22 +0530 Subject: [PATCH 2/2] fix: address security review feedback for local-coding feature - Store SHA-256 hash of API keys instead of plaintext in local_coding_api_keys - Add rate limiting: MAX_SESSIONS_PER_REQUEST=100, MAX_SESSIONS_PER_USER=365 - Validate days parameter with allowlist [7, 30, 90] (defaults to 30) - Add per-user session row limit (365 days max) - Add EOF newlines to all local-coding route files, component, and migrations --- src/app/api/local-coding/keys/route.ts | 15 ++- src/app/api/local-coding/stats/route.ts | 15 ++- src/app/api/local-coding/sync/route.ts | 120 ++++++++++++------ src/components/LocalCodingTime.tsx | 2 +- ...20260521000000_add_local_coding_tables.sql | 2 +- ...20260522000000_add_api_key_hash_column.sql | 3 + 6 files changed, 109 insertions(+), 48 deletions(-) create mode 100644 supabase/migrations/20260522000000_add_api_key_hash_column.sql diff --git a/src/app/api/local-coding/keys/route.ts b/src/app/api/local-coding/keys/route.ts index 57c8d238..56caddf4 100644 --- a/src/app/api/local-coding/keys/route.ts +++ b/src/app/api/local-coding/keys/route.ts @@ -3,10 +3,14 @@ import { NextRequest } from "next/server"; import { authOptions } from "@/lib/auth"; import { supabaseAdmin } from "@/lib/supabase"; import { resolveAppUser } from "@/lib/resolve-user"; -import { randomBytes } from "crypto"; +import { randomBytes, createHash } from "crypto"; export const dynamic = "force-dynamic"; +function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} + export async function GET() { const session = await getServerSession(authOptions); if (!session?.githubId) { @@ -47,15 +51,16 @@ export async function POST(req: NextRequest) { } const apiKey = randomBytes(24).toString("base64url"); + const apiKeyHash = hashApiKey(apiKey); const { data: keyRecord, error } = await supabaseAdmin .from("local_coding_api_keys") .insert({ user_id: user.id, - api_key: apiKey, + api_key: apiKeyHash, name, }) - .select("id, name, api_key, last_used_at, created_at") + .select("id, name, last_used_at, created_at") .single(); if (error) { @@ -67,7 +72,7 @@ export async function POST(req: NextRequest) { } return Response.json({ - key: keyRecord, + key: { ...keyRecord, api_key: apiKey }, message: "Store this API key securely. It will not be shown again.", }); } @@ -100,4 +105,4 @@ export async function DELETE(req: NextRequest) { } return Response.json({ success: true }); -} \ No newline at end of file +} diff --git a/src/app/api/local-coding/stats/route.ts b/src/app/api/local-coding/stats/route.ts index 0e7afc60..24ab971d 100644 --- a/src/app/api/local-coding/stats/route.ts +++ b/src/app/api/local-coding/stats/route.ts @@ -6,6 +6,16 @@ import { resolveAppUser } from "@/lib/resolve-user"; export const dynamic = "force-dynamic"; +const ALLOWED_DAYS = [7, 30, 90]; +const DEFAULT_DAYS = 30; + +function validateDays(days: number): number { + if (ALLOWED_DAYS.includes(days)) { + return days; + } + return DEFAULT_DAYS; +} + export async function GET(req: NextRequest) { const session = await getServerSession(authOptions); if (!session?.githubId) { @@ -16,7 +26,8 @@ export async function GET(req: NextRequest) { if (!user) return Response.json({ error: "User not found" }, { status: 404 }); const { searchParams } = new URL(req.url); - const days = parseInt(searchParams.get("days") || "30", 10); + const rawDays = parseInt(searchParams.get("days") || "30", 10); + const days = validateDays(isNaN(rawDays) ? DEFAULT_DAYS : rawDays); const fromDate = new Date(); fromDate.setDate(fromDate.getDate() - days); const fromDateStr = fromDate.toISOString().slice(0, 10); @@ -59,4 +70,4 @@ export async function GET(req: NextRequest) { }, hasData: true, }); -} \ No newline at end of file +} diff --git a/src/app/api/local-coding/sync/route.ts b/src/app/api/local-coding/sync/route.ts index d09d44fe..56df39a3 100644 --- a/src/app/api/local-coding/sync/route.ts +++ b/src/app/api/local-coding/sync/route.ts @@ -1,8 +1,46 @@ import { NextRequest } from "next/server"; import { supabaseAdmin } from "@/lib/supabase"; +import { createHash } from "crypto"; export const dynamic = "force-dynamic"; +const MAX_SESSIONS_PER_REQUEST = 100; +const MAX_SESSIONS_PER_USER = 365; +const ALLOWED_DAYS = [7, 30, 90]; +const DEFAULT_DAYS = 30; + +function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} + +async function authenticateApiKey(apiKey: string): Promise { + const keyHash = hashApiKey(apiKey); + + const { data: keyRecord } = await supabaseAdmin + .from("local_coding_api_keys") + .select("user_id") + .eq("api_key_hash", keyHash) + .single(); + + if (!keyRecord) { + return null; + } + + await supabaseAdmin + .from("local_coding_api_keys") + .update({ last_used_at: new Date().toISOString() }) + .eq("api_key_hash", keyHash); + + return keyRecord.user_id; +} + +function validateDays(days: number): number { + if (ALLOWED_DAYS.includes(days)) { + return days; + } + return DEFAULT_DAYS; +} + interface SessionData { date: string; totalSeconds: number; @@ -18,22 +56,12 @@ export async function POST(req: NextRequest) { } const apiKey = authHeader.slice(7); + const userId = await authenticateApiKey(apiKey); - const { data: keyRecord } = await supabaseAdmin - .from("local_coding_api_keys") - .select("user_id") - .eq("api_key", apiKey) - .single(); - - if (!keyRecord) { + if (!userId) { return Response.json({ error: "Invalid API key" }, { status: 401 }); } - await supabaseAdmin - .from("local_coding_api_keys") - .update({ last_used_at: new Date().toISOString() }) - .eq("api_key", apiKey); - let body: { sessions?: SessionData[] }; try { body = await req.json(); @@ -49,27 +77,45 @@ export async function POST(req: NextRequest) { ); } - const dateRegex = /^\d{4}-\d{2}-\d{2}$/; - for (const session of sessions) { - if (!dateRegex.test(session.date)) { - return Response.json( - { error: "Invalid date format. Use YYYY-MM-DD" }, - { status: 400 } - ); + if (sessions.length > MAX_SESSIONS_PER_REQUEST) { + return Response.json( + { error: `Too many sessions. Maximum ${MAX_SESSIONS_PER_REQUEST} per request.` }, + { status: 400 } + ); + } + + const { count: existingCount } = await supabaseAdmin + .from("local_coding_sessions") + .select("id", { count: "exact", head: true }) + .eq("user_id", userId); + + const newSessions = sessions.filter((s) => { + const dateRegex = /^\d{4}-\d{2}-\d{2}$/; + if (!dateRegex.test(s.date)) { + return false; } - if ( - typeof session.totalSeconds !== "number" || - session.totalSeconds < 0 - ) { - return Response.json( - { error: "totalSeconds must be a non-negative number" }, - { status: 400 } - ); + if (typeof s.totalSeconds !== "number" || s.totalSeconds < 0) { + return false; } + return true; + }); + + if (newSessions.length !== sessions.length) { + return Response.json( + { error: "Invalid session data found in array" }, + { status: 400 } + ); } - const records = sessions.map((session) => ({ - user_id: keyRecord.user_id, + if ((existingCount || 0) + newSessions.length > MAX_SESSIONS_PER_USER) { + return Response.json( + { error: `Session limit reached. Maximum ${MAX_SESSIONS_PER_USER} sessions per user.` }, + { status: 400 } + ); + } + + const records = newSessions.map((session) => ({ + user_id: userId, date: session.date, total_seconds: session.totalSeconds, file_count: session.fileCount || 0, @@ -97,19 +143,15 @@ export async function GET(req: NextRequest) { } const apiKey = authHeader.slice(7); + const userId = await authenticateApiKey(apiKey); - const { data: keyRecord } = await supabaseAdmin - .from("local_coding_api_keys") - .select("user_id") - .eq("api_key", apiKey) - .single(); - - if (!keyRecord) { + if (!userId) { return Response.json({ error: "Invalid API key" }, { status: 401 }); } const { searchParams } = new URL(req.url); - const days = parseInt(searchParams.get("days") || "30", 10); + const rawDays = parseInt(searchParams.get("days") || "30", 10); + const days = validateDays(isNaN(rawDays) ? DEFAULT_DAYS : rawDays); const fromDate = new Date(); fromDate.setDate(fromDate.getDate() - days); const fromDateStr = fromDate.toISOString().slice(0, 10); @@ -117,9 +159,9 @@ export async function GET(req: NextRequest) { const { data: sessions } = await supabaseAdmin .from("local_coding_sessions") .select("*") - .eq("user_id", keyRecord.user_id) + .eq("user_id", userId) .gte("date", fromDateStr) .order("date", { ascending: false }); return Response.json({ sessions: sessions || [] }); -} \ No newline at end of file +} diff --git a/src/components/LocalCodingTime.tsx b/src/components/LocalCodingTime.tsx index 4524ff0f..3b88f0b3 100644 --- a/src/components/LocalCodingTime.tsx +++ b/src/components/LocalCodingTime.tsx @@ -187,4 +187,4 @@ export default function LocalCodingTime() {
); -} \ No newline at end of file +} diff --git a/supabase/migrations/20260521000000_add_local_coding_tables.sql b/supabase/migrations/20260521000000_add_local_coding_tables.sql index fea25104..56eabe45 100644 --- a/supabase/migrations/20260521000000_add_local_coding_tables.sql +++ b/supabase/migrations/20260521000000_add_local_coding_tables.sql @@ -22,4 +22,4 @@ create table if not exists local_coding_api_keys ( ); create index if not exists local_coding_api_keys_user on local_coding_api_keys(user_id); -create index if not exists local_coding_api_keys_key on local_coding_api_keys(api_key); \ No newline at end of file +create index if not exists local_coding_api_keys_key on local_coding_api_keys(api_key); diff --git a/supabase/migrations/20260522000000_add_api_key_hash_column.sql b/supabase/migrations/20260522000000_add_api_key_hash_column.sql new file mode 100644 index 00000000..fec3f473 --- /dev/null +++ b/supabase/migrations/20260522000000_add_api_key_hash_column.sql @@ -0,0 +1,3 @@ +alter table local_coding_api_keys add column if not exists api_key_hash text unique; + +create index if not exists local_coding_api_keys_key_hash on local_coding_api_keys(api_key_hash);