Spaces:
Running
Running
Update apps/aura_measure.js
Browse files- apps/aura_measure.js +336 -81
apps/aura_measure.js
CHANGED
|
@@ -8,9 +8,10 @@ import { supabase } from '../config/supabaseClient.js';
|
|
| 8 |
const router = express.Router();
|
| 9 |
|
| 10 |
const TEMP_DIR = path.join(process.cwd(), 'tmp_scans');
|
| 11 |
-
const tempScans = new Map();
|
| 12 |
fs.mkdir(TEMP_DIR, { recursive: true }).catch(console.error);
|
| 13 |
|
|
|
|
| 14 |
setInterval(async () => {
|
| 15 |
const now = Date.now();
|
| 16 |
for (const [tempId, data] of tempScans.entries()) {
|
|
@@ -22,20 +23,23 @@ setInterval(async () => {
|
|
| 22 |
}, 5 * 60 * 1000);
|
| 23 |
|
| 24 |
const cache = {
|
| 25 |
-
feed_page_1:
|
| 26 |
-
leaderboard_all:
|
| 27 |
-
leaderboard_session:
|
| 28 |
-
leaderboard_grade:
|
| 29 |
-
challenges_active:
|
| 30 |
};
|
| 31 |
-
const CACHE_TTL = 60 * 1000;
|
| 32 |
|
| 33 |
function invalidateCaches(type) {
|
| 34 |
-
if (type === 'feed')
|
|
|
|
|
|
|
|
|
|
| 35 |
if (type === 'leaderboard') {
|
| 36 |
-
cache.leaderboard_all.expires = 0;
|
| 37 |
-
cache.leaderboard_session.expires = 0;
|
| 38 |
-
cache.leaderboard_grade.expires = 0;
|
| 39 |
}
|
| 40 |
}
|
| 41 |
|
|
@@ -68,28 +72,55 @@ Image 1: The Reference Image
|
|
| 68 |
Image 2: The Submission
|
| 69 |
Evaluate Image 2 strictly based on how well it matched the vibe and quality of Image 1. ${BASE_RULES}`;
|
| 70 |
|
|
|
|
|
|
|
| 71 |
router.post('/analyze', async (req, res) => {
|
| 72 |
try {
|
| 73 |
-
const {
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
|
|
|
| 77 |
.from('aura_profiles')
|
| 78 |
.upsert({ device_id, username, avatar }, { onConflict: 'device_id' })
|
| 79 |
-
.select('id')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
-
// FIX: Used maybeSingle() instead of single() to prevent PGRST116 crashes
|
| 82 |
-
let { data: session } = await supabase.from('aura_sessions').select('id').eq('is_active', true).maybeSingle();
|
| 83 |
if (!session) {
|
| 84 |
const start = new Date();
|
| 85 |
const end = new Date(start.getTime() + (3 * 24 * 60 * 60 * 1000));
|
| 86 |
-
const { data: newSession } = await supabase
|
| 87 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
session = newSession;
|
| 89 |
}
|
| 90 |
|
|
|
|
| 91 |
let systemPrompt = AURA_PROMPT_STANDALONE;
|
| 92 |
-
let promptText =
|
| 93 |
let imagesToProcess = [media_base64];
|
| 94 |
|
| 95 |
if ((context_type === 'challenge' || context_type === 'thread') && reference_data) {
|
|
@@ -99,186 +130,410 @@ router.post('/analyze', async (req, res) => {
|
|
| 99 |
}
|
| 100 |
|
| 101 |
const aiResult = await generateCompletion({
|
| 102 |
-
model:
|
| 103 |
prompt: promptText,
|
| 104 |
system_prompt: systemPrompt,
|
| 105 |
-
images: imagesToProcess
|
| 106 |
});
|
| 107 |
|
| 108 |
if (!aiResult.success) throw new Error(aiResult.error);
|
| 109 |
-
const cleanJsonStr = aiResult.data.replace(/```json/gi, '').replace(/```/gi, '').trim();
|
| 110 |
-
const parsedData = JSON.parse(cleanJsonStr);
|
| 111 |
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
});
|
|
|
|
| 115 |
|
|
|
|
| 116 |
const tempId = crypto.randomUUID();
|
| 117 |
const filepath = path.join(TEMP_DIR, `${tempId}.jpg`);
|
| 118 |
await fs.writeFile(filepath, Buffer.from(media_base64, 'base64'));
|
| 119 |
|
| 120 |
tempScans.set(tempId, {
|
| 121 |
-
filepath,
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
});
|
| 124 |
|
| 125 |
invalidateCaches('leaderboard');
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
} catch (err) {
|
|
|
|
| 128 |
res.status(500).json({ success: false, error: err.message });
|
| 129 |
}
|
| 130 |
});
|
| 131 |
|
|
|
|
|
|
|
| 132 |
router.post('/publish', async (req, res) => {
|
| 133 |
try {
|
| 134 |
const { temp_scan_id } = req.body;
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
const scanData = tempScans.get(temp_scan_id);
|
| 138 |
const fileName = `${scanData.user_id}/${Date.now()}_${temp_scan_id}.jpg`;
|
| 139 |
const fileBuffer = await fs.readFile(scanData.filepath);
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
await fs.unlink(scanData.filepath);
|
| 150 |
tempScans.delete(temp_scan_id);
|
| 151 |
invalidateCaches('feed');
|
| 152 |
-
|
|
|
|
|
|
|
| 153 |
} catch (err) {
|
|
|
|
| 154 |
res.status(500).json({ success: false, error: err.message });
|
| 155 |
}
|
| 156 |
});
|
| 157 |
|
|
|
|
|
|
|
| 158 |
router.get('/feed', async (req, res) => {
|
| 159 |
try {
|
| 160 |
-
const { cursor, limit = 20 } = req.query;
|
|
|
|
|
|
|
|
|
|
| 161 |
if (!cursor && cache.feed_page_1.expires > Date.now() && cache.feed_page_1.data) {
|
| 162 |
return res.json({ success: true, data: cache.feed_page_1.data, cached: true });
|
| 163 |
}
|
| 164 |
|
| 165 |
-
let query = supabase
|
| 166 |
-
.
|
| 167 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
-
if (cursor) query = query.lt('created_at', cursor);
|
| 170 |
const { data, error } = await query;
|
| 171 |
if (error) throw error;
|
| 172 |
|
| 173 |
-
const nextCursor = data.length ===
|
|
|
|
|
|
|
|
|
|
| 174 |
const responseData = { items: data, nextCursor };
|
| 175 |
|
| 176 |
-
if (!cursor)
|
|
|
|
|
|
|
|
|
|
| 177 |
res.json({ success: true, data: responseData });
|
|
|
|
| 178 |
} catch (err) {
|
|
|
|
| 179 |
res.status(500).json({ success: false, error: err.message });
|
| 180 |
}
|
| 181 |
});
|
| 182 |
|
|
|
|
|
|
|
| 183 |
router.get('/leaderboard', async (req, res) => {
|
| 184 |
try {
|
| 185 |
-
const { view = 'all_time' } = req.query;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
const cacheKey = `leaderboard_${view}`;
|
|
|
|
| 187 |
if (cache[cacheKey]?.expires > Date.now() && cache[cacheKey]?.data) {
|
| 188 |
return res.json({ success: true, data: cache[cacheKey].data, cached: true });
|
| 189 |
}
|
| 190 |
|
| 191 |
let data, error;
|
|
|
|
| 192 |
if (view === 'all_time') {
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
else if (view === 'session') {
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
}
|
| 207 |
else if (view === 'grade') {
|
| 208 |
-
({ data, error } = await supabase
|
| 209 |
-
.
|
| 210 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
}
|
| 212 |
|
| 213 |
if (error) throw error;
|
|
|
|
| 214 |
cache[cacheKey] = { data, expires: Date.now() + CACHE_TTL };
|
| 215 |
res.json({ success: true, data });
|
|
|
|
| 216 |
} catch (err) {
|
|
|
|
| 217 |
res.status(500).json({ success: false, error: err.message });
|
| 218 |
}
|
| 219 |
});
|
| 220 |
|
|
|
|
|
|
|
| 221 |
router.get('/challenges', async (req, res) => {
|
| 222 |
try {
|
| 223 |
-
const { status = 'active' } = req.query;
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
}
|
| 227 |
|
| 228 |
-
const { data: session } = await supabase
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
|
| 233 |
const { data, error } = await query;
|
| 234 |
if (error) throw error;
|
| 235 |
|
| 236 |
-
if (status === 'active')
|
|
|
|
|
|
|
|
|
|
| 237 |
res.json({ success: true, data });
|
|
|
|
| 238 |
} catch (err) {
|
|
|
|
| 239 |
res.status(500).json({ success: false, error: err.message });
|
| 240 |
}
|
| 241 |
});
|
| 242 |
|
|
|
|
|
|
|
| 243 |
router.get('/threads/:context_id', async (req, res) => {
|
| 244 |
try {
|
| 245 |
const { context_id } = req.params;
|
| 246 |
const { limit = 20, offset = 0 } = req.query;
|
| 247 |
-
const
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
if (error) throw error;
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
res.json({ success: true, data: items });
|
|
|
|
| 254 |
} catch (err) {
|
|
|
|
| 255 |
res.status(500).json({ success: false, error: err.message });
|
| 256 |
}
|
| 257 |
});
|
| 258 |
|
|
|
|
|
|
|
| 259 |
router.post('/tag', async (req, res) => {
|
| 260 |
try {
|
| 261 |
const { tagger_scan_id, tagged_scan_id } = req.body;
|
| 262 |
-
|
| 263 |
-
if (
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
} catch (err) {
|
|
|
|
| 266 |
res.status(500).json({ success: false, error: err.message });
|
| 267 |
}
|
| 268 |
});
|
| 269 |
|
|
|
|
|
|
|
| 270 |
router.get('/profile/:username', async (req, res) => {
|
| 271 |
try {
|
| 272 |
const { username } = req.params;
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
|
| 280 |
res.json({ success: true, data: { profile, scans } });
|
|
|
|
| 281 |
} catch (err) {
|
|
|
|
| 282 |
res.status(500).json({ success: false, error: err.message });
|
| 283 |
}
|
| 284 |
});
|
|
|
|
| 8 |
const router = express.Router();
|
| 9 |
|
| 10 |
const TEMP_DIR = path.join(process.cwd(), 'tmp_scans');
|
| 11 |
+
const tempScans = new Map();
|
| 12 |
fs.mkdir(TEMP_DIR, { recursive: true }).catch(console.error);
|
| 13 |
|
| 14 |
+
// Clean up expired temp scans every 5 minutes
|
| 15 |
setInterval(async () => {
|
| 16 |
const now = Date.now();
|
| 17 |
for (const [tempId, data] of tempScans.entries()) {
|
|
|
|
| 23 |
}, 5 * 60 * 1000);
|
| 24 |
|
| 25 |
const cache = {
|
| 26 |
+
feed_page_1: { data: null, expires: 0 },
|
| 27 |
+
leaderboard_all: { data: null, expires: 0 },
|
| 28 |
+
leaderboard_session:{ data: null, expires: 0 },
|
| 29 |
+
leaderboard_grade: { data: null, expires: 0 },
|
| 30 |
+
challenges_active: { data: null, expires: 0 }
|
| 31 |
};
|
| 32 |
+
const CACHE_TTL = 60 * 1000; // 1 minute
|
| 33 |
|
| 34 |
function invalidateCaches(type) {
|
| 35 |
+
if (type === 'feed') {
|
| 36 |
+
cache.feed_page_1.expires = 0;
|
| 37 |
+
cache.feed_page_1.data = null;
|
| 38 |
+
}
|
| 39 |
if (type === 'leaderboard') {
|
| 40 |
+
cache.leaderboard_all.expires = 0; cache.leaderboard_all.data = null;
|
| 41 |
+
cache.leaderboard_session.expires = 0; cache.leaderboard_session.data = null;
|
| 42 |
+
cache.leaderboard_grade.expires = 0; cache.leaderboard_grade.data = null;
|
| 43 |
}
|
| 44 |
}
|
| 45 |
|
|
|
|
| 72 |
Image 2: The Submission
|
| 73 |
Evaluate Image 2 strictly based on how well it matched the vibe and quality of Image 1. ${BASE_RULES}`;
|
| 74 |
|
| 75 |
+
// βββ POST /analyze βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 76 |
+
|
| 77 |
router.post('/analyze', async (req, res) => {
|
| 78 |
try {
|
| 79 |
+
const {
|
| 80 |
+
device_id,
|
| 81 |
+
username,
|
| 82 |
+
avatar,
|
| 83 |
+
media_base64,
|
| 84 |
+
context_type = 'standalone',
|
| 85 |
+
context_id = null,
|
| 86 |
+
reference_data = null
|
| 87 |
+
} = req.body;
|
| 88 |
+
|
| 89 |
+
if (!media_base64 || !device_id) {
|
| 90 |
+
return res.status(400).json({ success: false, error: 'Missing media or device_id.' });
|
| 91 |
+
}
|
| 92 |
|
| 93 |
+
// Upsert profile
|
| 94 |
+
const { data: profile, error: profileError } = await supabase
|
| 95 |
.from('aura_profiles')
|
| 96 |
.upsert({ device_id, username, avatar }, { onConflict: 'device_id' })
|
| 97 |
+
.select('id')
|
| 98 |
+
.single();
|
| 99 |
+
|
| 100 |
+
if (profileError) throw profileError;
|
| 101 |
+
|
| 102 |
+
// Get or create active session
|
| 103 |
+
let { data: session } = await supabase
|
| 104 |
+
.from('aura_sessions')
|
| 105 |
+
.select('id')
|
| 106 |
+
.eq('is_active', true)
|
| 107 |
+
.maybeSingle();
|
| 108 |
|
|
|
|
|
|
|
| 109 |
if (!session) {
|
| 110 |
const start = new Date();
|
| 111 |
const end = new Date(start.getTime() + (3 * 24 * 60 * 60 * 1000));
|
| 112 |
+
const { data: newSession, error: sessionError } = await supabase
|
| 113 |
+
.from('aura_sessions')
|
| 114 |
+
.insert({ start_time: start, end_time: end, is_active: true })
|
| 115 |
+
.select('id')
|
| 116 |
+
.single();
|
| 117 |
+
if (sessionError) throw sessionError;
|
| 118 |
session = newSession;
|
| 119 |
}
|
| 120 |
|
| 121 |
+
// Build prompt
|
| 122 |
let systemPrompt = AURA_PROMPT_STANDALONE;
|
| 123 |
+
let promptText = 'Analyze this image and calculate my Aura. Return JSON.';
|
| 124 |
let imagesToProcess = [media_base64];
|
| 125 |
|
| 126 |
if ((context_type === 'challenge' || context_type === 'thread') && reference_data) {
|
|
|
|
| 130 |
}
|
| 131 |
|
| 132 |
const aiResult = await generateCompletion({
|
| 133 |
+
model: 'qwen-vl-max',
|
| 134 |
prompt: promptText,
|
| 135 |
system_prompt: systemPrompt,
|
| 136 |
+
images: imagesToProcess
|
| 137 |
});
|
| 138 |
|
| 139 |
if (!aiResult.success) throw new Error(aiResult.error);
|
|
|
|
|
|
|
| 140 |
|
| 141 |
+
const cleanJsonStr = aiResult.data
|
| 142 |
+
.replace(/```json/gi, '')
|
| 143 |
+
.replace(/```/gi, '')
|
| 144 |
+
.trim();
|
| 145 |
+
|
| 146 |
+
let parsedData;
|
| 147 |
+
try {
|
| 148 |
+
parsedData = JSON.parse(cleanJsonStr);
|
| 149 |
+
} catch (parseErr) {
|
| 150 |
+
throw new Error(`AI returned invalid JSON: ${cleanJsonStr.substring(0, 100)}`);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
if (typeof parsedData.score !== 'number') {
|
| 154 |
+
throw new Error('AI response missing valid score field.');
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
// Increment aura points via RPC
|
| 158 |
+
const { error: rpcError } = await supabase.rpc('aura_increment_points', {
|
| 159 |
+
p_user_id: profile.id,
|
| 160 |
+
p_session_id: session.id,
|
| 161 |
+
p_score_to_add: parsedData.score,
|
| 162 |
+
p_is_challenge: context_type === 'challenge'
|
| 163 |
});
|
| 164 |
+
if (rpcError) throw rpcError;
|
| 165 |
|
| 166 |
+
// Store temp scan for optional publish
|
| 167 |
const tempId = crypto.randomUUID();
|
| 168 |
const filepath = path.join(TEMP_DIR, `${tempId}.jpg`);
|
| 169 |
await fs.writeFile(filepath, Buffer.from(media_base64, 'base64'));
|
| 170 |
|
| 171 |
tempScans.set(tempId, {
|
| 172 |
+
filepath,
|
| 173 |
+
timestamp: Date.now(),
|
| 174 |
+
user_id: profile.id,
|
| 175 |
+
session_id: session.id,
|
| 176 |
+
score: parsedData.score,
|
| 177 |
+
analysis: parsedData.analysis_markdown,
|
| 178 |
+
context_type,
|
| 179 |
+
context_id
|
| 180 |
});
|
| 181 |
|
| 182 |
invalidateCaches('leaderboard');
|
| 183 |
+
|
| 184 |
+
res.json({
|
| 185 |
+
success: true,
|
| 186 |
+
score: parsedData.score,
|
| 187 |
+
analysis: parsedData.analysis_markdown,
|
| 188 |
+
temp_scan_id: tempId
|
| 189 |
+
});
|
| 190 |
+
|
| 191 |
} catch (err) {
|
| 192 |
+
console.error('[Analyze] Error:', err);
|
| 193 |
res.status(500).json({ success: false, error: err.message });
|
| 194 |
}
|
| 195 |
});
|
| 196 |
|
| 197 |
+
// βββ POST /publish βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 198 |
+
|
| 199 |
router.post('/publish', async (req, res) => {
|
| 200 |
try {
|
| 201 |
const { temp_scan_id } = req.body;
|
| 202 |
+
|
| 203 |
+
if (!temp_scan_id || !tempScans.has(temp_scan_id)) {
|
| 204 |
+
return res.status(400).json({ success: false, error: 'Scan expired or not found.' });
|
| 205 |
+
}
|
| 206 |
|
| 207 |
const scanData = tempScans.get(temp_scan_id);
|
| 208 |
const fileName = `${scanData.user_id}/${Date.now()}_${temp_scan_id}.jpg`;
|
| 209 |
const fileBuffer = await fs.readFile(scanData.filepath);
|
| 210 |
|
| 211 |
+
const { error: uploadError } = await supabase.storage
|
| 212 |
+
.from('aura_media')
|
| 213 |
+
.upload(fileName, fileBuffer, { contentType: 'image/jpeg' });
|
| 214 |
|
| 215 |
+
if (uploadError) throw uploadError;
|
| 216 |
+
|
| 217 |
+
const { data: publicUrlData } = supabase.storage
|
| 218 |
+
.from('aura_media')
|
| 219 |
+
.getPublicUrl(fileName);
|
| 220 |
+
|
| 221 |
+
const { error: insertError } = await supabase
|
| 222 |
+
.from('aura_public_scans')
|
| 223 |
+
.insert({
|
| 224 |
+
user_id: scanData.user_id,
|
| 225 |
+
session_id: scanData.session_id,
|
| 226 |
+
image_url: publicUrlData.publicUrl,
|
| 227 |
+
score: scanData.score,
|
| 228 |
+
analysis_json: scanData.analysis,
|
| 229 |
+
context_type: scanData.context_type,
|
| 230 |
+
context_id: scanData.context_id
|
| 231 |
+
});
|
| 232 |
+
|
| 233 |
+
if (insertError) throw insertError;
|
| 234 |
|
| 235 |
await fs.unlink(scanData.filepath);
|
| 236 |
tempScans.delete(temp_scan_id);
|
| 237 |
invalidateCaches('feed');
|
| 238 |
+
|
| 239 |
+
res.json({ success: true, message: 'Published.' });
|
| 240 |
+
|
| 241 |
} catch (err) {
|
| 242 |
+
console.error('[Publish] Error:', err);
|
| 243 |
res.status(500).json({ success: false, error: err.message });
|
| 244 |
}
|
| 245 |
});
|
| 246 |
|
| 247 |
+
// βββ GET /feed βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 248 |
+
|
| 249 |
router.get('/feed', async (req, res) => {
|
| 250 |
try {
|
| 251 |
+
const { cursor, limit = 20 } = req.query;
|
| 252 |
+
const parsedLimit = parseInt(limit, 10);
|
| 253 |
+
|
| 254 |
+
// Serve cache only for first page
|
| 255 |
if (!cursor && cache.feed_page_1.expires > Date.now() && cache.feed_page_1.data) {
|
| 256 |
return res.json({ success: true, data: cache.feed_page_1.data, cached: true });
|
| 257 |
}
|
| 258 |
|
| 259 |
+
let query = supabase
|
| 260 |
+
.from('aura_public_scans')
|
| 261 |
+
.select(`
|
| 262 |
+
id,
|
| 263 |
+
image_url,
|
| 264 |
+
score,
|
| 265 |
+
context_type,
|
| 266 |
+
context_id,
|
| 267 |
+
created_at,
|
| 268 |
+
aura_profiles ( username, avatar, current_grade )
|
| 269 |
+
`)
|
| 270 |
+
.order('created_at', { ascending: false })
|
| 271 |
+
.limit(parsedLimit);
|
| 272 |
+
|
| 273 |
+
if (cursor) {
|
| 274 |
+
query = query.lt('created_at', cursor);
|
| 275 |
+
}
|
| 276 |
|
|
|
|
| 277 |
const { data, error } = await query;
|
| 278 |
if (error) throw error;
|
| 279 |
|
| 280 |
+
const nextCursor = data.length === parsedLimit
|
| 281 |
+
? data[data.length - 1].created_at
|
| 282 |
+
: null;
|
| 283 |
+
|
| 284 |
const responseData = { items: data, nextCursor };
|
| 285 |
|
| 286 |
+
if (!cursor) {
|
| 287 |
+
cache.feed_page_1 = { data: responseData, expires: Date.now() + CACHE_TTL };
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
res.json({ success: true, data: responseData });
|
| 291 |
+
|
| 292 |
} catch (err) {
|
| 293 |
+
console.error('[Feed] Error:', err);
|
| 294 |
res.status(500).json({ success: false, error: err.message });
|
| 295 |
}
|
| 296 |
});
|
| 297 |
|
| 298 |
+
// βββ GET /leaderboard ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 299 |
+
|
| 300 |
router.get('/leaderboard', async (req, res) => {
|
| 301 |
try {
|
| 302 |
+
const { view = 'all_time' } = req.query;
|
| 303 |
+
|
| 304 |
+
// Validate view param to prevent cache poisoning
|
| 305 |
+
const validViews = ['all_time', 'session', 'grade'];
|
| 306 |
+
if (!validViews.includes(view)) {
|
| 307 |
+
return res.status(400).json({ success: false, error: 'Invalid view parameter.' });
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
const cacheKey = `leaderboard_${view}`;
|
| 311 |
+
|
| 312 |
if (cache[cacheKey]?.expires > Date.now() && cache[cacheKey]?.data) {
|
| 313 |
return res.json({ success: true, data: cache[cacheKey].data, cached: true });
|
| 314 |
}
|
| 315 |
|
| 316 |
let data, error;
|
| 317 |
+
|
| 318 |
if (view === 'all_time') {
|
| 319 |
+
// NOTE: Supabase does not support "col as alias" in .select() strings.
|
| 320 |
+
// Fetch raw column then map to { score } client-side.
|
| 321 |
+
({ data, error } = await supabase
|
| 322 |
+
.from('aura_profiles')
|
| 323 |
+
.select('id, username, avatar, current_grade, cumulative_aura')
|
| 324 |
+
.order('cumulative_aura', { ascending: false })
|
| 325 |
+
.limit(50));
|
| 326 |
+
|
| 327 |
+
if (data) {
|
| 328 |
+
data = data.map(row => ({
|
| 329 |
+
id: row.id,
|
| 330 |
+
username: row.username,
|
| 331 |
+
avatar: row.avatar,
|
| 332 |
+
current_grade: row.current_grade,
|
| 333 |
+
score: row.cumulative_aura ?? 0
|
| 334 |
+
}));
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
else if (view === 'session') {
|
| 338 |
+
const { data: activeSession } = await supabase
|
| 339 |
+
.from('aura_sessions')
|
| 340 |
+
.select('id')
|
| 341 |
+
.eq('is_active', true)
|
| 342 |
+
.maybeSingle();
|
| 343 |
+
|
| 344 |
+
if (!activeSession) {
|
| 345 |
+
return res.json({ success: true, data: [] });
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
({ data, error } = await supabase
|
| 349 |
+
.from('aura_user_session_stats')
|
| 350 |
+
.select('session_aura, aura_profiles ( id, username, avatar, current_grade )')
|
| 351 |
+
.eq('session_id', activeSession.id)
|
| 352 |
+
.order('session_aura', { ascending: false })
|
| 353 |
+
.limit(50));
|
| 354 |
+
|
| 355 |
+
if (data) {
|
| 356 |
+
data = data.map(d => ({
|
| 357 |
+
id: d.aura_profiles?.id,
|
| 358 |
+
username: d.aura_profiles?.username,
|
| 359 |
+
avatar: d.aura_profiles?.avatar,
|
| 360 |
+
current_grade: d.aura_profiles?.current_grade,
|
| 361 |
+
score: d.session_aura ?? 0
|
| 362 |
+
}));
|
| 363 |
+
}
|
| 364 |
}
|
| 365 |
else if (view === 'grade') {
|
| 366 |
+
({ data, error } = await supabase
|
| 367 |
+
.from('aura_profiles')
|
| 368 |
+
.select('id, username, avatar, current_grade, cumulative_aura')
|
| 369 |
+
.order('current_grade', { ascending: false })
|
| 370 |
+
.order('cumulative_aura', { ascending: false })
|
| 371 |
+
.limit(50));
|
| 372 |
+
|
| 373 |
+
if (data) {
|
| 374 |
+
data = data.map(row => ({
|
| 375 |
+
id: row.id,
|
| 376 |
+
username: row.username,
|
| 377 |
+
avatar: row.avatar,
|
| 378 |
+
current_grade: row.current_grade,
|
| 379 |
+
score: row.cumulative_aura ?? 0
|
| 380 |
+
}));
|
| 381 |
+
}
|
| 382 |
}
|
| 383 |
|
| 384 |
if (error) throw error;
|
| 385 |
+
|
| 386 |
cache[cacheKey] = { data, expires: Date.now() + CACHE_TTL };
|
| 387 |
res.json({ success: true, data });
|
| 388 |
+
|
| 389 |
} catch (err) {
|
| 390 |
+
console.error('[Leaderboard] Error:', err);
|
| 391 |
res.status(500).json({ success: false, error: err.message });
|
| 392 |
}
|
| 393 |
});
|
| 394 |
|
| 395 |
+
// βββ GET /challenges βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 396 |
+
|
| 397 |
router.get('/challenges', async (req, res) => {
|
| 398 |
try {
|
| 399 |
+
const { status = 'active' } = req.query;
|
| 400 |
+
|
| 401 |
+
// Fix: guard both expires AND data before serving cache
|
| 402 |
+
if (
|
| 403 |
+
status === 'active' &&
|
| 404 |
+
cache.challenges_active.expires > Date.now() &&
|
| 405 |
+
cache.challenges_active.data
|
| 406 |
+
) {
|
| 407 |
+
return res.json({
|
| 408 |
+
success: true,
|
| 409 |
+
data: cache.challenges_active.data,
|
| 410 |
+
cached: true
|
| 411 |
+
});
|
| 412 |
}
|
| 413 |
|
| 414 |
+
const { data: session } = await supabase
|
| 415 |
+
.from('aura_sessions')
|
| 416 |
+
.select('id')
|
| 417 |
+
.eq('is_active', true)
|
| 418 |
+
.maybeSingle();
|
| 419 |
+
|
| 420 |
+
let query = supabase
|
| 421 |
+
.from('aura_challenges')
|
| 422 |
+
.select('*')
|
| 423 |
+
.order('created_at', { ascending: false });
|
| 424 |
+
|
| 425 |
+
if (status === 'active' && session) {
|
| 426 |
+
query = query.eq('session_id', session.id);
|
| 427 |
+
} else if (status === 'archive' && session) {
|
| 428 |
+
query = query.neq('session_id', session.id);
|
| 429 |
+
}
|
| 430 |
|
| 431 |
const { data, error } = await query;
|
| 432 |
if (error) throw error;
|
| 433 |
|
| 434 |
+
if (status === 'active') {
|
| 435 |
+
cache.challenges_active = { data, expires: Date.now() + CACHE_TTL };
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
res.json({ success: true, data });
|
| 439 |
+
|
| 440 |
} catch (err) {
|
| 441 |
+
console.error('[Challenges] Error:', err);
|
| 442 |
res.status(500).json({ success: false, error: err.message });
|
| 443 |
}
|
| 444 |
});
|
| 445 |
|
| 446 |
+
// βββ GET /threads/:context_id ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 447 |
+
|
| 448 |
router.get('/threads/:context_id', async (req, res) => {
|
| 449 |
try {
|
| 450 |
const { context_id } = req.params;
|
| 451 |
const { limit = 20, offset = 0 } = req.query;
|
| 452 |
+
const parsedLimit = parseInt(limit, 10);
|
| 453 |
+
const parsedOffset = parseInt(offset, 10);
|
| 454 |
+
|
| 455 |
+
const { data, error } = await supabase
|
| 456 |
+
.from('aura_thread_summaries')
|
| 457 |
+
.select(`
|
| 458 |
+
collection_aura,
|
| 459 |
+
submission_count,
|
| 460 |
+
top_image_url,
|
| 461 |
+
aura_profiles ( id, username, avatar, current_grade )
|
| 462 |
+
`)
|
| 463 |
+
.eq('context_id', context_id)
|
| 464 |
+
.order('collection_aura', { ascending: false })
|
| 465 |
+
.range(parsedOffset, parsedOffset + parsedLimit - 1);
|
| 466 |
|
| 467 |
if (error) throw error;
|
| 468 |
+
|
| 469 |
+
const items = data.map(d => ({
|
| 470 |
+
...d.aura_profiles,
|
| 471 |
+
collection_aura: d.collection_aura,
|
| 472 |
+
submission_count: d.submission_count,
|
| 473 |
+
top_image_url: d.top_image_url
|
| 474 |
+
}));
|
| 475 |
+
|
| 476 |
res.json({ success: true, data: items });
|
| 477 |
+
|
| 478 |
} catch (err) {
|
| 479 |
+
console.error('[Threads] Error:', err);
|
| 480 |
res.status(500).json({ success: false, error: err.message });
|
| 481 |
}
|
| 482 |
});
|
| 483 |
|
| 484 |
+
// βββ POST /tag βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 485 |
+
|
| 486 |
router.post('/tag', async (req, res) => {
|
| 487 |
try {
|
| 488 |
const { tagger_scan_id, tagged_scan_id } = req.body;
|
| 489 |
+
|
| 490 |
+
if (!tagger_scan_id || !tagged_scan_id) {
|
| 491 |
+
return res.status(400).json({ success: false, error: 'Missing scan IDs.' });
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
const { error } = await supabase
|
| 495 |
+
.from('aura_thread_tags')
|
| 496 |
+
.insert({ tagger_scan_id, tagged_scan_id });
|
| 497 |
+
|
| 498 |
+
// Ignore duplicate tag (unique constraint violation)
|
| 499 |
+
if (error && error.code !== '23505') throw error;
|
| 500 |
+
|
| 501 |
+
res.json({ success: true, message: 'Tagged successfully.' });
|
| 502 |
+
|
| 503 |
} catch (err) {
|
| 504 |
+
console.error('[Tag] Error:', err);
|
| 505 |
res.status(500).json({ success: false, error: err.message });
|
| 506 |
}
|
| 507 |
});
|
| 508 |
|
| 509 |
+
// βββ GET /profile/:username ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 510 |
+
|
| 511 |
router.get('/profile/:username', async (req, res) => {
|
| 512 |
try {
|
| 513 |
const { username } = req.params;
|
| 514 |
+
|
| 515 |
+
const { data: profile, error: profErr } = await supabase
|
| 516 |
+
.from('aura_profiles')
|
| 517 |
+
.select('id, username, avatar, current_grade, cumulative_aura')
|
| 518 |
+
.eq('username', username)
|
| 519 |
+
.single();
|
| 520 |
+
|
| 521 |
+
if (profErr || !profile) {
|
| 522 |
+
return res.status(404).json({ success: false, error: 'Sorcerer not found.' });
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
const { data: scans, error: scansErr } = await supabase
|
| 526 |
+
.from('aura_public_scans')
|
| 527 |
+
.select('id, image_url, score, context_type, created_at')
|
| 528 |
+
.eq('user_id', profile.id)
|
| 529 |
+
.order('created_at', { ascending: false });
|
| 530 |
+
|
| 531 |
+
if (scansErr) throw scansErr;
|
| 532 |
|
| 533 |
res.json({ success: true, data: { profile, scans } });
|
| 534 |
+
|
| 535 |
} catch (err) {
|
| 536 |
+
console.error('[Profile] Error:', err);
|
| 537 |
res.status(500).json({ success: false, error: err.message });
|
| 538 |
}
|
| 539 |
});
|