Vibecodingex / src /services /classroom.js
Lashtw's picture
Upload 9 files
ae79a68 verified
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));
}