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} 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} */ 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} The student ID (userId) */ export async function joinRoom(roomCode, nickname) { // 1. Verify Room Exists const roomRef = doc(db, ROOMS_COLLECTION, roomCode); const roomSnap = await getDoc(roomRef); if (!roomSnap.exists()) { throw new Error("教室代碼不存在"); } // 2. Check if user already exists in this room (Cross-device sync) const usersRef = collection(db, USERS_COLLECTION); const q = query( usersRef, where("current_room", "==", roomCode), where("nickname", "==", nickname) ); const querySnapshot = await getDocs(q); if (!querySnapshot.empty) { // User exists, return existing ID const userDoc = querySnapshot.docs[0]; // Update last active await updateDoc(userDoc.ref, { last_active: serverTimestamp() }); return userDoc.id; } // 3. Create new user if not exists const newUserRef = await addDoc(usersRef, { nickname, current_room: roomCode, role: 'student', joinedAt: serverTimestamp(), last_active: serverTimestamp() }); return newUserRef.id; } /** * 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} 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} */ /** * Gets the number of users in a room * @param {string} roomCode * @returns {Promise} */ 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} */ 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} */ 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)); }