Team Ai
Apppublic

Lashtw/Vibecodingex

sourceHugging Faceupdated 7mo agoView on Hugging Face
0likes
classroom.js640 linesDownload Raw Back to services
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