diff --git a/src/models/DocumentRoot.ts b/src/models/DocumentRoot.ts index ae47f2798..2b0a73652 100644 --- a/src/models/DocumentRoot.ts +++ b/src/models/DocumentRoot.ts @@ -219,12 +219,25 @@ class DocumentRoot { * applied afterwards. */ get allDocuments() { + // TODO: Check usages, consider switching to allAuthoritativeDocuments. if (!this.store.root.userStore.current?.hasElevatedAccess) { return this.documents; } return this.store.root.documentStore.findByDocumentRoot(this.id); } + /** + * All **authoritative** documents which are related to this document root. + * This method should be used only for admin users or when the author-filtering is + * applied afterwards. + * + * @see {@link iDocument#isAuthoritative} + */ + @computed + get allAuthoritativeDocuments() { + return this.allDocuments.filter((doc) => doc.isAuthoritative); + } + /** * TODO: replace this placeholder to the currently viewed user * @default: should return the current viewed user id diff --git a/src/models/documents/Assessable/iAssessable.ts b/src/models/documents/Assessable/iAssessable.ts index 19e5b49db..a52dc2f8e 100644 --- a/src/models/documents/Assessable/iAssessable.ts +++ b/src/models/documents/Assessable/iAssessable.ts @@ -6,7 +6,6 @@ import React from 'react'; import { AssessableMeta, ExpandedOption } from './AssessableMeta'; import Quiz from './Quiz'; import { iTaskableDocument } from '@tdev-models/iTaskableDocument'; -import { mdiTooltipQuestionOutline } from '@mdi/js'; import { IfmColors } from '@tdev-components/shared/Colors'; export enum Correctness { @@ -154,7 +153,7 @@ abstract class iAssessable extends iDocument implem if (this.type === 'quiz' || !this.inQuiz) { return undefined; } - const quiz = this.root?.allDocuments.find( + const quiz = this.root?.allAuthoritativeDocuments.find( (doc) => doc.authorId === this.authorId && doc.type === 'quiz' ); return quiz as Quiz | undefined; @@ -298,9 +297,10 @@ abstract class iAssessable extends iDocument implem return; } if (this.inQuiz && this.quiz) { - if (this.quiz.questionCount === 0) { - // A real quiz always has at least one questionId. If this is empty, the quiz hasn't loaded yet - // and we shouldn't delete anything. + if (!this.quiz.isAuthoritative) { + console.error( + `iAssessable with documentRootId='${this.root?.id}' encountered a non-authoritative quiz (id='${this.quiz.id}'). This should not happen.` + ); return; } // ensure the current document is unique for the given qid and authorId diff --git a/src/models/iDocument.ts b/src/models/iDocument.ts index 9196cb34f..db3ff2f00 100644 --- a/src/models/iDocument.ts +++ b/src/models/iDocument.ts @@ -178,11 +178,46 @@ abstract class iDocument { return !!this.root && this.root.isLoaded; } + /** + * Invariant. If true, this document represents the ground truth of what its persisted data should look like. + * + * A document is authoritative, if and only if one of the following are true: + * - This document represents the **data loaded from the API** + potential local changes yet to be committed. + * - This document represents an **initial creation** after the **API** has **explicitly confirmed** that **no document** + * for this document root + user + document type exists yet. + * - This is a **dummy document** **AND** there is no active **or residual** login session available. + * + * Examples where a document is **NOT** authoritative: + * - It is a dummy document, there is no active login session or server connection, but a residual login session exists + * (i.e. waiting for a possible reconnect) + * - It is a non-dummy document that has been created after the API failed to return an existing document e.g. due to a network error + * (but hasn't explicitly confirmed that none exist). + * + * If a document is **NOT** authoritative: + * - it must **not be persisted** to the database. + * - it must **not be edited**. + * - it must **not be relied** upon as accurate (e.g. when deriving state or actions for another document). + */ + @computed + get isAuthoritative() { + if (this.isDummy) { + // Dummy documents are authoritative as long as we can guarantee that the user is not logged in. + const sessionStore = this.store.root.sessionStore; + return !sessionStore.isLoggedIn && !sessionStore.sessionStatusArbitrary; + } + // TODO: Does the source remain API after local edits? + // TODO: Can we guarantee that a source=local can only happen after API has confirmed non-existence? + return true; + } + @computed get canEdit() { if (!this.root) { return false; } + if (!this.isAuthoritative) { + return false; + } if (this.sideEffects.some((se) => !se.canEdit)) { return false; } @@ -236,6 +271,10 @@ abstract class iDocument { @action save(skipStreamUpdate: boolean = false, onBeforeSave?: (() => Promise) | undefined) { + if (!this.isAuthoritative) { + throw `Trying to save a non-authoritative document (id=${this.id}, documentRootId=${this.root?.id})`; + } + const res = this.saveFn(onBeforeSave); if (!skipStreamUpdate) { this.streamUpdate(); @@ -245,6 +284,8 @@ abstract class iDocument { @action streamUpdate() { + // TODO: Authoritative invariant required? + if (!this.isPresenting) { return; } @@ -265,6 +306,10 @@ abstract class iDocument { @action _save(onBeforeSave: () => Promise = () => Promise.resolve()) { + if (!this.isAuthoritative) { + throw `Trying to save a non-authoritative document (id=${this.id}, documentRootId=${this.root?.id})`; + } + /** * call the api to save the code... */ diff --git a/src/stores/SessionStore.ts b/src/stores/SessionStore.ts index 6c9e846b2..0b8eb9820 100644 --- a/src/stores/SessionStore.ts +++ b/src/stores/SessionStore.ts @@ -9,6 +9,7 @@ export class SessionStore extends iStore<'checkLogin'> { @observable accessor initialized = false; @observable accessor isLoggedIn = false; + @observable accessor sessionStatusArbitrary = true; @observable accessor currentUserId: string | undefined; @observable accessor storageSyncInitialized = false; @@ -23,6 +24,11 @@ export class SessionStore extends iStore<'checkLogin'> { @action setCurrentUserId(userId: string | undefined) { + if (!userId) { + this.sessionStatusArbitrary = true; + } else { + this.sessionStatusArbitrary = false; + } this.currentUserId = userId; } @@ -31,6 +37,18 @@ export class SessionStore extends iStore<'checkLogin'> { this.isLoggedIn = loggedIn; } + @action + cleanup(sessionStatusArbitrary: boolean) { + this.sessionStatusArbitrary = sessionStatusArbitrary; + this.isLoggedIn = false; + this.currentUserId = undefined; + } + + @action + markSessionStatusArbitrary() { + this.sessionStatusArbitrary = true; + } + get apiMode(): 'indexedDB' | 'memory' | 'api' { return api.mode ?? 'api'; } diff --git a/src/stores/rootStore.ts b/src/stores/rootStore.ts index 9a0b5fb02..97890ae34 100644 --- a/src/stores/rootStore.ts +++ b/src/stores/rootStore.ts @@ -69,12 +69,9 @@ export class RootStore { } @action - cleanup() { - /** - * could be probably ignored since the page gets reloaded on logout? - */ - console.log('cleanup data stores'); - this.sessionStore.setIsLoggedIn(false); + cleanup(sessionStatusArbitrary: boolean) { + console.log('cleanup data stores; session status arbitrary:', sessionStatusArbitrary); + this.sessionStore.cleanup(sessionStatusArbitrary); this.userStore.cleanup(); this.socketStore.cleanup(); this.studentGroupStore.cleanup(); diff --git a/src/theme/Root.tsx b/src/theme/Root.tsx index 35d582d32..d0906fa16 100644 --- a/src/theme/Root.tsx +++ b/src/theme/Root.tsx @@ -65,17 +65,19 @@ const ExposeRootStoreToWindow = observer(() => { }); const Authentication = observer(() => { - const { data: session } = authClient.useSession(); + const { data: session, isPending, error } = authClient.useSession(); React.useEffect(() => { if (!rootStore) { return; } + if (session?.user) { rootStore.load(session.user.id); } else { - rootStore.cleanup(); + const sessionStatusArbitrary = isPending || !!error; + rootStore.cleanup(sessionStatusArbitrary); } - }, [session?.user, rootStore]); + }, [session?.user, isPending, error, rootStore]); return null; });