Spaces:
Running
Running
| import { db } from "./firebase.js"; | |
| import { | |
| collection, | |
| doc, | |
| setDoc, | |
| getDoc, | |
| addDoc, | |
| onSnapshot, | |
| serverTimestamp, | |
| query, | |
| where, | |
| getDocs, | |
| orderBy, | |
| updateDoc, | |
| getCountFromServer, | |
| deleteDoc | |
| } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js"; | |
| // Collection references | |
| const ROOMS_COLLECTION = "classrooms"; | |
| const USERS_COLLECTION = "users"; | |
| const PROGRESS_COLLECTION = "progress"; | |
| const CHALLENGES_COLLECTION = "challenges"; | |
| /** | |
| * Creates a new classroom room | |
| * @param {string} promotedCode - Optional custom code | |
| * @param {string} hostName - Optional host name | |
| * @returns {Promise<string>} The room code | |
| */ | |
| export async function createRoom(promotedCode = null, hostName = 'Unknown') { | |
| const roomCode = promotedCode || Math.floor(1000 + Math.random() * 9000).toString(); | |
| const roomRef = doc(db, ROOMS_COLLECTION, roomCode); | |
| await setDoc(roomRef, { | |
| createdAt: serverTimestamp(), | |
| active: true, | |
| host: hostName | |
| }); | |
| return roomCode; | |
| } | |
| /** | |
| * Cleans up rooms older than 14 days (inactive) | |
| */ | |
| export async function cleanupOldRooms() { | |
| try { | |
| const fourteenDaysAgo = new Date(); | |
| fourteenDaysAgo.setDate(fourteenDaysAgo.getDate() - 14); | |
| const roomsRef = collection(db, ROOMS_COLLECTION); | |
| const q = query(roomsRef, where("createdAt", "<", fourteenDaysAgo)); | |
| const snapshot = await getDocs(q); | |
| if (snapshot.empty) return; | |
| const deletePromises = snapshot.docs.map(doc => deleteDoc(doc.ref)); | |
| await Promise.all(deletePromises); | |
| console.log(`Cleaned up ${snapshot.size} old rooms.`); | |
| } catch (e) { | |
| console.error("Cleanup failed:", e); | |
| } | |
| } | |
| /** | |
| * Verifies instructor password against Firestore (Auto-seeds if missing) | |
| * @param {string} inputPassword | |
| * @returns {Promise<boolean>} | |
| */ | |
| export async function verifyInstructorPassword(inputPassword) { | |
| const settingsRef = doc(db, "settings", "instructor_auth"); | |
| const snap = await getDoc(settingsRef); | |
| if (!snap.exists()) { | |
| // Auto-seed default password for migration | |
| await setDoc(settingsRef, { password: "88300" }); | |
| return inputPassword === "88300"; | |
| } | |
| return snap.data().password === inputPassword; | |
| } | |
| /** | |
| * Joins a room with Dual-Role Auth / Session Persistence logic | |
| * @param {string} roomCode | |
| * @param {string} nickname | |
| * @returns {Promise<{userId: string, nickname: string}>} Object containing userId and final nickname | |
| */ | |
| /** | |
| * Checks for nickname conflicts in a room | |
| * @param {string} roomCode | |
| * @param {string} nickname | |
| * @returns {Promise<Array<{id: string, nickname: string, last_active: any}>>} List of conflicting users | |
| */ | |
| export async function checkNicknameConflict(roomCode, nickname) { | |
| const usersRef = collection(db, USERS_COLLECTION); | |
| // We want to find users whose nickname STARTS with the input nickname | |
| // Firestore doesn't have a simple "startsWith" for this specific case combined with other filters easily without an index. | |
| // However, we can query exact match OR match with suffix pattern if we fetch all users in room. | |
| // Given class size is small (<100), fetching all room users is efficient enough. | |
| // Actually, let's just query for the exact nickname AND nicknames that likely start with it? | |
| // Firestore querying for "nickname" >= "Name" and "nickname" <= "Name\uf8ff" works. | |
| const q = query( | |
| usersRef, | |
| where("current_room", "==", roomCode) | |
| ); | |
| const snapshot = await getDocs(q); | |
| const conflicts = []; | |
| snapshot.forEach(doc => { | |
| const data = doc.data(); | |
| const name = data.nickname; | |
| // Match exact "Name" or "Name#1234" | |
| if (name === nickname || (name.startsWith(nickname + "#") && /#\d{4}$/.test(name))) { | |
| conflicts.push({ id: doc.id, ...data }); | |
| } | |
| }); | |
| return conflicts; | |
| } | |
| /** | |
| * Creates a new user or joins as an existing specific user | |
| * @param {string} roomCode | |
| * @param {string} nickname | |
| * @param {boolean} forceNew - If true, force create new user with suffix even if exact match exists | |
| * @returns {Promise<{userId: string, nickname: string}>} | |
| */ | |
| export async function joinRoom(roomCode, nickname, forceNew = false) { | |
| // 1. Verify Room Exists | |
| const roomRef = doc(db, ROOMS_COLLECTION, roomCode); | |
| const roomSnap = await getDoc(roomRef); | |
| if (!roomSnap.exists()) { | |
| throw new Error("教室代碼不存在"); | |
| } | |
| const usersRef = collection(db, USERS_COLLECTION); | |
| let finalNickname = nickname; | |
| // Check if direct re-login (e.g. user typed "Name#1234") | |
| const isspecificAuth = /#\d{4}$/.test(nickname); | |
| if (isspecificAuth && !forceNew) { | |
| // Try to find this specific user | |
| const q = query( | |
| usersRef, | |
| where("current_room", "==", roomCode), | |
| where("nickname", "==", nickname) | |
| ); | |
| const snapshot = await getDocs(q); | |
| if (!snapshot.empty) { | |
| const userDoc = snapshot.docs[0]; | |
| await updateDoc(userDoc.ref, { last_active: serverTimestamp() }); | |
| return { userId: userDoc.id, nickname: nickname }; | |
| } | |
| } | |
| // Logic for generic name or forced new | |
| if (forceNew) { | |
| // Generate suffix | |
| const suffix = Math.floor(1000 + Math.random() * 9000).toString(); | |
| finalNickname = `${nickname}#${suffix}`; | |
| } else { | |
| // Check if exact match exists | |
| const q = query( | |
| usersRef, | |
| where("current_room", "==", roomCode), | |
| where("nickname", "==", nickname) | |
| ); | |
| const snapshot = await getDocs(q); | |
| if (!snapshot.empty) { | |
| // Collision on generic name -> Auto suffix | |
| const suffix = Math.floor(1000 + Math.random() * 9000).toString(); | |
| finalNickname = `${nickname}#${suffix}`; | |
| } | |
| } | |
| // Create new user | |
| const newUserRef = await addDoc(usersRef, { | |
| nickname: finalNickname, | |
| current_room: roomCode, | |
| role: 'student', | |
| joinedAt: serverTimestamp(), | |
| last_active: serverTimestamp() | |
| }); | |
| return { userId: newUserRef.id, nickname: finalNickname }; | |
| } | |
| /** | |
| * Submits a prompt for a specific level | |
| * @param {string} userId | |
| * @param {string} roomCode | |
| * @param {string} challengeId | |
| * @param {string} prompt | |
| */ | |
| export async function submitPrompt(userId, roomCode, challengeId, prompt) { | |
| const text = prompt.trim(); | |
| if (!text) return; | |
| // Check if submission already exists to update it, or add new | |
| // For simplicity, we can just use a composite ID or query first. | |
| // Let's use addDoc for history, or setDoc with custom ID for latest state. | |
| // Requirement says "progress/{docId}", let's query first to see if we should update. | |
| const progressRef = collection(db, PROGRESS_COLLECTION); | |
| const q = query( | |
| progressRef, | |
| where("userId", "==", userId), | |
| where("challengeId", "==", challengeId) | |
| ); | |
| const snapshot = await getDocs(q); | |
| if (!snapshot.empty) { | |
| // Update existing | |
| const docRef = snapshot.docs[0].ref; | |
| await updateDoc(docRef, { | |
| status: "completed", | |
| submission_prompt: text, | |
| roomCode: roomCode, // Ensure roomCode is updated for legacy docs | |
| timestamp: serverTimestamp() | |
| }); | |
| } else { | |
| // Create new | |
| await addDoc(progressRef, { | |
| userId, | |
| roomCode, // Added for easier querying by instructor | |
| challengeId, | |
| status: "completed", | |
| submission_prompt: text, | |
| timestamp: serverTimestamp() | |
| }); | |
| } | |
| // Update user last active | |
| const userRef = doc(db, USERS_COLLECTION, userId); | |
| await updateDoc(userRef, { last_active: serverTimestamp() }); | |
| } | |
| /** | |
| * Records that a user has started a challenge | |
| * @param {string} userId | |
| * @param {string} roomCode | |
| * @param {string} challengeId | |
| */ | |
| export async function startChallenge(userId, roomCode, challengeId) { | |
| const progressRef = collection(db, PROGRESS_COLLECTION); | |
| const q = query( | |
| progressRef, | |
| where("userId", "==", userId), | |
| where("challengeId", "==", challengeId) | |
| ); | |
| const snapshot = await getDocs(q); | |
| if (!snapshot.empty) { | |
| // Already exists (maybe started or completed) | |
| const docData = snapshot.docs[0].data(); | |
| if (docData.status === 'completed') return; // Don't overwrite completed status | |
| // If already started, maybe just update timestamp? Or do nothing. | |
| // Let's update timestamp to reflect "last worked on" | |
| await updateDoc(snapshot.docs[0].ref, { | |
| status: 'started', | |
| roomCode: String(roomCode), // Ensure roomCode is updated | |
| timestamp: serverTimestamp() | |
| }); | |
| } else { | |
| // Create new progress entry with 'started' status | |
| await addDoc(progressRef, { | |
| userId, | |
| roomCode: String(roomCode), | |
| challengeId, | |
| status: 'started', | |
| startedAt: serverTimestamp(), // Keep original start time if we want to track duration | |
| timestamp: serverTimestamp() // Last update time | |
| }); | |
| } | |
| // Update user last active | |
| const userRef = doc(db, USERS_COLLECTION, userId); | |
| await updateDoc(userRef, { last_active: serverTimestamp() }); | |
| } | |
| /** | |
| * Subscribes to room users and their progress | |
| * @param {string} roomCode | |
| * @param {Function} callback (studentsWithProgress) => void | |
| * @returns {Function} Unsubscribe function | |
| */ | |
| export function subscribeToRoom(roomCode, callback) { | |
| // Listen to users in the room | |
| const usersQuery = query(collection(db, USERS_COLLECTION), where("current_room", "==", roomCode)); | |
| // We also need progress. Real-time listening to TWO collections and joining them | |
| // is complex in NoSQL. | |
| // Strategy: Listen to Users. When Users change, fetch all progress for this room (or listen to it). | |
| // Simpler efficient approach for dashboard: | |
| // Listen to Progress independent of users? No, we need user list. | |
| // Let's listen to Users, and inside, listen to Progress for this room. | |
| let unsubscribeProgress = () => { }; | |
| const unsubscribeUsers = onSnapshot(usersQuery, (userSnap) => { | |
| const users = []; | |
| userSnap.forEach((doc) => { | |
| users.push({ id: doc.id, ...doc.data() }); | |
| }); | |
| // Now listen to progress for this room | |
| const progressQuery = query(collection(db, PROGRESS_COLLECTION), where("roomCode", "==", roomCode)); | |
| unsubscribeProgress(); // Detach previous listener if any | |
| unsubscribeProgress = onSnapshot(progressQuery, (progressSnap) => { | |
| const progressMap = {}; // { userId: { challengeId: { status, prompt } } } | |
| progressSnap.forEach(doc => { | |
| const data = doc.data(); | |
| if (!progressMap[data.userId]) progressMap[data.userId] = {}; | |
| progressMap[data.userId][data.challengeId] = { | |
| status: data.status, | |
| prompt: data.submission_prompt, | |
| timestamp: data.timestamp, | |
| likes: data.likes || 0 // Include likes | |
| }; | |
| }); | |
| // Merge back to users | |
| const combinedData = users.map(user => ({ | |
| ...user, | |
| progress: progressMap[user.id] || {} | |
| })); | |
| callback(combinedData); | |
| }); | |
| }); | |
| return () => { | |
| unsubscribeUsers(); | |
| unsubscribeProgress(); | |
| }; | |
| } | |
| /** | |
| * Fetches prompts for Peer Learning | |
| */ | |
| export async function getPeerPrompts(roomCode, challengeId) { | |
| const progressRef = collection(db, PROGRESS_COLLECTION); | |
| // Query completions (filtered by challenge, status) | |
| const q = query( | |
| progressRef, | |
| where("challengeId", "==", challengeId), | |
| where("status", "==", "completed") | |
| ); | |
| try { | |
| const snapshot = await getDocs(q); | |
| const entries = []; | |
| for (const docSnapshot of snapshot.docs) { | |
| const data = docSnapshot.data(); | |
| // Fetch nickname & user room info | |
| // We fetch user first to verify room if needed | |
| const userSnap = await getDoc(doc(db, USERS_COLLECTION, data.userId)); | |
| if (!userSnap.exists()) continue; | |
| const userData = userSnap.data(); | |
| // Room Code Check | |
| // 1. Check direct reference in progress (Fastest) | |
| // 2. Fallback to user's current room (Legacy support) | |
| const targetRoom = data.roomCode || userData.current_room; | |
| if (String(targetRoom) !== String(roomCode)) { | |
| continue; | |
| } | |
| entries.push({ | |
| id: docSnapshot.id, | |
| userId: data.userId, | |
| nickname: userData.nickname, | |
| prompt: data.submission_prompt, | |
| timestamp: data.timestamp, | |
| likes: data.likes || 0, | |
| likedBy: data.likedBy || [] | |
| }); | |
| } | |
| return entries; | |
| } catch (e) { | |
| console.error("Error fetching peer prompts:", e); | |
| return []; | |
| } | |
| } | |
| /** | |
| * Resets a user's progress for a specific challenge (sets status to 'started') | |
| * @param {string} userId | |
| * @param {string} roomCode | |
| * @param {string} challengeId | |
| */ | |
| export async function resetProgress(userId, roomCode, challengeId) { | |
| const progressRef = collection(db, PROGRESS_COLLECTION); | |
| const q = query( | |
| progressRef, | |
| where("userId", "==", userId), | |
| where("challengeId", "==", challengeId) | |
| ); | |
| const snapshot = await getDocs(q); | |
| if (!snapshot.empty) { | |
| // Reset status to 'started' so they can submit again | |
| await updateDoc(snapshot.docs[0].ref, { | |
| status: 'started', | |
| timestamp: serverTimestamp() | |
| }); | |
| } | |
| } | |
| /** | |
| * Validates and gets progress for a specific user | |
| * @param {string} userId | |
| * @returns {Promise<Object>} Map of challengeId -> { status, prompt, timestamp } | |
| */ | |
| export async function getUserProgress(userId) { | |
| const progressRef = collection(db, PROGRESS_COLLECTION); | |
| const q = query(progressRef, where("userId", "==", userId)); | |
| const snapshot = await getDocs(q); | |
| const progressMap = {}; | |
| snapshot.forEach(doc => { | |
| const data = doc.data(); | |
| progressMap[data.challengeId] = data; | |
| }); | |
| return progressMap; | |
| } | |
| // --- Admin / Challenge Services --- | |
| export async function getChallenges() { | |
| const q = query(collection(db, CHALLENGES_COLLECTION), orderBy("order", "asc")); | |
| const snapshot = await getDocs(q); | |
| return snapshot.docs.map(doc => ({ id: doc.id, ...doc.data() })); | |
| } | |
| export async function createChallenge(data) { | |
| // data: { level, title, description, link, order } | |
| await addDoc(collection(db, CHALLENGES_COLLECTION), data); | |
| } | |
| export async function updateChallenge(id, data) { | |
| await updateDoc(doc(db, CHALLENGES_COLLECTION, id), data); | |
| } | |
| export async function deleteChallenge(id) { | |
| await deleteDoc(doc(db, CHALLENGES_COLLECTION, id)); | |
| } | |
| // --- Social Features --- | |
| const NOTIFICATIONS_COLLECTION = "notifications"; | |
| /** | |
| * Toggles like on a progress submission | |
| */ | |
| export async function toggleLike(progressId, currentUserId, currentNickname, targetUserId, challengeTitle) { | |
| const progressRef = doc(db, PROGRESS_COLLECTION, progressId); | |
| const progressSnap = await getDoc(progressRef); | |
| if (!progressSnap.exists()) return; | |
| const data = progressSnap.data(); | |
| const likedBy = data.likedBy || []; | |
| const isLiked = likedBy.includes(currentUserId); | |
| if (isLiked) { | |
| // Unlike | |
| await updateDoc(progressRef, { | |
| likes: (data.likes || 1) - 1, | |
| likedBy: likedBy.filter(id => id !== currentUserId) | |
| }); | |
| } else { | |
| // Like | |
| await updateDoc(progressRef, { | |
| likes: (data.likes || 0) + 1, | |
| likedBy: [...likedBy, currentUserId] | |
| }); | |
| // Send Notification if not liking self | |
| if (targetUserId !== currentUserId) { | |
| await addDoc(collection(db, NOTIFICATIONS_COLLECTION), { | |
| recipientId: targetUserId, | |
| senderNickname: currentNickname, | |
| challengeTitle: challengeTitle, | |
| type: 'like', | |
| timestamp: serverTimestamp(), | |
| read: false | |
| }); | |
| } | |
| } | |
| } | |
| /** | |
| * Listen to notifications for a user | |
| */ | |
| export function subscribeToNotifications(userId, callback) { | |
| const q = query( | |
| collection(db, NOTIFICATIONS_COLLECTION), | |
| where("recipientId", "==", userId), | |
| where("read", "==", false), | |
| orderBy("timestamp", "desc") | |
| ); | |
| return onSnapshot(q, (snapshot) => { | |
| const notifications = []; | |
| snapshot.forEach(doc => { | |
| notifications.push({ id: doc.id, ...doc.data() }); | |
| }); | |
| callback(notifications); | |
| }); | |
| } | |
| /** | |
| * Mark notification as read | |
| */ | |
| export async function markNotificationRead(notificationId) { | |
| await updateDoc(doc(db, NOTIFICATIONS_COLLECTION, notificationId), { | |
| read: true | |
| }); | |
| } | |
| /** | |
| * Gets the number of users in a room | |
| * @param {string} roomCode | |
| * @returns {Promise<number>} | |
| */ | |
| /** | |
| * Gets the number of users in a room | |
| * @param {string} roomCode | |
| * @returns {Promise<number>} | |
| */ | |
| export async function getClassSize(roomCode) { | |
| const q = query( | |
| collection(db, USERS_COLLECTION), | |
| where("current_room", "==", roomCode) | |
| ); | |
| const snapshot = await getCountFromServer(q); | |
| return snapshot.data().count; | |
| } | |
| /** | |
| * Gets the number of students who have reached a higher or equal stage | |
| * Used for determining percentile ranking | |
| * @param {string} roomCode | |
| * @param {number} targetStage | |
| * @returns {Promise<number>} | |
| */ | |
| export async function getHigherStageCount(roomCode, targetStage) { | |
| const q = query( | |
| collection(db, USERS_COLLECTION), | |
| where("current_room", "==", roomCode), | |
| where("monster_stage", ">=", targetStage) | |
| ); | |
| const snapshot = await getCountFromServer(q); | |
| return snapshot.data().count; | |
| } | |
| /** | |
| * Updates a student's monster stage and specific form | |
| * @param {string} userId | |
| * @param {number} newStage | |
| * @param {string} monsterId (Optional, for persisting specific form) | |
| */ | |
| export async function updateUserMonster(userId, newStage, monsterId = undefined) { | |
| const userRef = doc(db, USERS_COLLECTION, userId); | |
| const data = { monster_stage: newStage }; | |
| // If monsterId is explicitly provided (including null/empty string), update it. | |
| // If undefined, leave as is. | |
| // We want to allow clearing it (null) or setting 'Egg'. | |
| if (monsterId !== undefined) { | |
| data.monster_id = monsterId; | |
| } | |
| await updateDoc(userRef, data); | |
| } | |
| /** | |
| * Gets user profile data | |
| * @param {string} userId | |
| * @returns {Promise<Object>} | |
| */ | |
| export async function getUser(userId) { | |
| const userRef = doc(db, USERS_COLLECTION, userId); | |
| const snap = await getDoc(userRef); | |
| return snap.exists() ? snap.data() : null; | |
| } | |
| /** | |
| * Removes a user from the classroom (Kick) | |
| * @param {string} userId | |
| */ | |
| /** | |
| * Subscribes to a single user's progress for real-time updates | |
| * @param {string} userId | |
| * @param {Function} callback (progressMap) => void | |
| * @returns {Function} unsubscribe | |
| */ | |
| export function subscribeToUserProgress(userId, callback) { | |
| const q = query( | |
| collection(db, PROGRESS_COLLECTION), | |
| where("userId", "==", userId) | |
| ); | |
| return onSnapshot(q, (snapshot) => { | |
| const progressMap = {}; | |
| snapshot.forEach(doc => { | |
| const data = doc.data(); | |
| progressMap[data.challengeId] = data; | |
| }); | |
| callback(progressMap); | |
| }); | |
| } | |
| export async function removeUser(userId) { | |
| if (!userId) return; | |
| await deleteDoc(doc(db, USERS_COLLECTION, userId)); | |
| } | |