iamfebin commited on
Commit
a95fdd1
·
1 Parent(s): 080f0c0

feat: implement sandbox mode

Browse files
.gitignore CHANGED
@@ -12,6 +12,7 @@ __pycache__/
12
 
13
  # Local SQLite databases and logs
14
  /scratch/db_*.sqlite
 
15
  sync.log
16
  debug_cookies.txt
17
  debug_db.txt
 
12
 
13
  # Local SQLite databases and logs
14
  /scratch/db_*.sqlite
15
+ /scratch/sandbox.db
16
  sync.log
17
  debug_cookies.txt
18
  debug_db.txt
algospaced-ui/src/App.tsx CHANGED
@@ -8,6 +8,7 @@ import TerminalEmulator from './components/TerminalEmulator';
8
  import PythonChallenge from './components/PythonChallenge';
9
  import MySQLPlayground from './components/MySQLPlayground';
10
  import FileExplorer from './components/FileExplorer';
 
11
 
12
 
13
 
@@ -29,6 +30,7 @@ export default function App() {
29
  sync: { isOpen: false, isMinimized: false },
30
  python: { isOpen: false, isMinimized: false },
31
  mysql: { isOpen: false, isMinimized: false },
 
32
  });
33
  const [activeWindow, setActiveWindow] = useState<string>('queue');
34
  const [isMobile, setIsMobile] = useState(false);
@@ -461,6 +463,22 @@ export default function App() {
461
  TERMINAL
462
  </span>
463
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  </nav>
465
  )}
466
 
@@ -602,6 +620,20 @@ export default function App() {
602
  </span>
603
  </button>
604
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  {/* Drive Sync Tile (Wide - 2 cols) */}
606
  <button
607
  onClick={triggerSync}
@@ -804,6 +836,25 @@ export default function App() {
804
  <MySQLPlayground />
805
  </WindowShell>
806
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
807
  {/* Start Menu Popup */}
808
  {isStartOpen && (
809
  <div
@@ -860,6 +911,15 @@ export default function App() {
860
  <span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>database</span>
861
  MySQL Playground
862
  </button>
 
 
 
 
 
 
 
 
 
863
  </div>
864
 
865
  <div className="mt-4 flex justify-end border-t-[3px] border-ink-black pt-4 select-none">
@@ -999,6 +1059,21 @@ export default function App() {
999
  <span>{isMobile ? 'SQL' : 'MySQL Playground'}</span>
1000
  </button>
1001
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1002
  </div>
1003
 
1004
  {/* System tray (wifi, clock, volume) */}
 
8
  import PythonChallenge from './components/PythonChallenge';
9
  import MySQLPlayground from './components/MySQLPlayground';
10
  import FileExplorer from './components/FileExplorer';
11
+ import Settings from './components/Settings';
12
 
13
 
14
 
 
30
  sync: { isOpen: false, isMinimized: false },
31
  python: { isOpen: false, isMinimized: false },
32
  mysql: { isOpen: false, isMinimized: false },
33
+ settings: { isOpen: false, isMinimized: false },
34
  });
35
  const [activeWindow, setActiveWindow] = useState<string>('queue');
36
  const [isMobile, setIsMobile] = useState(false);
 
463
  TERMINAL
464
  </span>
465
  </button>
466
+
467
+ {localStorage.getItem('algospaced_sandbox_mode') === 'true' && (
468
+ <button
469
+ onClick={() => toggleWindowMinimize('settings')}
470
+ className="flex flex-col items-center justify-center text-ink-black gap-2.5 p-2 w-full group icon-hover-shift cursor-pointer outline-none animate-fade-in"
471
+ >
472
+ <div className="w-16 h-16 bg-paper-white border-[3px] border-ink-black flex items-center justify-center gadget-shadow group-hover:bg-[#10b981] group-hover:text-white transition-all shadow-[inset_2px_2px_0px_rgba(255,255,255,0.8)]">
473
+ <span className="material-symbols-outlined text-4xl" style={{ fontVariationSettings: "'FILL' 1" }}>
474
+ key
475
+ </span>
476
+ </div>
477
+ <span className="font-icon-label text-[9px] bg-ink-black text-white px-2 py-1 border-[3px] border-ink-black text-center leading-tight shadow-[2px_2px_0px_0px_#10b981] uppercase">
478
+ API<br/>KEYS
479
+ </span>
480
+ </button>
481
+ )}
482
  </nav>
483
  )}
484
 
 
620
  </span>
621
  </button>
622
 
623
+ {localStorage.getItem('algospaced_sandbox_mode') === 'true' && (
624
+ <button
625
+ onClick={() => toggleWindowMinimize('settings')}
626
+ className="bg-[#10b981] text-white border-[3px] border-ink-black p-4 flex flex-col justify-between aspect-square gadget-shadow active:translate-y-1 active:translate-x-1 active:shadow-none select-none text-left cursor-pointer transition-all"
627
+ >
628
+ <span className="material-symbols-outlined text-3xl align-top" style={{ fontVariationSettings: "'FILL' 1" }}>
629
+ key
630
+ </span>
631
+ <span className="font-arcade text-[8px] font-bold tracking-tight uppercase leading-tight">
632
+ API<br/>KEYS
633
+ </span>
634
+ </button>
635
+ )}
636
+
637
  {/* Drive Sync Tile (Wide - 2 cols) */}
638
  <button
639
  onClick={triggerSync}
 
836
  <MySQLPlayground />
837
  </WindowShell>
838
 
839
+ {/* 8. API Control Panel Window */}
840
+ <WindowShell
841
+ id="settings"
842
+ title="Control Panel"
843
+ icon="key"
844
+ isOpen={windows.settings?.isOpen}
845
+ isMinimized={windows.settings?.isMinimized}
846
+ onClose={() => closeWindow('settings')}
847
+ onMinimize={() => minimizeWindow('settings')}
848
+ activeWindow={activeWindow}
849
+ setActiveWindow={setActiveWindow}
850
+ defaultWidth="500px"
851
+ defaultHeight="450px"
852
+ titleBarColor="#10b981"
853
+ isMobile={isMobile}
854
+ >
855
+ <Settings />
856
+ </WindowShell>
857
+
858
  {/* Start Menu Popup */}
859
  {isStartOpen && (
860
  <div
 
911
  <span className="material-symbols-outlined text-lg" style={{ fontVariationSettings: "'FILL' 1" }}>database</span>
912
  MySQL Playground
913
  </button>
914
+ {localStorage.getItem('algospaced_sandbox_mode') === 'true' && (
915
+ <button
916
+ onClick={() => openWindow('settings')}
917
+ className="w-full text-left hover:bg-[#10b981]/25 px-3 py-1.5 border-2 border-transparent hover:border-[#10b981] transition-colors cursor-pointer flex items-center gap-2"
918
+ >
919
+ <span className="material-symbols-outlined text-lg text-[#10b981]" style={{ fontVariationSettings: "'FILL' 1" }}>key</span>
920
+ Gemini API Config
921
+ </button>
922
+ )}
923
  </div>
924
 
925
  <div className="mt-4 flex justify-end border-t-[3px] border-ink-black pt-4 select-none">
 
1059
  <span>{isMobile ? 'SQL' : 'MySQL Playground'}</span>
1060
  </button>
1061
  )}
1062
+
1063
+ {/* API Settings Tab */}
1064
+ {windows.settings?.isOpen && (
1065
+ <button
1066
+ onClick={() => toggleWindowMinimize('settings')}
1067
+ className={`h-full ${isMobile ? 'px-2 text-[10px] gap-1' : 'px-4 text-xs gap-2'} border-r-[4px] border-ink-black font-bold font-window-title cursor-pointer transition-all shadow-[inset_0px_3px_0px_rgba(255,255,255,0.8)] ${
1068
+ activeWindow === 'settings' && !windows.settings.isMinimized
1069
+ ? 'bg-paper-white text-[#10b981] font-extrabold translate-y-[2px]'
1070
+ : 'bg-surface-variant text-trash-gray hover:bg-white/40'
1071
+ }`}
1072
+ >
1073
+ <span className="material-symbols-outlined text-sm font-bold text-[#10b981]">key</span>
1074
+ <span>{isMobile ? 'API' : 'API Config'}</span>
1075
+ </button>
1076
+ )}
1077
  </div>
1078
 
1079
  {/* System tray (wifi, clock, volume) */}
algospaced-ui/src/components/Auth.tsx CHANGED
@@ -54,6 +54,11 @@ export default function Auth() {
54
  }
55
  };
56
 
 
 
 
 
 
57
  return (
58
  <div className="min-h-screen w-screen flex items-center justify-center p-4 relative selection:bg-highlight-pink selection:text-white">
59
  {/* Background Wallpaper image */}
@@ -194,6 +199,15 @@ export default function Auth() {
194
  </svg>
195
  SIGN IN WITH GOOGLE
196
  </button>
 
 
 
 
 
 
 
 
 
197
  </div>
198
  </div>
199
  </div>
 
54
  }
55
  };
56
 
57
+ const handleSandboxEntrance = () => {
58
+ localStorage.setItem('algospaced_sandbox_mode', 'true');
59
+ window.location.reload();
60
+ };
61
+
62
  return (
63
  <div className="min-h-screen w-screen flex items-center justify-center p-4 relative selection:bg-highlight-pink selection:text-white">
64
  {/* Background Wallpaper image */}
 
199
  </svg>
200
  SIGN IN WITH GOOGLE
201
  </button>
202
+
203
+ <button
204
+ onClick={handleSandboxEntrance}
205
+ disabled={loading}
206
+ className="mt-3 w-full bg-[#10b981] hover:bg-emerald-600 text-white font-window-title py-3 px-4 border-[3px] border-ink-black rounded-xl flex items-center justify-center gap-2 cursor-pointer shadow-[3px_3px_0px_0px_rgba(30,41,59,1)] active:translate-y-0.5 active:shadow-none uppercase font-bold text-xs"
207
+ >
208
+ <span className="material-symbols-outlined text-sm font-bold">sports_esports</span>
209
+ Enter Sandbox Mode (No Login)
210
+ </button>
211
  </div>
212
  </div>
213
  </div>
algospaced-ui/src/components/Settings.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useEffect } from 'react';
2
+ import { Key, Eye, EyeOff, Save, Trash } from 'lucide-react';
3
+
4
+ export default function Settings() {
5
+ const [apiKey, setApiKey] = useState('');
6
+ const [showKey, setShowKey] = useState(false);
7
+ const [status, setStatus] = useState<string | null>(null);
8
+
9
+ useEffect(() => {
10
+ const savedKey = localStorage.getItem('sandbox_gemini_api_key') || '';
11
+ setApiKey(savedKey);
12
+ }, []);
13
+
14
+ const handleSave = () => {
15
+ if (apiKey.trim()) {
16
+ localStorage.setItem('sandbox_gemini_api_key', apiKey.trim());
17
+ setStatus('API Key saved successfully!');
18
+ } else {
19
+ localStorage.removeItem('sandbox_gemini_api_key');
20
+ setStatus('API Key cleared.');
21
+ }
22
+ setTimeout(() => setStatus(null), 3000);
23
+ };
24
+
25
+ const handleClear = () => {
26
+ localStorage.removeItem('sandbox_gemini_api_key');
27
+ setApiKey('');
28
+ setStatus('API Key cleared.');
29
+ setTimeout(() => setStatus(null), 3000);
30
+ };
31
+
32
+ return (
33
+ <div className="w-full h-full bg-[#fdf8e1] p-6 flex flex-col gap-5 select-none font-sans text-ink-black overflow-y-auto custom-scrollbar">
34
+ <div className="flex flex-col gap-2">
35
+ <h3 className="font-headline-md font-bold text-sm m-0 flex items-center gap-2">
36
+ <span className="material-symbols-outlined text-lg text-[#0ea5e9]">key</span>
37
+ CUSTOM GEMINI API CONFIG
38
+ </h3>
39
+ <p className="text-[9px] text-trash-gray font-bold leading-normal m-0 uppercase font-arcade">
40
+ Configure a custom Gemini API key for Sandbox Mode AI evaluations & explanations.
41
+ </p>
42
+ </div>
43
+
44
+ <div className="bg-white border-[3px] border-ink-black p-4 shadow-[4px_4px_0px_0px_rgba(30,41,59,1)] flex flex-col gap-4">
45
+ <div>
46
+ <label className="block text-xs font-bold text-ink-black uppercase tracking-wider mb-2 font-headline-md">
47
+ Gemini API Key
48
+ </label>
49
+ <div className="relative">
50
+ <Key className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-ink-black" />
51
+ <input
52
+ type={showKey ? 'text' : 'password'}
53
+ value={apiKey}
54
+ onChange={(e) => setApiKey(e.target.value)}
55
+ placeholder="AIzaSy..."
56
+ className="w-full bg-white border-[3px] border-ink-black rounded-lg py-2.5 pl-10 pr-10 text-ink-black placeholder-trash-gray focus:outline-none focus:bg-white text-xs font-code-mono shadow-[inset_2px_2px_0px_rgba(0,0,0,0.1)]"
57
+ />
58
+ <button
59
+ type="button"
60
+ onClick={() => setShowKey(!showKey)}
61
+ className="absolute right-3.5 top-1/2 -translate-y-1/2 text-trash-gray hover:text-ink-black cursor-pointer bg-transparent border-0 outline-none"
62
+ >
63
+ {showKey ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
64
+ </button>
65
+ </div>
66
+ <span className="text-[9px] text-trash-gray mt-2 block font-medium leading-normal">
67
+ Your key is stored locally in your browser's localStorage and is only sent to the local server in request headers.
68
+ </span>
69
+ </div>
70
+
71
+ {status && (
72
+ <div className="p-3 border-[3px] border-ink-black bg-emerald-100 border-emerald-500 text-emerald-700 text-xs font-bold font-arcade leading-relaxed shadow-[2px_2px_0px_0px_rgba(30,41,59,1)]">
73
+ {status}
74
+ </div>
75
+ )}
76
+
77
+ <div className="flex flex-col sm:flex-row gap-3 pt-2 border-t border-slate-200">
78
+ <button
79
+ onClick={handleSave}
80
+ className="flex-1 flex items-center justify-center gap-2 py-2.5 border-[3px] border-ink-black bg-[#ffe24c] hover:bg-yellow-300 text-xs font-bold text-ink-black shadow-[3px_3px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer text-center uppercase"
81
+ >
82
+ <Save className="w-4 h-4" />
83
+ Save Key
84
+ </button>
85
+ <button
86
+ onClick={handleClear}
87
+ className="flex-1 flex items-center justify-center gap-2 py-2.5 border-[3px] border-ink-black bg-white hover:bg-slate-100 text-xs font-bold text-ink-black shadow-[3px_3px_0px_0px_#1E293B] active:translate-y-0.5 active:shadow-none transition-all cursor-pointer text-center uppercase"
88
+ >
89
+ <Trash className="w-4 h-4 text-rose-500" />
90
+ Clear Key
91
+ </button>
92
+ </div>
93
+ </div>
94
+ </div>
95
+ );
96
+ }
algospaced-ui/src/main.tsx CHANGED
@@ -5,6 +5,27 @@ import App from './App.tsx'
5
 
6
  import { initializeSupabase } from './supabaseClient'
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  async function init() {
9
  try {
10
  const apiBase = import.meta.env.VITE_API_BASE_URL || '';
 
5
 
6
  import { initializeSupabase } from './supabaseClient'
7
 
8
+ // Global fetch interceptor to append custom Gemini API Key header if available
9
+ const originalFetch = window.fetch;
10
+ window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
11
+ const customKey = localStorage.getItem('sandbox_gemini_api_key');
12
+ if (customKey) {
13
+ const urlStr = typeof input === 'string'
14
+ ? input
15
+ : 'url' in input
16
+ ? (input as any).url
17
+ : input.toString();
18
+ if (urlStr && urlStr.includes('/api/')) {
19
+ const newInit = init ? { ...init } : {};
20
+ const headers = new Headers(newInit.headers || {});
21
+ headers.set('X-Gemini-API-Key', customKey);
22
+ newInit.headers = headers;
23
+ return originalFetch(input, newInit);
24
+ }
25
+ }
26
+ return originalFetch(input, init);
27
+ };
28
+
29
  async function init() {
30
  try {
31
  const apiBase = import.meta.env.VITE_API_BASE_URL || '';
algospaced-ui/src/supabaseClient.ts CHANGED
@@ -11,6 +11,39 @@ export function initializeSupabase(url: string, key: string) {
11
  // Proxy wrapper that delegates all calls to the dynamically initialized instance
12
  export const supabase = new Proxy({} as any, {
13
  get(_target, prop) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  if (!supabaseInstance) {
15
  // Fallback: try loading from build-time environment if not initialized yet
16
  const url = import.meta.env.VITE_SUPABASE_URL || '';
 
11
  // Proxy wrapper that delegates all calls to the dynamically initialized instance
12
  export const supabase = new Proxy({} as any, {
13
  get(_target, prop) {
14
+ if (prop === 'auth' && localStorage.getItem('algospaced_sandbox_mode') === 'true') {
15
+ const mockSession = {
16
+ access_token: 'sandbox',
17
+ user: {
18
+ id: 'sandbox-user-id',
19
+ email: 'sandbox@algospaced.io',
20
+ email_confirmed_at: new Date().toISOString(),
21
+ last_sign_in_at: new Date().toISOString(),
22
+ role: 'authenticated',
23
+ aud: 'authenticated',
24
+ }
25
+ };
26
+ return {
27
+ getSession: async () => ({ data: { session: mockSession }, error: null }),
28
+ getUser: async () => ({ data: { user: mockSession.user }, error: null }),
29
+ onAuthStateChange: (callback: any) => {
30
+ setTimeout(() => callback('SIGNED_IN', mockSession), 0);
31
+ return {
32
+ data: {
33
+ subscription: {
34
+ unsubscribe: () => {}
35
+ }
36
+ }
37
+ };
38
+ },
39
+ signOut: async () => {
40
+ localStorage.removeItem('algospaced_sandbox_mode');
41
+ window.location.reload();
42
+ return { error: null };
43
+ }
44
+ };
45
+ }
46
+
47
  if (!supabaseInstance) {
48
  // Fallback: try loading from build-time environment if not initialized yet
49
  const url = import.meta.env.VITE_SUPABASE_URL || '';
db.py CHANGED
@@ -208,12 +208,12 @@ def get_problem_titles_by_number(client=None) -> dict:
208
  if client is None:
209
  client = get_supabase_client()
210
  user_id = get_current_user_id(client)
211
- if not user_id:
212
- return {}
213
- res = client.table('problems').select('problem_number, name').eq('user_id', user_id).execute()
214
  mapping = {}
215
  if res.data:
216
- for row in res.data:
 
 
217
  num = row.get('problem_number')
218
  if num is not None:
219
  base_name = row['name'].split(" - ")[0].strip()
@@ -224,10 +224,22 @@ def get_problems_by_number(problem_number: int, client=None) -> list:
224
  if client is None:
225
  client = get_supabase_client()
226
  user_id = get_current_user_id(client)
227
- if not user_id:
 
 
 
 
 
228
  return []
229
- res = client.table('problems').select('*').eq('user_id', user_id).eq('problem_number', problem_number).execute()
230
- return res.data if res.data else []
 
 
 
 
 
 
 
231
 
232
  def get_reviews_by_problem_id(problem_id: str, client=None) -> list:
233
  if client is None:
 
208
  if client is None:
209
  client = get_supabase_client()
210
  user_id = get_current_user_id(client)
211
+ res = client.table('problems').select('problem_number, name, user_id').execute()
 
 
212
  mapping = {}
213
  if res.data:
214
+ # Sort so that current user's titles override others
215
+ rows = sorted(res.data, key=lambda r: 1 if r.get('user_id') == user_id else 0)
216
+ for row in rows:
217
  num = row.get('problem_number')
218
  if num is not None:
219
  base_name = row['name'].split(" - ")[0].strip()
 
224
  if client is None:
225
  client = get_supabase_client()
226
  user_id = get_current_user_id(client)
227
+
228
+ # Fetch all versions of this problem number
229
+ res = client.table('problems').select('*').eq('problem_number', problem_number).execute()
230
+ problems = res.data if res.data else []
231
+
232
+ if not problems:
233
  return []
234
+
235
+ # Prioritize current user's problem
236
+ if user_id:
237
+ user_probs = [p for p in problems if p.get('user_id') == user_id]
238
+ if user_probs:
239
+ return user_probs
240
+
241
+ # Fallback to returning all public versions
242
+ return problems
243
 
244
  def get_reviews_by_problem_id(problem_id: str, client=None) -> list:
245
  if client is None:
llm_reviewer.py CHANGED
@@ -40,10 +40,10 @@ def generate_content_with_fallback(client, system_instruction, user_prompt, is_j
40
 
41
  raise last_err if last_err else Exception("No Gemini models succeeded")
42
 
43
- def evaluate_code(problem_name, pattern_name, expected_time, expected_space, candidate_code):
44
- api_key = get_secret("GEMINI_API_KEY")
45
  if not api_key:
46
- return {"error": "GEMINI_API_KEY not found in secrets"}
47
 
48
  client = genai.Client(api_key=api_key)
49
 
@@ -80,10 +80,10 @@ def evaluate_code(problem_name, pattern_name, expected_time, expected_space, can
80
  except Exception as e:
81
  return {"error": str(e)}
82
 
83
- def explain_code(problem_name, code, optimal_time, optimal_space):
84
- api_key = get_secret("GEMINI_API_KEY")
85
  if not api_key:
86
- return {"error": "GEMINI_API_KEY not found in secrets"}
87
 
88
  client = genai.Client(api_key=api_key)
89
 
@@ -119,10 +119,10 @@ def explain_code(problem_name, code, optimal_time, optimal_space):
119
  except Exception as e:
120
  return {"error": str(e)}
121
 
122
- def generate_daily_python_challenge():
123
- api_key = get_secret("GEMINI_API_KEY")
124
  if not api_key:
125
- return {"error": "GEMINI_API_KEY not found in secrets"}
126
 
127
  client = genai.Client(api_key=api_key)
128
 
@@ -173,10 +173,10 @@ def generate_daily_python_challenge():
173
  except Exception as e:
174
  return {"error": str(e)}
175
 
176
- def generate_sql_challenge():
177
- api_key = get_secret("GEMINI_API_KEY")
178
  if not api_key:
179
- return {"error": "GEMINI_API_KEY not found in secrets"}
180
 
181
  client = genai.Client(api_key=api_key)
182
 
 
40
 
41
  raise last_err if last_err else Exception("No Gemini models succeeded")
42
 
43
+ def evaluate_code(problem_name, pattern_name, expected_time, expected_space, candidate_code, custom_api_key=None):
44
+ api_key = custom_api_key or get_secret("GEMINI_API_KEY")
45
  if not api_key:
46
+ return {"error": "GEMINI_API_KEY not found in secrets. Please configure your API key in the System Control Panel."}
47
 
48
  client = genai.Client(api_key=api_key)
49
 
 
80
  except Exception as e:
81
  return {"error": str(e)}
82
 
83
+ def explain_code(problem_name, code, optimal_time, optimal_space, custom_api_key=None):
84
+ api_key = custom_api_key or get_secret("GEMINI_API_KEY")
85
  if not api_key:
86
+ return {"error": "GEMINI_API_KEY not found in secrets. Please configure your API key in the System Control Panel."}
87
 
88
  client = genai.Client(api_key=api_key)
89
 
 
119
  except Exception as e:
120
  return {"error": str(e)}
121
 
122
+ def generate_daily_python_challenge(custom_api_key=None):
123
+ api_key = custom_api_key or get_secret("GEMINI_API_KEY")
124
  if not api_key:
125
+ return {"error": "GEMINI_API_KEY not found in secrets. Please configure your API key in the System Control Panel."}
126
 
127
  client = genai.Client(api_key=api_key)
128
 
 
173
  except Exception as e:
174
  return {"error": str(e)}
175
 
176
+ def generate_sql_challenge(custom_api_key=None):
177
+ api_key = custom_api_key or get_secret("GEMINI_API_KEY")
178
  if not api_key:
179
+ return {"error": "GEMINI_API_KEY not found in secrets. Please configure your API key in the System Control Panel."}
180
 
181
  client = genai.Client(api_key=api_key)
182
 
main.py CHANGED
@@ -103,6 +103,10 @@ def get_supabase_client(authorization: str = Header(None)) -> Client:
103
  raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
104
  access_token = authorization.split(" ")[1]
105
 
 
 
 
 
106
  try:
107
  # Initialize a Supabase client authenticated as this specific user
108
  client = create_client(
@@ -200,13 +204,18 @@ class EvaluateCodeRequest(BaseModel):
200
  code: str
201
 
202
  @app.post("/api/reviews/evaluate")
203
- def evaluate_code(req: EvaluateCodeRequest, client: Client = Depends(get_supabase_client)):
 
 
 
 
204
  eval_res = llm_reviewer.evaluate_code(
205
  req.problem_name,
206
  req.pattern,
207
  req.optimal_time,
208
  req.optimal_space,
209
- req.code
 
210
  )
211
  if "error" in eval_res:
212
  raise HTTPException(status_code=500, detail=eval_res["error"])
@@ -217,7 +226,11 @@ class ExplainCodeRequest(BaseModel):
217
  method: str
218
 
219
  @app.post("/api/reviews/explain")
220
- def explain_review_code(req: ExplainCodeRequest, client: Client = Depends(get_supabase_client)):
 
 
 
 
221
  try:
222
  user_id = db.get_current_user_id(client)
223
  if not user_id:
@@ -250,7 +263,8 @@ def explain_review_code(req: ExplainCodeRequest, client: Client = Depends(get_su
250
  problem_name=problem_name,
251
  code=reference_code,
252
  optimal_time=optimal_time,
253
- optimal_space=optimal_space
 
254
  )
255
 
256
  if "error" in eval_res:
@@ -282,6 +296,9 @@ def get_journey_titles(client: Client = Depends(get_supabase_client)):
282
 
283
  @app.post("/api/sync")
284
  def trigger_sync(client: Client = Depends(get_supabase_client)):
 
 
 
285
  try:
286
  res = gdrive_sync.sync_gdrive(client)
287
  return {"message": res}
@@ -295,11 +312,14 @@ class SubmitPythonRequest(BaseModel):
295
  code: str
296
 
297
  @app.get("/api/challenges/python/daily")
298
- def get_daily_python_challenge(client: Client = Depends(get_supabase_client)):
 
 
 
299
  today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
300
 
301
  if today_str not in DAILY_PYTHON_CHALLENGE_CACHE:
302
- challenge = llm_reviewer.generate_daily_python_challenge()
303
  if "error" in challenge:
304
  raise HTTPException(status_code=500, detail=f"Failed to generate challenge: {challenge['error']}")
305
  DAILY_PYTHON_CHALLENGE_CACHE[today_str] = challenge
@@ -403,13 +423,16 @@ class SQLQueryRequest(BaseModel):
403
  query: str
404
 
405
  @app.post("/api/challenges/mysql/init")
406
- def init_mysql_challenge(client: Client = Depends(get_supabase_client)):
 
 
 
407
  import os
408
  user_id = db.get_current_user_id(client)
409
  if not user_id:
410
  raise HTTPException(status_code=401, detail="Authentication failed")
411
 
412
- challenge = llm_reviewer.generate_sql_challenge()
413
  if "error" in challenge:
414
  raise HTTPException(status_code=500, detail=f"Failed to generate SQL challenge: {challenge['error']}")
415
 
@@ -557,13 +580,9 @@ class UpdateProblemRequest(BaseModel):
557
 
558
  @app.get("/api/explorer/problems")
559
  def get_explorer_problems(client: Client = Depends(get_supabase_client)):
560
- user_id = db.get_current_user_id(client)
561
- if not user_id:
562
- raise HTTPException(status_code=401, detail="Authentication failed")
563
  try:
564
  res = client.table('problems')\
565
  .select('id, name, pattern, difficulty, reference_code, description, user_reviews(box_level, next_review, last_reviewed, times_correct, total_attempts)')\
566
- .eq('user_id', user_id)\
567
  .execute()
568
 
569
  problems = res.data if res.data else []
 
103
  raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
104
  access_token = authorization.split(" ")[1]
105
 
106
+ if access_token == "sandbox":
107
+ import sandbox_db
108
+ return sandbox_db.get_sandbox_client()
109
+
110
  try:
111
  # Initialize a Supabase client authenticated as this specific user
112
  client = create_client(
 
204
  code: str
205
 
206
  @app.post("/api/reviews/evaluate")
207
+ def evaluate_code(
208
+ req: EvaluateCodeRequest,
209
+ client: Client = Depends(get_supabase_client),
210
+ x_gemini_api_key: str = Header(None)
211
+ ):
212
  eval_res = llm_reviewer.evaluate_code(
213
  req.problem_name,
214
  req.pattern,
215
  req.optimal_time,
216
  req.optimal_space,
217
+ req.code,
218
+ custom_api_key=x_gemini_api_key
219
  )
220
  if "error" in eval_res:
221
  raise HTTPException(status_code=500, detail=eval_res["error"])
 
226
  method: str
227
 
228
  @app.post("/api/reviews/explain")
229
+ def explain_review_code(
230
+ req: ExplainCodeRequest,
231
+ client: Client = Depends(get_supabase_client),
232
+ x_gemini_api_key: str = Header(None)
233
+ ):
234
  try:
235
  user_id = db.get_current_user_id(client)
236
  if not user_id:
 
263
  problem_name=problem_name,
264
  code=reference_code,
265
  optimal_time=optimal_time,
266
+ optimal_space=optimal_space,
267
+ custom_api_key=x_gemini_api_key
268
  )
269
 
270
  if "error" in eval_res:
 
296
 
297
  @app.post("/api/sync")
298
  def trigger_sync(client: Client = Depends(get_supabase_client)):
299
+ user_id = db.get_current_user_id(client)
300
+ if user_id == "sandbox-user-id":
301
+ return {"message": "Google Drive Sync is not available in Sandbox Mode. Please log in to sync your notes."}
302
  try:
303
  res = gdrive_sync.sync_gdrive(client)
304
  return {"message": res}
 
312
  code: str
313
 
314
  @app.get("/api/challenges/python/daily")
315
+ def get_daily_python_challenge(
316
+ client: Client = Depends(get_supabase_client),
317
+ x_gemini_api_key: str = Header(None)
318
+ ):
319
  today_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
320
 
321
  if today_str not in DAILY_PYTHON_CHALLENGE_CACHE:
322
+ challenge = llm_reviewer.generate_daily_python_challenge(custom_api_key=x_gemini_api_key)
323
  if "error" in challenge:
324
  raise HTTPException(status_code=500, detail=f"Failed to generate challenge: {challenge['error']}")
325
  DAILY_PYTHON_CHALLENGE_CACHE[today_str] = challenge
 
423
  query: str
424
 
425
  @app.post("/api/challenges/mysql/init")
426
+ def init_mysql_challenge(
427
+ client: Client = Depends(get_supabase_client),
428
+ x_gemini_api_key: str = Header(None)
429
+ ):
430
  import os
431
  user_id = db.get_current_user_id(client)
432
  if not user_id:
433
  raise HTTPException(status_code=401, detail="Authentication failed")
434
 
435
+ challenge = llm_reviewer.generate_sql_challenge(custom_api_key=x_gemini_api_key)
436
  if "error" in challenge:
437
  raise HTTPException(status_code=500, detail=f"Failed to generate SQL challenge: {challenge['error']}")
438
 
 
580
 
581
  @app.get("/api/explorer/problems")
582
  def get_explorer_problems(client: Client = Depends(get_supabase_client)):
 
 
 
583
  try:
584
  res = client.table('problems')\
585
  .select('id, name, pattern, difficulty, reference_code, description, user_reviews(box_level, next_review, last_reviewed, times_correct, total_attempts)')\
 
586
  .execute()
587
 
588
  problems = res.data if res.data else []
sandbox_db.py ADDED
@@ -0,0 +1,511 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import uuid
3
+ import os
4
+ from datetime import datetime, timezone
5
+
6
+ DB_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scratch")
7
+ DB_PATH = os.path.join(DB_FOLDER, "sandbox.db")
8
+
9
+ def init_db():
10
+ os.makedirs(DB_FOLDER, exist_ok=True)
11
+ conn = sqlite3.connect(DB_PATH)
12
+ cursor = conn.cursor()
13
+
14
+ # Enable foreign keys
15
+ cursor.execute("PRAGMA foreign_keys = ON;")
16
+
17
+ # 1. Problems table
18
+ cursor.execute("""
19
+ CREATE TABLE IF NOT EXISTS problems (
20
+ id TEXT PRIMARY KEY,
21
+ user_id TEXT NOT NULL,
22
+ name TEXT NOT NULL,
23
+ problem_number INTEGER,
24
+ pattern TEXT NOT NULL,
25
+ difficulty TEXT NOT NULL,
26
+ google_doc_url TEXT,
27
+ google_doc_id TEXT,
28
+ optimal_time TEXT DEFAULT 'O(N)',
29
+ optimal_space TEXT DEFAULT 'O(N)',
30
+ description TEXT,
31
+ reference_code TEXT,
32
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
33
+ UNIQUE(user_id, name)
34
+ );
35
+ """)
36
+
37
+ # 2. User Reviews table
38
+ cursor.execute("""
39
+ CREATE TABLE IF NOT EXISTS user_reviews (
40
+ id TEXT PRIMARY KEY,
41
+ user_id TEXT NOT NULL,
42
+ problem_id TEXT NOT NULL,
43
+ box_level INTEGER DEFAULT 1,
44
+ last_reviewed TEXT DEFAULT CURRENT_TIMESTAMP,
45
+ next_review TEXT DEFAULT CURRENT_TIMESTAMP,
46
+ times_correct INTEGER DEFAULT 0,
47
+ total_attempts INTEGER DEFAULT 0,
48
+ UNIQUE(user_id, problem_id),
49
+ FOREIGN KEY(problem_id) REFERENCES problems(id) ON DELETE CASCADE
50
+ );
51
+ """)
52
+
53
+ # 3. User Streaks table
54
+ cursor.execute("""
55
+ CREATE TABLE IF NOT EXISTS user_streaks (
56
+ user_id TEXT PRIMARY KEY,
57
+ current_streak INTEGER DEFAULT 0,
58
+ longest_streak INTEGER DEFAULT 0,
59
+ last_active_date TEXT,
60
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
61
+ );
62
+ """)
63
+
64
+ # 4. User Journey Progress table
65
+ cursor.execute("""
66
+ CREATE TABLE IF NOT EXISTS user_journey_progress (
67
+ id TEXT PRIMARY KEY,
68
+ user_id TEXT NOT NULL,
69
+ problem_number INTEGER NOT NULL,
70
+ viewed_at TEXT DEFAULT CURRENT_TIMESTAMP,
71
+ UNIQUE(user_id, problem_number)
72
+ );
73
+ """)
74
+
75
+ # 5. User Scores table
76
+ cursor.execute("""
77
+ CREATE TABLE IF NOT EXISTS user_scores (
78
+ user_id TEXT PRIMARY KEY,
79
+ total_score INTEGER DEFAULT 0,
80
+ python_challenges_completed INTEGER DEFAULT 0,
81
+ sql_challenges_completed INTEGER DEFAULT 0,
82
+ updated_at TEXT DEFAULT CURRENT_TIMESTAMP
83
+ );
84
+ """)
85
+
86
+ # 6. Challenge History table
87
+ cursor.execute("""
88
+ CREATE TABLE IF NOT EXISTS challenge_history (
89
+ id TEXT PRIMARY KEY,
90
+ user_id TEXT NOT NULL,
91
+ challenge_type TEXT NOT NULL,
92
+ challenge_title TEXT NOT NULL,
93
+ points_awarded INTEGER DEFAULT 0,
94
+ completed_at TEXT DEFAULT CURRENT_TIMESTAMP
95
+ );
96
+ """)
97
+
98
+ conn.commit()
99
+
100
+ # Seed default problems and reviews if empty
101
+ cursor.execute("SELECT COUNT(*) FROM problems WHERE user_id = 'sandbox-user-id'")
102
+ if cursor.fetchone()[0] == 0:
103
+ seed_default_problems(cursor)
104
+ conn.commit()
105
+
106
+ conn.close()
107
+
108
+ def seed_default_problems(cursor):
109
+ default_problems = [
110
+ {
111
+ "id": "prob-contains-duplicate",
112
+ "user_id": "sandbox-user-id",
113
+ "name": "Contains Duplicate",
114
+ "problem_number": 217,
115
+ "pattern": "Arrays & Hashing",
116
+ "difficulty": "Easy",
117
+ "google_doc_url": "https://leetcode.com/problems/contains-duplicate/",
118
+ "google_doc_id": "doc-contains-duplicate",
119
+ "optimal_time": "O(N)",
120
+ "optimal_space": "O(N)",
121
+ "description": "Given an integer array nums, return true if any value appears at least twice in the array, and return false if every element is distinct.",
122
+ "reference_code": "def solve(nums: list[int]) -> bool:\n # Return True if any value appears at least twice\n return len(nums) != len(set(nums))"
123
+ },
124
+ {
125
+ "id": "prob-valid-anagram",
126
+ "user_id": "sandbox-user-id",
127
+ "name": "Valid Anagram",
128
+ "problem_number": 242,
129
+ "pattern": "Arrays & Hashing",
130
+ "difficulty": "Easy",
131
+ "google_doc_url": "https://leetcode.com/problems/valid-anagram/",
132
+ "google_doc_id": "doc-valid-anagram",
133
+ "optimal_time": "O(N)",
134
+ "optimal_space": "O(1)",
135
+ "description": "Given two strings s and t, return true if t is an anagram of s, and false otherwise.",
136
+ "reference_code": "def solve(s: str, t: str) -> bool:\n # Return True if t is an anagram of s\n if len(s) != len(t):\n return False\n return sorted(s) == sorted(t)"
137
+ },
138
+ {
139
+ "id": "prob-two-sum",
140
+ "user_id": "sandbox-user-id",
141
+ "name": "Two Sum",
142
+ "problem_number": 1,
143
+ "pattern": "Arrays & Hashing",
144
+ "difficulty": "Easy",
145
+ "google_doc_url": "https://leetcode.com/problems/two-sum/",
146
+ "google_doc_id": "doc-two-sum",
147
+ "optimal_time": "O(N)",
148
+ "optimal_space": "O(N)",
149
+ "description": "Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.",
150
+ "reference_code": "def solve(nums: list[int], target: int) -> list[int]:\n # Return indices of the two numbers that add up to target\n seen = {}\n for i, n in enumerate(nums):\n diff = target - n\n if diff in seen:\n return [seen[diff], i]\n seen[n] = i\n return []"
151
+ },
152
+ {
153
+ "id": "prob-group-anagrams",
154
+ "user_id": "sandbox-user-id",
155
+ "name": "Group Anagrams",
156
+ "problem_number": 49,
157
+ "pattern": "Arrays & Hashing",
158
+ "difficulty": "Medium",
159
+ "google_doc_url": "https://leetcode.com/problems/group-anagrams/",
160
+ "google_doc_id": "doc-group-anagrams",
161
+ "optimal_time": "O(N * K log K)",
162
+ "optimal_space": "O(N * K)",
163
+ "description": "Given an array of strings strs, group the anagrams together. You can return the answer in any order.",
164
+ "reference_code": "def solve(strs: list[str]) -> list[list[str]]:\n # Group anagrams together\n ans = {}\n for s in strs:\n key = ''.join(sorted(s))\n if key not in ans:\n ans[key] = []\n ans[key].append(s)\n return list(ans.values())"
165
+ },
166
+ {
167
+ "id": "prob-valid-parentheses",
168
+ "user_id": "sandbox-user-id",
169
+ "name": "Valid Parentheses",
170
+ "problem_number": 20,
171
+ "pattern": "Stack",
172
+ "difficulty": "Easy",
173
+ "google_doc_url": "https://leetcode.com/problems/valid-parentheses/",
174
+ "google_doc_id": "doc-valid-parentheses",
175
+ "optimal_time": "O(N)",
176
+ "optimal_space": "O(N)",
177
+ "description": "Given a string s containing just the characters '(', ')', '{', '}', '[' and ']', determine if the input string is valid.",
178
+ "reference_code": "def solve(s: str) -> bool:\n # Determine if the parentheses input string is valid\n stack = []\n mapping = {')': '(', '}': '{', ']': '['}\n for char in s:\n if char in mapping:\n top_element = stack.pop() if stack else '#'\n if mapping[char] != top_element:\n return False\n else:\n stack.append(char)\n return not stack"
179
+ }
180
+ ]
181
+
182
+ for p in default_problems:
183
+ columns = list(p.keys())
184
+ placeholders = ", ".join(["?"] * len(columns))
185
+ query = f"INSERT OR IGNORE INTO problems ({', '.join(columns)}) VALUES ({placeholders})"
186
+ cursor.execute(query, list(p.values()))
187
+
188
+ now_iso = datetime.now(timezone.utc).isoformat()
189
+ default_reviews = [
190
+ {
191
+ "id": "rev-contains-duplicate",
192
+ "user_id": "sandbox-user-id",
193
+ "problem_id": "prob-contains-duplicate",
194
+ "box_level": 1,
195
+ "last_reviewed": now_iso,
196
+ "next_review": now_iso,
197
+ "times_correct": 0,
198
+ "total_attempts": 0
199
+ },
200
+ {
201
+ "id": "rev-valid-anagram",
202
+ "user_id": "sandbox-user-id",
203
+ "problem_id": "prob-valid-anagram",
204
+ "box_level": 1,
205
+ "last_reviewed": now_iso,
206
+ "next_review": now_iso,
207
+ "times_correct": 0,
208
+ "total_attempts": 0
209
+ },
210
+ {
211
+ "id": "rev-two-sum",
212
+ "user_id": "sandbox-user-id",
213
+ "problem_id": "prob-two-sum",
214
+ "box_level": 1,
215
+ "last_reviewed": now_iso,
216
+ "next_review": now_iso,
217
+ "times_correct": 0,
218
+ "total_attempts": 0
219
+ }
220
+ ]
221
+
222
+ for r in default_reviews:
223
+ cols = list(r.keys())
224
+ places = ", ".join(["?"] * len(cols))
225
+ query = f"INSERT OR IGNORE INTO user_reviews ({', '.join(cols)}) VALUES ({places})"
226
+ cursor.execute(query, list(r.values()))
227
+
228
+ class MockResponse:
229
+ def __init__(self, data):
230
+ self.data = data
231
+
232
+ class QueryBuilder:
233
+ def __init__(self, table_name, real_client=None):
234
+ self.table_name = table_name
235
+ self.real_client = real_client
236
+ self.filters = []
237
+ self.order_by = []
238
+ self.limit_val = None
239
+ self.op = None
240
+ self.op_data = None
241
+ self.on_conflict = None
242
+
243
+ def select(self, columns="*"):
244
+ self.op = 'select'
245
+ self.op_data = columns
246
+ return self
247
+
248
+ def insert(self, data):
249
+ self.op = 'insert'
250
+ self.op_data = data
251
+ return self
252
+
253
+ def update(self, data):
254
+ self.op = 'update'
255
+ self.op_data = data
256
+ return self
257
+
258
+ def upsert(self, data, on_conflict=None):
259
+ self.op = 'upsert'
260
+ self.op_data = data
261
+ self.on_conflict = on_conflict
262
+ return self
263
+
264
+ def delete(self):
265
+ self.op = 'delete'
266
+ return self
267
+
268
+ def eq(self, column, value):
269
+ self.filters.append(('eq', column, value))
270
+ return self
271
+
272
+ def lte(self, column, value):
273
+ self.filters.append(('lte', column, value))
274
+ return self
275
+
276
+ def order(self, column, desc=False):
277
+ self.order_by.append((column, desc))
278
+ return self
279
+
280
+ def limit(self, count):
281
+ self.limit_val = count
282
+ return self
283
+
284
+ def execute(self):
285
+ conn = sqlite3.connect(DB_PATH)
286
+ conn.row_factory = sqlite3.Row
287
+ cursor = conn.cursor()
288
+
289
+ try:
290
+ if self.op == 'select':
291
+ # Build SELECT query
292
+ # If table is 'problems' and real client is online, try fetching from Supabase first
293
+ if self.table_name == 'problems' and self.real_client:
294
+ try:
295
+ # Build public Supabase query
296
+ supabase_query = self.real_client.table('problems').select('*')
297
+ for op, col, val in self.filters:
298
+ if op == 'eq':
299
+ supabase_query = supabase_query.eq(col, val)
300
+ elif op == 'lte':
301
+ supabase_query = supabase_query.lte(col, val)
302
+ for col, desc in self.order_by:
303
+ supabase_query = supabase_query.order(col, desc=desc)
304
+ if self.limit_val is not None:
305
+ supabase_query = supabase_query.limit(self.limit_val)
306
+
307
+ res = supabase_query.execute()
308
+ data = res.data if res.data else []
309
+
310
+ # Post-process for nested table relations problems -> user_reviews
311
+ if self.op_data and 'user_reviews(' in self.op_data:
312
+ for row in data:
313
+ prob_id = row.get('id')
314
+ if prob_id:
315
+ cursor.execute("SELECT * FROM user_reviews WHERE problem_id = ? AND user_id = 'sandbox-user-id'", (prob_id,))
316
+ rev_rows = cursor.fetchall()
317
+ row['user_reviews'] = [dict(r) for r in rev_rows]
318
+ return MockResponse(data)
319
+ except Exception as e:
320
+ # Fallback silently to SQLite
321
+ pass
322
+
323
+ query = f"SELECT * FROM {self.table_name}"
324
+ params = []
325
+ where_clauses = []
326
+ for op, col, val in self.filters:
327
+ if op == 'eq':
328
+ where_clauses.append(f"{col} = ?")
329
+ params.append(val)
330
+ elif op == 'lte':
331
+ where_clauses.append(f"{col} <= ?")
332
+ params.append(val)
333
+
334
+ if where_clauses:
335
+ query += " WHERE " + " AND ".join(where_clauses)
336
+
337
+ # Order by
338
+ if self.order_by:
339
+ order_clauses = []
340
+ for col, desc in self.order_by:
341
+ direction = "DESC" if desc else "ASC"
342
+ order_clauses.append(f"{col} {direction}")
343
+ query += " ORDER BY " + ", ".join(order_clauses)
344
+
345
+ # Limit
346
+ if self.limit_val is not None:
347
+ query += f" LIMIT {self.limit_val}"
348
+
349
+ cursor.execute(query, params)
350
+ rows = cursor.fetchall()
351
+ data = [dict(row) for row in rows]
352
+
353
+ # Post-process for nested table relations
354
+ # 1. user_reviews -> problems
355
+ if self.table_name == 'user_reviews' and self.op_data and 'problems(*)' in self.op_data:
356
+ for row in data:
357
+ prob_id = row.get('problem_id')
358
+ if prob_id:
359
+ cursor.execute("SELECT * FROM problems WHERE id = ?", (prob_id,))
360
+ prob_row = cursor.fetchone()
361
+ if prob_row:
362
+ row['problems'] = dict(prob_row)
363
+ else:
364
+ # Fallback to real Supabase client for public problems
365
+ if self.real_client:
366
+ try:
367
+ res_p = self.real_client.table('problems').select('*').eq('id', prob_id).execute()
368
+ row['problems'] = res_p.data[0] if res_p.data else None
369
+ except Exception:
370
+ row['problems'] = None
371
+ else:
372
+ row['problems'] = None
373
+
374
+ # 2. problems -> user_reviews
375
+ elif self.table_name == 'problems' and self.op_data and 'user_reviews(' in self.op_data:
376
+ for row in data:
377
+ prob_id = row.get('id')
378
+ if prob_id:
379
+ cursor.execute("SELECT * FROM user_reviews WHERE problem_id = ? AND user_id = 'sandbox-user-id'", (prob_id,))
380
+ rev_rows = cursor.fetchall()
381
+ row['user_reviews'] = [dict(r) for r in rev_rows]
382
+
383
+ return MockResponse(data)
384
+
385
+ elif self.op == 'insert':
386
+ data_list = self.op_data if isinstance(self.op_data, list) else [self.op_data]
387
+ inserted_rows = []
388
+
389
+ for row_data in data_list:
390
+ # Auto generate UUID if not exists or None
391
+ if 'id' in row_data and row_data['id'] is None:
392
+ row_data['id'] = str(uuid.uuid4())
393
+ elif self.table_name in ('problems', 'user_reviews', 'user_journey_progress', 'challenge_history') and 'id' not in row_data:
394
+ row_data['id'] = str(uuid.uuid4())
395
+
396
+ columns = list(row_data.keys())
397
+ placeholders = ", ".join(["?"] * len(columns))
398
+ query = f"INSERT INTO {self.table_name} ({', '.join(columns)}) VALUES ({placeholders})"
399
+ cursor.execute(query, list(row_data.values()))
400
+ inserted_rows.append(row_data)
401
+
402
+ conn.commit()
403
+ return MockResponse(inserted_rows)
404
+
405
+ elif self.op == 'update':
406
+ columns = list(self.op_data.keys())
407
+ set_clause = ", ".join([f"{col} = ?" for col in columns])
408
+ params = list(self.op_data.values())
409
+
410
+ query = f"UPDATE {self.table_name} SET {set_clause}"
411
+
412
+ where_clauses = []
413
+ where_params = []
414
+ for op, col, val in self.filters:
415
+ if op == 'eq':
416
+ where_clauses.append(f"{col} = ?")
417
+ where_params.append(val)
418
+ elif op == 'lte':
419
+ where_clauses.append(f"{col} <= ?")
420
+ where_params.append(val)
421
+
422
+ if where_clauses:
423
+ query += " WHERE " + " AND ".join(where_clauses)
424
+
425
+ cursor.execute(query, params + where_params)
426
+ conn.commit()
427
+
428
+ # Fetch and return updated rows
429
+ select_query = f"SELECT * FROM {self.table_name}"
430
+ if where_clauses:
431
+ select_query += " WHERE " + " AND ".join(where_clauses)
432
+ cursor.execute(select_query, where_params)
433
+ rows = cursor.fetchall()
434
+ return MockResponse([dict(row) for row in rows])
435
+
436
+ elif self.op == 'upsert':
437
+ row_data = self.op_data if not isinstance(self.op_data, list) else self.op_data[0]
438
+ if self.table_name == 'user_journey_progress':
439
+ if 'id' not in row_data:
440
+ row_data['id'] = str(uuid.uuid4())
441
+ columns = list(row_data.keys())
442
+ placeholders = ", ".join(["?"] * len(columns))
443
+ query = f"""
444
+ INSERT INTO user_journey_progress ({', '.join(columns)})
445
+ VALUES ({placeholders})
446
+ ON CONFLICT(user_id, problem_number)
447
+ DO UPDATE SET viewed_at = excluded.viewed_at
448
+ """
449
+ cursor.execute(query, list(row_data.values()))
450
+ else:
451
+ columns = list(row_data.keys())
452
+ placeholders = ", ".join(["?"] * len(columns))
453
+ query = f"INSERT OR REPLACE INTO {self.table_name} ({', '.join(columns)}) VALUES ({placeholders})"
454
+ cursor.execute(query, list(row_data.values()))
455
+
456
+ conn.commit()
457
+ return MockResponse([row_data])
458
+
459
+ elif self.op == 'delete':
460
+ query = f"DELETE FROM {self.table_name}"
461
+ params = []
462
+ where_clauses = []
463
+ for op, col, val in self.filters:
464
+ if op == 'eq':
465
+ where_clauses.append(f"{col} = ?")
466
+ params.append(val)
467
+
468
+ if where_clauses:
469
+ query += " WHERE " + " AND ".join(where_clauses)
470
+
471
+ cursor.execute(query, params)
472
+ conn.commit()
473
+ return MockResponse([])
474
+ finally:
475
+ conn.close()
476
+
477
+ class MockSupabaseClient:
478
+ def __init__(self):
479
+ self.current_user_id = 'sandbox-user-id'
480
+ # Ensure database and tables exist
481
+ init_db()
482
+
483
+ # Initialize a real Supabase client for reading public data
484
+ from supabase import create_client
485
+ import os
486
+ import toml
487
+
488
+ url = os.environ.get("SUPABASE_URL")
489
+ key = os.environ.get("SUPABASE_KEY")
490
+ if not url or not key:
491
+ try:
492
+ secrets_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".streamlit", "secrets.toml")
493
+ if os.path.exists(secrets_path):
494
+ secrets = toml.load(secrets_path)
495
+ url = url or secrets.get("SUPABASE_URL")
496
+ key = key or secrets.get("SUPABASE_KEY")
497
+ except Exception:
498
+ pass
499
+
500
+ self.real_client = None
501
+ if url and key:
502
+ try:
503
+ self.real_client = create_client(url, key)
504
+ except Exception as e:
505
+ print(f"Error creating real client in sandbox: {e}")
506
+
507
+ def table(self, table_name):
508
+ return QueryBuilder(table_name, real_client=self.real_client)
509
+
510
+ def get_sandbox_client():
511
+ return MockSupabaseClient()
schema.sql CHANGED
@@ -52,7 +52,7 @@ ALTER TABLE user_reviews ENABLE ROW LEVEL SECURITY;
52
  ALTER TABLE user_streaks ENABLE ROW LEVEL SECURITY;
53
 
54
  -- RLS Policies for problems
55
- CREATE POLICY select_problems ON problems FOR SELECT TO authenticated USING (auth.uid() = user_id);
56
  CREATE POLICY insert_problems ON problems FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
57
  CREATE POLICY update_problems ON problems FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
58
  CREATE POLICY delete_problems ON problems FOR DELETE TO authenticated USING (auth.uid() = user_id);
 
52
  ALTER TABLE user_streaks ENABLE ROW LEVEL SECURITY;
53
 
54
  -- RLS Policies for problems
55
+ CREATE POLICY select_problems ON problems FOR SELECT USING (true);
56
  CREATE POLICY insert_problems ON problems FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
57
  CREATE POLICY update_problems ON problems FOR UPDATE TO authenticated USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);
58
  CREATE POLICY delete_problems ON problems FOR DELETE TO authenticated USING (auth.uid() = user_id);