Skip to content
Merged

Frq #383

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,7 @@ src/lib/AddAdminRoles

# firebase emulator
.firebaserc
firebase.json
firebase-debug.log
firestore.indexes.json
firestore-debug.log
storage.rules
firebase-storage.rules
Expand Down
19 changes: 19 additions & 0 deletions firebase.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"firestore": {
"rules": "firestore.rules",
"indexes": "firestore.indexes.json"
},
"emulators": {
"auth": {
"port": 9099
},
"firestore": {
"port": 8080
},
"ui": {
"enabled": true,
"port": 4000
},
"singleProjectMode": true
}
}
28 changes: 28 additions & 0 deletions firestore.indexes.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
{
"indexes": [
{
"collectionGroup": "gradableFrqSubmissions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "submittedAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "gradableFrqSubmissions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "studentId", "order": "ASCENDING" },
{ "fieldPath": "submittedAt", "order": "DESCENDING" }
]
},
{
"collectionGroup": "gradedFrqSubmissions",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "studentId", "order": "ASCENDING" },
{ "fieldPath": "gradedAt", "order": "DESCENDING" }
]
}
],
"fieldOverrides": []
}
94 changes: 88 additions & 6 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ rules_version = '2';

service cloud.firestore {
match /databases/{database}/documents {

// Centralized function to check if the user is authenticated
function isAuthenticated() {
return request.auth != null;
}

// Centralized function to check if the user is an admin
function isAdmin() {
return isAuthenticated() && get(/databases/$(database)/documents/users/$(request.auth.uid)).data.access == "admin";
Expand All @@ -24,6 +24,12 @@ service cloud.firestore {
return isAuthenticated() && (accessLevel == "admin" || accessLevel == "member" || accessLevel == "grader");
}

function isGraderOrAdmin() {
let accessLevel = get(/databases/$(database)/documents/users/$(request.auth.uid)).data.access;
return isAuthenticated() &&
(accessLevel == "admin" || accessLevel == "grader");
}

// Allow read/write access to users for their own user data
match /users/{userId} {
allow read: if isAuthenticated() && request.auth.uid == userId;
Expand Down Expand Up @@ -51,7 +57,7 @@ service cloud.firestore {
let userDocAfter = getAfter(/databases/$(database)/documents/users/$(request.auth.uid));
let userDataAfter = userDocAfter.data;

return userData.access != "banned" && (isAdmin() || (!("lastFrqResponseAt" in userData) &&
return userData.access != "banned" && (isAdmin() || (!("lastFrqResponseAt" in userData) &&
"lastFrqResponseAt" in userDataAfter
) || (
userData.lastFrqResponseAt < (request.time - duration.value(6, 'h'))
Expand All @@ -63,12 +69,79 @@ service cloud.firestore {
match /{somePath=**}/frqResponses/{frqResponse} {
allow read: if isGraderOrMemberOrAdmin();
}


// Digital testing FRQs deliberately use separate stores. Templates are
// published prompts, the queue contains only ungraded responses, and a
// grading result is kept separately for the student's dashboard.


match /ungraded-frqs/{submissionId} {
// `resource` is null when the document does not exist, and dereferencing it
// raises an evaluation error rather than denying, which surfaces to the
// client as "insufficient permissions" for what is really a missing doc.
allow get, list: if isGraderOrAdmin() ||
(isAuthenticated() && resource != null && resource.data.studentId == request.auth.uid);

allow create: if isAuthenticated()
&& request.resource.data.keys().hasOnly([
'templateId',
'subject',
'unitId',
'studentId',
'responses',
'submittedAt'
])
&& request.resource.data.templateId is string
&& request.resource.data.subject is string
&& request.resource.data.unitId is string
&& request.resource.data.studentId == request.auth.uid
&& request.resource.data.responses is map
&& exists(
/databases/$(database)/documents/subjects/$(request.resource.data.subject)/units/$(request.resource.data.unitId)/frqs/$(request.resource.data.templateId)
);

allow delete: if isGraderOrAdmin();
}

match /graded-frqs/{resultId} {
allow get, list: if isGraderOrAdmin() ||
(isAuthenticated() && resource != null && resource.data.studentId == request.auth.uid);

allow create: if isGraderOrAdmin()
&& resultId == request.resource.data.sourceSubmissionId
&& request.resource.data.keys().hasOnly([
'sourceSubmissionId',
'templateId',
'subject',
'unitId',
'studentId',
'responses',
'submittedAt',
'score',
'feedback',
'grades',
'graderId',
'gradedAt'
])
&& request.resource.data.grades is list
&& request.resource.data.sourceSubmissionId is string
&& request.resource.data.templateId is string
&& request.resource.data.subject is string
&& request.resource.data.unitId is string
&& request.resource.data.studentId is string
&& request.resource.data.responses is map
&& request.resource.data.score is string
&& request.resource.data.feedback is string
&& request.resource.data.graderId == request.auth.uid;

allow update, delete: if isGraderOrAdmin();
}

// Admins can read/write all user documents
match /users/{document=**} {
allow read, write: if isAdmin();
}

// Subjects and pages can be accessed by members and admins
match /subjects/{subject} {
allow read: if true;
Expand All @@ -87,9 +160,18 @@ service cloud.firestore {
allow read: if resource.data.isPublic == true || isMemberOrAdmin();
allow write: if isMemberOrAdmin();
}

// Graders read templates too: the rubric being scored against lives on
// the template, and so does the prompt shown beside a student's answer.
// Restricting reads to members left graders unable to open a private
// FRQ's submission at all.
match /frqs/{frq} {
allow read: if (resource != null && resource.data.isPublic == true) || isGraderOrMemberOrAdmin();
allow write: if isMemberOrAdmin();
}
}
}

match /pages/{document=**} {
allow read: if true;
allow write: if isMemberOrAdmin();
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"dev": "next dev",
"lint": "next lint",
"start": "next start",
"emulate": "firebase emulators:start --import emulator --export-on-exit"
"emulate": "firebase emulators:start --import emulator --export-on-exit",
"deploy:rules": "firebase deploy --only firestore:rules,firestore:indexes"
},
"dependencies": {
"@editorjs/attaches": "^1.3.0",
Expand Down Expand Up @@ -46,7 +47,7 @@
"editorjs-math": "^1.0.2",
"editorjs-parser": "^1.5.3",
"editorjs-undo": "^2.0.28",
"firebase": "^10.13.0",
"firebase": "^10.14.1",
"firebase-admin": "^12.3.1",
"highlight.js": "^11.10.0",
"katex": "^0.16.47",
Expand Down
52 changes: 50 additions & 2 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,17 @@ import type { User } from "@/types/user";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useUserManagement } from "./useUserManagement";
import { getUngradedFrqsCollectionRef } from "@/lib/firestore/frqRefs";
import apClassesData from "@/components/apClasses.json";
import { useUser } from "../../components/hooks/UserContext";
import Link from "next/link";
import { cn, formatSlug } from "@/lib/utils";
import { Ban, ClipboardPen, PencilRuler, ShieldUser, X } from "lucide-react";
import { doc, updateDoc } from "firebase/firestore";
import {
doc,
getDocs,
updateDoc,
} from "firebase/firestore";
import { db } from "@/lib/firebase";
import { Button } from "@/components/ui/button";

Expand All @@ -22,6 +27,28 @@ const Page = () => {
const { user } = useUser();
const router = useRouter();

const [ungradedFrqCount, setUngradedFrqCount] = useState<number | null>(
null,
);

useEffect(() => {
if (!user || user.access !== "admin") return;

const fetchUngradedFrqCount = async () => {
try {
const collectionRef = getUngradedFrqsCollectionRef();
const snapshot = await getDocs(collectionRef);

setUngradedFrqCount(snapshot.size);
} catch (error) {
console.error("Failed to fetch ungraded FRQ count:", error);
setUngradedFrqCount(null);
}
};

void fetchUngradedFrqCount();
}, [user]);

if (!user) {
return (
<div className="flex h-screen items-center justify-center text-3xl">
Expand All @@ -45,10 +72,31 @@ const Page = () => {
{user.access === "admin" && (
<>
<AdminPanel user={user} />

<div className="mb-4 flex flex-col gap-4 rounded-lg border-4 p-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-sm font-semibold uppercase tracking-wide opacity-70">
Ungraded FRQs
</p>

<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
{ungradedFrqCount ?? "—"}
</span>

<span className="text-sm opacity-70">currently ungraded</span>
</div>
</div>

<Link href="/frq-grading">
<Button className="w-full sm:w-auto">
Open FRQ Grading List
</Button>
</Link>
</div>
</>
)}


<Link href="/admin/feedback" className="hover:text-yellow-600">
<Button className="w-full">
Check Feedback & Bug Reports
Expand Down
Loading
Loading