Added notes.
Build and Test App / Build-and-test-App (push) Successful in 33s Details
Build and Test API / Build-and-test-API (push) Successful in 52s Details

This commit is contained in:
Andrew Lalis 2025-01-30 10:23:25 -05:00
parent ac3196fb26
commit 45ac42aa80
7 changed files with 187 additions and 1 deletions

View File

@ -42,3 +42,13 @@ CREATE TABLE classroom_compliance_entry (
CONSTRAINT unique_entry_per_date
UNIQUE(class_id, student_id, date)
);
CREATE TABLE classroom_compliance_class_note (
id BIGSERIAL PRIMARY KEY,
class_id BIGINT NOT NULL
REFERENCES classroom_compliance_class(id)
ON UPDATE CASCADE ON DELETE CASCADE,
created_at BIGINT NOT NULL
DEFAULT EXTRACT(EPOCH FROM CURRENT_TIMESTAMP) * 1000,
content VARCHAR(2000) NOT NULL
);

View File

@ -15,6 +15,9 @@ void registerApiEndpoints(PathHandler handler) {
const CLASS_PATH = ROOT_PATH ~ "/classes/:classId:ulong";
handler.addMapping(Method.GET, CLASS_PATH, &getClass);
handler.addMapping(Method.DELETE, CLASS_PATH, &deleteClass);
handler.addMapping(Method.GET, CLASS_PATH ~ "/notes", &getClassNotes);
handler.addMapping(Method.POST, CLASS_PATH ~ "/notes", &createClassNote);
handler.addMapping(Method.DELETE, CLASS_PATH ~ "/notes/:noteId:ulong", &deleteClassNote);
handler.addMapping(Method.POST, CLASS_PATH ~ "/students", &createStudent);
handler.addMapping(Method.GET, CLASS_PATH ~ "/students", &getStudents);

View File

@ -103,3 +103,41 @@ void deleteClass(ref HttpRequestContext ctx) {
ps.setUlong(2, user.id);
ps.executeUpdate();
}
void getClassNotes(ref HttpRequestContext ctx) {
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
const query = "SELECT * FROM classroom_compliance_class_note WHERE class_id = ? ORDER BY created_at DESC";
const notes = findAll(conn, query, &ClassroomComplianceClassNote.parse, cls.id);
writeJsonBody(ctx, notes);
}
void createClassNote(ref HttpRequestContext ctx) {
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
struct Payload {string content;}
Payload payload = readJsonPayload!(Payload)(ctx);
const query = "INSERT INTO classroom_compliance_class_note (class_id, content) VALUES (?, ?) RETURNING id";
ulong id = insertOne(conn, query, cls.id, payload.content);
const note = findOne(
conn,
"SELECT * FROM classroom_compliance_class_note WHERE id = ?",
&ClassroomComplianceClassNote.parse,
id
);
writeJsonBody(ctx, note);
}
void deleteClassNote(ref HttpRequestContext ctx) {
Connection conn = getDb();
scope(exit) conn.close();
User user = getUserOrThrow(ctx, conn);
auto cls = getClassOrThrow(ctx, conn, user);
ulong noteId = ctx.request.getPathParamAs!ulong("noteId");
const query = "DELETE FROM classroom_compliance_class_note WHERE class_id = ? AND id = ?";
update(conn, query, cls.id, noteId);
}

View File

@ -97,4 +97,18 @@ struct ClassroomComplianceEntry {
}
}
struct ClassroomComplianceClassNote {
const ulong id;
const ulong classId;
const ulong createdAt;
const string content;
static ClassroomComplianceClassNote parse(DataSetReader r) {
return ClassroomComplianceClassNote(
r.getUlong(1),
r.getUlong(2),
r.getUlong(3),
r.getString(4)
);
}
}

View File

@ -100,6 +100,13 @@ export interface StudentStatisticsOverview {
entryCount: number
}
export interface ClassNote {
id: number
classId: number
createdAt: number
content: string
}
export class ClassroomComplianceAPIClient extends APIClient {
constructor(authStore: AuthStoreType) {
super(BASE_URL, authStore)
@ -121,6 +128,18 @@ export class ClassroomComplianceAPIClient extends APIClient {
return super.delete(`/classes/${classId}`)
}
getClassNotes(classId: number): APIResponse<ClassNote[]> {
return super.get(`/classes/${classId}/notes`)
}
createClassNote(classId: number, content: string): APIResponse<ClassNote> {
return super.post(`/classes/${classId}/notes`, { content: content })
}
deleteClassNote(classId: number, noteId: number): APIResponse<void> {
return super.delete(`/classes/${classId}/notes/${noteId}`)
}
getStudents(classId: number): APIResponse<Student[]> {
return super.get(`/classes/${classId}/students`)
}

View File

@ -0,0 +1,64 @@
<script setup lang="ts">
import { ClassroomComplianceAPIClient, type ClassNote } from '@/api/classroom_compliance';
import { useAuthStore } from '@/stores/auth';
const props = defineProps<{
note: ClassNote
}>()
const emit = defineEmits(['noteDeleted'])
const authStore = useAuthStore()
const apiClient = new ClassroomComplianceAPIClient(authStore)
async function deleteNote() {
const result = await apiClient.deleteClassNote(props.note.classId, props.note.id).handleErrorsWithAlert()
if (result !== null) {
emit('noteDeleted')
}
}
</script>
<template>
<div class="class-note-item">
<p class="class-note-item-content">{{ note.content }}</p>
<div class="class-note-item-attributes-container">
<p class="class-note-item-timestamp">{{ new Date(note.createdAt).toLocaleString() }}</p>
<button class="class-note-item-delete-button" @click="deleteNote()">Delete</button>
</div>
</div>
</template>
<style scoped>
.class-note-item {
padding: 0.5em;
margin-top: 0.5em;
margin-bottom: 0.5em;
background-color: rgb(49, 49, 49);
border-radius: 10px;
font-size: smaller;
max-width: 500px;
display: flex;
}
.class-note-item-content {
margin: 0;
flex-grow: 1;
}
.class-note-item-attributes-container {
min-width: 200px;
text-align: right;
}
.class-note-item-timestamp {
margin: 0;
}
.class-note-item-delete-button {
background-color: inherit;
border: none;
color: rgb(207, 207, 207);
}
.class-note-item-delete-button:hover {
cursor: pointer;
color: white;
}
</style>

View File

@ -4,7 +4,8 @@ import { onMounted, ref, useTemplateRef, type Ref } from 'vue'
import EntriesTable from '@/apps/classroom_compliance/EntriesTable.vue'
import { useRouter } from 'vue-router'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import { ClassroomComplianceAPIClient, type Class } from '@/api/classroom_compliance'
import { ClassroomComplianceAPIClient, type Class, type ClassNote } from '@/api/classroom_compliance'
import ClassNoteItem from './ClassNoteItem.vue'
const props = defineProps<{
id: string
@ -12,6 +13,8 @@ const props = defineProps<{
const authStore = useAuthStore()
const router = useRouter()
const cls: Ref<Class | null> = ref(null)
const notes: Ref<ClassNote[]> = ref([])
const noteContent: Ref<string> = ref('')
const apiClient = new ClassroomComplianceAPIClient(authStore)
const deleteClassDialog = useTemplateRef('deleteClassDialog')
@ -20,6 +23,7 @@ onMounted(() => {
apiClient.getClass(idNumber).handleErrorsWithAlert().then(result => {
if (result) {
cls.value = result
refreshNotes()
} else {
router.back();
}
@ -31,6 +35,32 @@ async function deleteThisClass() {
await apiClient.deleteClass(cls.value.id).handleErrorsWithAlert()
await router.replace('/classroom-compliance')
}
async function submitNote() {
if (noteContent.value.trim().length < 1 || !cls.value) return
const note = await apiClient.createClassNote(cls.value?.id, noteContent.value).handleErrorsWithAlert()
if (note) {
noteContent.value = ''
const updatedNotes = await apiClient.getClassNotes(cls.value?.id).handleErrorsWithAlert()
if (updatedNotes !== null) {
notes.value = updatedNotes
}
}
}
function refreshNotes() {
if (cls.value === null) {
notes.value = []
return
}
apiClient.getClassNotes(cls.value.id).handleErrorsWithAlert().then(notesResult => {
if (notesResult !== null) {
notes.value = notesResult
} else {
notes.value = []
}
})
}
</script>
<template>
<div v-if="cls">
@ -44,6 +74,14 @@ async function deleteThisClass() {
<button type="button" @click="deleteThisClass">Delete this Class</button>
</div>
<h3>Notes</h3>
<form @submit.prevent="submitNote">
<textarea style="min-height: 50px; min-width: 300px;" maxlength="2000" minlength="1"
v-model="noteContent"></textarea>
<button style="vertical-align: top; margin-left: 0.5em;" type="submit">Add Note</button>
</form>
<ClassNoteItem v-for="note in notes" :key="note.id" :note="note" @noteDeleted="refreshNotes()" />
<EntriesTable :classId="cls.id" />
<!-- Confirmation dialog used for attempts at deleting this class. -->