Lashtw/Vibecodingex
0
1import { db } from "./firebase.js";
2import {
3 collection,
4 doc,
5 setDoc,
6 getDoc,
7 addDoc,
8 onSnapshot,
9 serverTimestamp,
10 query,
11 where,
12 getDocs,
13 orderBy,
14 updateDoc,
15 getCountFromServer,
16 deleteDoc
17} from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js";
18
19// Collection references
20const ROOMS_COLLECTION = "classrooms";
21const USERS_COLLECTION = "users";
22const PROGRESS_COLLECTION = "progress";
23const CHALLENGES_COLLECTION = "challenges";
24
25/**
26 * Creates a new classroom room
27 * @param {string} promotedCode - Optional custom code
28 * @param {string} hostName - Optional host name
29 * @returns {Promise<string>} The room code
30 */
31export async function createRoom(promotedCode = null, hostName = 'Unknown') {
32 const roomCode = promotedCode || Math.floor(1000 + Math.random() * 9000).toString();
33 const roomRef = doc(db, ROOMS_COLLECTION, roomCode);
34
35 await setDoc(roomRef, {
36 createdAt: serverTimestamp(),
37 active: true,
38 host: hostName
39 });
40
41 return roomCode;
42}
43
44/**
45 * Cleans up rooms older than 14 days (inactive)
46 */
47export async function cleanupOldRooms() {
48 try {
49 const fourteenDaysAgo = new Date();
50 fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14);
51
52 const roomsRef = collection(db, ROOMS_COLLECTION);
53 const q = query(roomsRef, where("createdAt", "<", fourteenDaysAgo));
54
55 const snapshot = await getDocs(q);
56 if (snapshot.empty) return;
57
58 const deletePromises = snapshot.docs.map(doc => deleteDoc(doc.ref));
59 await Promise.all(deletePromises);
60 console.log(`Cleaned up ${snapshot.size} old rooms.`);
61 } catch (e) {
62 console.error("Cleanup failed:", e);
63 }
64}
65
66/**
67 * Verifies instructor password against Firestore (Auto-seeds if missing)
68 * @param {string} inputPassword
69 * @returns {Promise<boolean>}
70 */
71export async function verifyInstructorPassword(inputPassword) {
72 const settingsRef = doc(db, "settings", "instructor_auth");
73 const snap = await getDoc(settingsRef);
74
75 if (!snap.exists()) {
76 // Auto-seed default password for migration
77 await setDoc(settingsRef, { password: "88300" });
78 return inputPassword === "88300";
79 }
80
81 return snap.data().password === inputPassword;
82}
83
84/**
85 * Joins a room with Dual-Role Auth / Session Persistence logic
86 * @param {string} roomCode
87 * @param {string} nickname
88 * @returns {Promise<{userId: string, nickname: string}>} Object containing userId and final nickname
89 */
90/**
91 * Checks for nickname conflicts in a room
92 * @param {string} roomCode
93 * @param {string} nickname
94 * @returns {Promise<Array<{id: string, nickname: string, last_active: any}>>} List of conflicting users
95 */
96export async function checkNicknameConflict(roomCode, nickname) {
97 const usersRef = collection(db, USERS_COLLECTION);
98
99 // We want to find users whose nickname STARTS with the input nickname
100 // Firestore doesn't have a simple "startsWith" for this specific case combined with other filters easily without an index.
101 // However, we can query exact match OR match with suffix pattern if we fetch all users in room.
102 // Given class size is small (<100), fetching all room users is efficient enough.
103
104 // Actually, let's just query for the exact nickname AND nicknames that likely start with it?
105 // Firestore querying for "nickname" >= "Name" and "nickname" <= "Name\uf8ff" works.
106
107 const q = query(
108 usersRef,
109 where("current_room", "==", roomCode)
110 );
111
112 const snapshot = await getDocs(q);
113 const conflicts = [];
114
115 snapshot.forEach(doc => {
116 const data = doc.data();
117 const name = data.nickname;
118
119 // Match exact "Name" or "Name#1234"
120 if (name === nickname || (name.startsWith(nickname + "#") && /#\d{4}$/.test(name))) {
121 conflicts.push({ id: doc.id, ...data });
122 }
123 });
124
125 return conflicts;
126}
127
128/**
129 * Creates a new user or joins as an existing specific user
130 * @param {string} roomCode
131 * @param {string} nickname
132 * @param {boolean} forceNew - If true, force create new user with suffix even if exact match exists
133 * @returns {Promise<{userId: string, nickname: string}>}
134 */
135export async function joinRoom(roomCode, nickname, forceNew = false) {
136 // 1. Verify Room Exists
137 const roomRef = doc(db, ROOMS_COLLECTION, roomCode);
138 const roomSnap = await getDoc(roomRef);
139
140 if (!roomSnap.exists()) {
141 throw new Error("教室代碼不存在");
142 }
143
144 const usersRef = collection(db, USERS_COLLECTION);
145 let finalNickname = nickname;
146
147 // Check if direct re-login (e.g. user typed "Name#1234")
148 const isspecificAuth = /#\d{4}$/.test(nickname);
149
150 if (isspecificAuth && !forceNew) {
151 // Try to find this specific user
152 const q = query(
153 usersRef,
154 where("current_room", "==", roomCode),
155 where("nickname", "==", nickname)
156 );
157 const snapshot = await getDocs(q);
158
159 if (!snapshot.empty) {
160 const userDoc = snapshot.docs[0];
161 await updateDoc(userDoc.ref, { last_active: serverTimestamp() });
162 return { userId: userDoc.id, nickname: nickname };
163 }
164 }
165
166 // Logic for generic name or forced new
167 if (forceNew) {
168 // Generate suffix
169 const suffix = Math.floor(1000 + Math.random() * 9000).toString();
170 finalNickname = `${nickname}#${suffix}`;
171 } else {
172 // Check if exact match exists
173 const q = query(
174 usersRef,
175 where("current_room", "==", roomCode),
176 where("nickname", "==", nickname)
177 );
178 const snapshot = await getDocs(q);
179
180 if (!snapshot.empty) {
181 // Collision on generic name -> Auto suffix
182 const suffix = Math.floor(1000 + Math.random() * 9000).toString();
183 finalNickname = `${nickname}#${suffix}`;
184 }
185 }
186
187 // Create new user
188 const newUserRef = await addDoc(usersRef, {
189 nickname: finalNickname,
190 current_room: roomCode,
191 role: 'student',
192 joinedAt: serverTimestamp(),
193 last_active: serverTimestamp()
194 });
195
196 return { userId: newUserRef.id, nickname: finalNickname };
197}
198
199/**
200 * Submits a prompt for a specific level
201 * @param {string} userId
202 * @param {string} roomCode
203 * @param {string} challengeId
204 * @param {string} prompt
205 */
206export async function submitPrompt(userId, roomCode, challengeId, prompt) {
207 const text = prompt.trim();
208 if (!text) return;
209
210 // Check if submission already exists to update it, or add new
211 // For simplicity, we can just use a composite ID or query first.
212 // Let's use addDoc for history, or setDoc with custom ID for latest state.
213 // Requirement says "progress/{docId}", let's query first to see if we should update.
214
215 const progressRef = collection(db, PROGRESS_COLLECTION);
216 const q = query(
217 progressRef,
218 where("userId", "==", userId),
219 where("challengeId", "==", challengeId)
220 );
221 const snapshot = await getDocs(q);
222
223 if (!snapshot.empty) {
224 // Update existing
225 const docRef = snapshot.docs[0].ref;
226 await updateDoc(docRef, {
227 status: "completed",
228 submission_prompt: text,
229 roomCode: roomCode, // Ensure roomCode is updated for legacy docs
230 timestamp: serverTimestamp()
231 });
232 } else {
233 // Create new
234 await addDoc(progressRef, {
235 userId,
236 roomCode, // Added for easier querying by instructor
237 challengeId,
238 status: "completed",
239 submission_prompt: text,
240 timestamp: serverTimestamp()
241 });
242 }
243
244 // Update user last active
245 const userRef = doc(db, USERS_COLLECTION, userId);
246 await updateDoc(userRef, { last_active: serverTimestamp() });
247}
248
249/**
250 * Records that a user has started a challenge
251 * @param {string} userId
252 * @param {string} roomCode
253 * @param {string} challengeId
254 */
255export async function startChallenge(userId, roomCode, challengeId) {
256 const progressRef = collection(db, PROGRESS_COLLECTION);
257 const q = query(
258 progressRef,
259 where("userId", "==", userId),
260 where("challengeId", "==", challengeId)
261 );
262 const snapshot = await getDocs(q);
263
264 if (!snapshot.empty) {
265 // Already exists (maybe started or completed)
266 const docData = snapshot.docs[0].data();
267 if (docData.status === 'completed') return; // Don't overwrite completed status
268
269 // If already started, maybe just update timestamp? Or do nothing.
270 // Let's update timestamp to reflect "last worked on"
271 await updateDoc(snapshot.docs[0].ref, {
272 status: 'started',
273 roomCode: String(roomCode), // Ensure roomCode is updated
274 timestamp: serverTimestamp()
275 });
276 } else {
277 // Create new progress entry with 'started' status
278 await addDoc(progressRef, {
279 userId,
280 roomCode: String(roomCode),
281 challengeId,
282 status: 'started',
283 startedAt: serverTimestamp(), // Keep original start time if we want to track duration
284 timestamp: serverTimestamp() // Last update time
285 });
286 }
287
288 // Update user last active
289 const userRef = doc(db, USERS_COLLECTION, userId);
290 await updateDoc(userRef, { last_active: serverTimestamp() });
291}
292
293/**
294 * Subscribes to room users and their progress
295 * @param {string} roomCode
296 * @param {Function} callback (studentsWithProgress) => void
297 * @returns {Function} Unsubscribe function
298 */
299export function subscribeToRoom(roomCode, callback) {
300 // Listen to users in the room
301 const usersQuery = query(collection(db, USERS_COLLECTION), where("current_room", "==", roomCode));
302
303 // We also need progress. Real-time listening to TWO collections and joining them
304 // is complex in NoSQL.
305 // Strategy: Listen to Users. When Users change, fetch all progress for this room (or listen to it).
306 // Simpler efficient approach for dashboard:
307 // Listen to Progress independent of users? No, we need user list.
308
309 // Let's listen to Users, and inside, listen to Progress for this room.
310
311 let unsubscribeProgress = () => { };
312
313 const unsubscribeUsers = onSnapshot(usersQuery, (userSnap) => {
314 const users = [];
315 userSnap.forEach((doc) => {
316 users.push({ id: doc.id, ...doc.data() });
317 });
318
319 // Now listen to progress for this room
320 const progressQuery = query(collection(db, PROGRESS_COLLECTION), where("roomCode", "==", roomCode));
321
322 unsubscribeProgress(); // Detach previous listener if any
323
324 unsubscribeProgress = onSnapshot(progressQuery, (progressSnap) => {
325 const progressMap = {}; // { userId: { challengeId: { status, prompt } } }
326
327 progressSnap.forEach(doc => {
328 const data = doc.data();
329 if (!progressMap[data.userId]) progressMap[data.userId] = {};
330 progressMap[data.userId][data.challengeId] = {
331 status: data.status,
332 prompt: data.submission_prompt,
333 timestamp: data.timestamp,
334 likes: data.likes || 0 // Include likes
335 };
336 });
337
338 // Merge back to users
339 const combinedData = users.map(user => ({
340 ...user,
341 progress: progressMap[user.id] || {}
342 }));
343
344 callback(combinedData);
345 });
346 });
347
348 return () => {
349 unsubscribeUsers();
350 unsubscribeProgress();
351 };
352}
353
354/**
355 * Fetches prompts for Peer Learning
356 */
357export async function getPeerPrompts(roomCode, challengeId) {
358 const progressRef = collection(db, PROGRESS_COLLECTION);
359
360 // Query completions (filtered by challenge, status)
361 const q = query(
362 progressRef,
363 where("challengeId", "==", challengeId),
364 where("status", "==", "completed")
365 );
366
367 try {
368 const snapshot = await getDocs(q);
369 const entries = [];
370
371 for (const docSnapshot of snapshot.docs) {
372 const data = docSnapshot.data();
373
374 // Fetch nickname & user room info
375 // We fetch user first to verify room if needed
376 const userSnap = await getDoc(doc(db, USERS_COLLECTION, data.userId));
377 if (!userSnap.exists()) continue;
378
379 const userData = userSnap.data();
380
381 // Room Code Check
382 // 1. Check direct reference in progress (Fastest)
383 // 2. Fallback to user's current room (Legacy support)
384 const targetRoom = data.roomCode || userData.current_room;
385
386 if (String(targetRoom) !== String(roomCode)) {
387 continue;
388 }
389
390 entries.push({
391 id: docSnapshot.id,
392 userId: data.userId,
393 nickname: userData.nickname,
394 prompt: data.submission_prompt,
395 timestamp: data.timestamp,
396 likes: data.likes || 0,
397 likedBy: data.likedBy || []
398 });
399 }
400 return entries;
401
402 } catch (e) {
403 console.error("Error fetching peer prompts:", e);
404 return [];
405 }
406}
407
408/**
409 * Resets a user's progress for a specific challenge (sets status to 'started')
410 * @param {string} userId
411 * @param {string} roomCode
412 * @param {string} challengeId
413 */
414export async function resetProgress(userId, roomCode, challengeId) {
415 const progressRef = collection(db, PROGRESS_COLLECTION);
416 const q = query(
417 progressRef,
418 where("userId", "==", userId),
419 where("challengeId", "==", challengeId)
420 );
421 const snapshot = await getDocs(q);
422
423 if (!snapshot.empty) {
424 // Reset status to 'started' so they can submit again
425 await updateDoc(snapshot.docs[0].ref, {
426 status: 'started',
427 timestamp: serverTimestamp()
428 });
429 }
430}
431
432/**
433 * Validates and gets progress for a specific user
434 * @param {string} userId
435 * @returns {Promise<Object>} Map of challengeId -> { status, prompt, timestamp }
436 */
437export async function getUserProgress(userId) {
438 const progressRef = collection(db, PROGRESS_COLLECTION);
439 const q = query(progressRef, where("userId", "==", userId));
440 const snapshot = await getDocs(q);
441
442 const progressMap = {};
443 snapshot.forEach(doc => {
444 const data = doc.data();
445 progressMap[data.challengeId] = data;
446 });
447 return progressMap;
448}
449
450// --- Admin / Challenge Services ---
451
452export async function getChallenges() {
453 const q = query(collection(db, CHALLENGES_COLLECTION), orderBy("order", "asc"));
454 const snapshot = await getDocs(q);
455 return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
456}
457
458export async function createChallenge(data) {
459 // data: { level, title, description, link, order }
460 await addDoc(collection(db, CHALLENGES_COLLECTION), data);
461}
462
463export async function updateChallenge(id, data) {
464 await updateDoc(doc(db, CHALLENGES_COLLECTION, id), data);
465}
466
467export async function deleteChallenge(id) {
468 await deleteDoc(doc(db, CHALLENGES_COLLECTION, id));
469}
470
471// --- Social Features ---
472
473const NOTIFICATIONS_COLLECTION = "notifications";
474
475/**
476 * Toggles like on a progress submission
477 */
478export async function toggleLike(progressId, currentUserId, currentNickname, targetUserId, challengeTitle) {
479 const progressRef = doc(db, PROGRESS_COLLECTION, progressId);
480 const progressSnap = await getDoc(progressRef);
481
482 if (!progressSnap.exists()) return;
483
484 const data = progressSnap.data();
485 const likedBy = data.likedBy || [];
486 const isLiked = likedBy.includes(currentUserId);
487
488 if (isLiked) {
489 // Unlike
490 await updateDoc(progressRef, {
491 likes: (data.likes || 1) - 1,
492 likedBy: likedBy.filter(id => id !== currentUserId)
493 });
494 } else {
495 // Like
496 await updateDoc(progressRef, {
497 likes: (data.likes || 0) + 1,
498 likedBy: [...likedBy, currentUserId]
499 });
500
501 // Send Notification if not liking self
502 if (targetUserId !== currentUserId) {
503 await addDoc(collection(db, NOTIFICATIONS_COLLECTION), {
504 recipientId: targetUserId,
505 senderNickname: currentNickname,
506 challengeTitle: challengeTitle,
507 type: 'like',
508 timestamp: serverTimestamp(),
509 read: false
510 });
511 }
512 }
513}
514
515/**
516 * Listen to notifications for a user
517 */
518export function subscribeToNotifications(userId, callback) {
519 const q = query(
520 collection(db, NOTIFICATIONS_COLLECTION),
521 where("recipientId", "==", userId),
522 where("read", "==", false),
523 orderBy("timestamp", "desc")
524 );
525
526 return onSnapshot(q, (snapshot) => {
527 const notifications = [];
528 snapshot.forEach(doc => {
529 notifications.push({ id: doc.id, ...doc.data() });
530 });
531 callback(notifications);
532 });
533}
534
535/**
536 * Mark notification as read
537 */
538export async function markNotificationRead(notificationId) {
539 await updateDoc(doc(db, NOTIFICATIONS_COLLECTION, notificationId), {
540 read: true
541 });
542}
543
544/**
545 * Gets the number of users in a room
546 * @param {string} roomCode
547 * @returns {Promise<number>}
548 */
549/**
550 * Gets the number of users in a room
551 * @param {string} roomCode
552 * @returns {Promise<number>}
553 */
554export async function getClassSize(roomCode) {
555 const q = query(
556 collection(db, USERS_COLLECTION),
557 where("current_room", "==", roomCode)
558 );
559 const snapshot = await getCountFromServer(q);
560 return snapshot.data().count;
561}
562
563/**
564 * Gets the number of students who have reached a higher or equal stage
565 * Used for determining percentile ranking
566 * @param {string} roomCode
567 * @param {number} targetStage
568 * @returns {Promise<number>}
569 */
570export async function getHigherStageCount(roomCode, targetStage) {
571 const q = query(
572 collection(db, USERS_COLLECTION),
573 where("current_room", "==", roomCode),
574 where("monster_stage", ">=", targetStage)
575 );
576 const snapshot = await getCountFromServer(q);
577 return snapshot.data().count;
578}
579
580/**
581 * Updates a student's monster stage and specific form
582 * @param {string} userId
583 * @param {number} newStage
584 * @param {string} monsterId (Optional, for persisting specific form)
585 */
586export async function updateUserMonster(userId, newStage, monsterId = undefined) {
587 const userRef = doc(db, USERS_COLLECTION, userId);
588 const data = { monster_stage: newStage };
589 // If monsterId is explicitly provided (including null/empty string), update it.
590 // If undefined, leave as is.
591 // We want to allow clearing it (null) or setting 'Egg'.
592 if (monsterId !== undefined) {
593 data.monster_id = monsterId;
594 }
595
596 await updateDoc(userRef, data);
597}
598
599/**
600 * Gets user profile data
601 * @param {string} userId
602 * @returns {Promise<Object>}
603 */
604export async function getUser(userId) {
605 const userRef = doc(db, USERS_COLLECTION, userId);
606 const snap = await getDoc(userRef);
607 return snap.exists() ? snap.data() : null;
608}
609
610/**
611 * Removes a user from the classroom (Kick)
612 * @param {string} userId
613 */
614/**
615 * Subscribes to a single user's progress for real-time updates
616 * @param {string} userId
617 * @param {Function} callback (progressMap) => void
618 * @returns {Function} unsubscribe
619 */
620export function subscribeToUserProgress(userId, callback) {
621 const q = query(
622 collection(db, PROGRESS_COLLECTION),
623 where("userId", "==", userId)
624 );
625
626 return onSnapshot(q, (snapshot) => {
627 const progressMap = {};
628 snapshot.forEach(doc => {
629 const data = doc.data();
630 progressMap[data.challengeId] = data;
631 });
632 callback(progressMap);
633 });
634}
635
636export async function removeUser(userId) {
637 if (!userId) return;
638 await deleteDoc(doc(db, USERS_COLLECTION, userId));
639}
640 