OASIS_MOBILE/src/models/school_model.js
oasis-bot c332afd6ea upstream: integrar Oasis 1.3.1
Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.

Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).

Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
2026-10-18 02:52:17 +02:00

1423 lines
60 KiB
JavaScript

const pull = require("../server/node_modules/pull-stream")
const moment = require("../server/node_modules/moment")
const { getConfig } = require("../configs/config-manager.js")
const { readTyped, memoIndex } = require("./typed_log")
const { buildValidatedTombstoneSet } = require("./tombstone_validator")
const { versionGroups } = require("../backend/dedupe")
const opinionCategories = require("../backend/opinion_categories")
const logLimit = getConfig().ssbLogStream?.limit || 1000
const norm = (s) => String(s || "").trim().toLowerCase()
const toNum = (v) => {
const n = parseFloat(String(v ?? "").replace(",", "."))
return Number.isFinite(n) ? n : NaN
}
const normalizeTags = (raw) => {
if (raw === undefined || raw === null) return []
if (Array.isArray(raw)) return raw.map(t => String(t || "").trim()).filter(Boolean)
return String(raw).split(",").map(t => t.trim()).filter(Boolean)
}
const normalizeDate = (raw) => {
const t = Date.parse(String(raw || ""))
return Number.isFinite(t) ? new Date(t).toISOString() : null
}
const normalizeIds = (raw) => {
if (raw === undefined || raw === null) return []
const arr = Array.isArray(raw) ? raw : String(raw).split(/[\s,]+/)
return Array.from(new Set(arr.map(t => String(t || "").trim()).filter(t => t.startsWith("@"))))
}
const matchSearch = (course, q) => {
const qq = norm(q)
if (!qq) return true
const hay = [
course.title,
course.description,
Array.isArray(course.tags) ? course.tags.join(" ") : ""
].map(x => norm(x)).join(" ")
return hay.includes(qq)
}
module.exports = ({ cooler, transfersModel, schoolCrypto, chatsModel }) => {
const ssbKeys = require("../server/node_modules/ssb-keys")
const nodeCrypto = require("crypto")
let ssb
const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb }
const SCHOOL_TYPES = [
"schoolCourse", "schoolLesson", "schoolLessonMedia", "schoolExam", "schoolExamQuestion",
"schoolExamResult", "schoolProgress", "schoolOpinion", "schoolCertificate", "schoolCommentHide",
"schoolEnroll", "school-invite", "tombstone", "tribe-keys", "transfer", "transferConfirm", "chatMember", "chat"
]
const readAll = async (ssbClient) => readTyped(ssbClient, SCHOOL_TYPES, { limit: logLimit, withWindow: true, withPrivate: true })
const indexOf = (messages, ssbClient) => memoIndex('school', messages, (m) => buildIndex(m, ssbClient))
const buildIndex = (messages, ssbClient) => {
const tomb = buildValidatedTombstoneSet(messages)
const courseNodes = new Map()
const lessonNodes = new Map()
const certNodes = new Map()
const lessonReplaces = new Map()
const enrollLatest = new Map()
const transferAgg = new Map()
const transferNodes = new Map()
const transferConfirms = []
const tribeKeyMsgs = []
const opinionMsgs = []
const examNodes = new Map()
const questionNodes = new Map()
const progressLatest = new Map()
const examResultLatest = new Map()
const chatByCourse = new Map()
const chatMsgs = []
for (const m of messages) {
const key = m.key
const v = m.value || {}
const c = v.content
if (!c) continue
if (c.type === "chat") {
if (typeof c.courseId === "string" && c.courseId) chatMsgs.push({ courseId: c.courseId, key, author: v.author })
continue
}
if (c.type === "tombstone") continue
if (c.type === "schoolCourse") {
courseNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, seq: v.sequence || 0, c, author: v.author })
continue
}
if (c.type === "schoolLesson" && c.courseId) {
lessonNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
if (c.replaces) lessonReplaces.set(key, c.replaces)
continue
}
if (c.type === "schoolCertificate" && c.courseId) {
certNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
continue
}
if (c.type === "schoolEnroll" && c.courseId) {
const author = v.author
if (!author) continue
const ts = v.timestamp || m.timestamp || 0
const k = `${c.courseId}::${author}`
const prev = enrollLatest.get(k)
if (!prev || ts >= prev.ts) enrollLatest.set(k, { ts, value: !!c.value, author, courseId: c.courseId, transferId: c.transferId || null, keyProof: c.keyProof || null })
continue
}
if (c.type === "transfer") {
if (!v.author || c.from !== v.author) continue
transferNodes.set(key, { author: v.author, to: c.to, amount: toNum(c.amount), concept: String(c.concept || ""), discarded: String(c.status || "").toUpperCase() === "DISCARDED", replaces: c.replaces || null })
continue
}
if (c.type === "transferConfirm" && c.target) {
if (v.author) transferConfirms.push({ target: c.target, author: v.author })
continue
}
if (c.type === "tribe-keys" && c.tribeId && c.memberKeys && typeof c.memberKeys === "object") {
tribeKeyMsgs.push({ tribeId: c.tribeId, memberKeys: c.memberKeys, author: v.author })
continue
}
if (c.type === "schoolOpinion" && c.target) {
opinionMsgs.push({ target: c.target, author: v.author, category: c.category })
continue
}
if (c.type === "schoolLessonMedia" && c.courseId && c.target) {
examNodes.set("media:" + key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author, isMedia: true })
continue
}
if (c.type === "schoolExamQuestion" && c.courseId && c.target) {
questionNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
continue
}
if (c.type === "schoolExam" && c.courseId) {
examNodes.set(key, { key, ts: v.timestamp || m.timestamp || 0, c, author: v.author })
continue
}
if (c.type === "schoolProgress" && c.courseId && c.lessonId) {
const ts = v.timestamp || m.timestamp || 0
const k = `${c.courseId}::${c.lessonId}::${v.author}`
const prev = progressLatest.get(k)
if (!prev || ts >= prev.ts) progressLatest.set(k, { ts, value: c.value !== false, author: v.author, courseId: c.courseId, lessonId: c.lessonId })
continue
}
}
if (ssbClient && ssbClient.private && typeof ssbClient.private.unbox === "function") {
for (const m of messages) {
const sealed = typeof m.value?.content === "string"
if (!sealed && m.value?.private !== true) continue
try {
const dec = sealed ? ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 }) : m
const c = dec?.value?.content
const author = dec?.value?.author
if (!c || !author) continue
const ts = dec.value.timestamp || m.timestamp || 0
if (c.type === "schoolEnroll" && c.courseId) {
const k = `${c.courseId}::${author}`
const prev = enrollLatest.get(k)
if (!prev || ts >= prev.ts) enrollLatest.set(k, { ts, value: !!c.value, author, courseId: c.courseId, transferId: c.transferId || null, keyProof: c.keyProof || null })
continue
}
if (c.type === "schoolProgress" && c.courseId && c.lessonId) {
const k = `${c.courseId}::${c.lessonId}::${author}`
const prev = progressLatest.get(k)
if (!prev || ts >= prev.ts) progressLatest.set(k, { ts, value: c.value !== false, author, courseId: c.courseId, lessonId: c.lessonId })
continue
}
if (c.type === "schoolExamResult" && c.courseId && c.examId) {
const k = `${c.examId}::${author}`
const prev = examResultLatest.get(k)
if (!prev || ts >= prev.ts) examResultLatest.set(k, { ts, author, courseId: c.courseId, examId: c.examId, score: c.score, total: c.total })
continue
}
} catch {}
}
}
const transferChild = new Map()
const transferForked = new Set()
for (const [key, t] of transferNodes.entries()) {
const orig = t.replaces ? transferNodes.get(t.replaces) : null
if (!orig || orig.author !== t.author) continue
if (transferChild.has(t.replaces)) transferForked.add(t.replaces)
transferChild.set(t.replaces, key)
}
const confirmersOf = new Map()
for (const cf of transferConfirms) {
if (!confirmersOf.has(cf.target)) confirmersOf.set(cf.target, new Set())
confirmersOf.get(cf.target).add(cf.author)
}
for (const [key, t] of transferNodes.entries()) {
const orig = t.replaces ? transferNodes.get(t.replaces) : null
if (orig && orig.author === t.author) continue
const chain = [key]
while (transferChild.has(chain[chain.length - 1])) chain.push(transferChild.get(chain[chain.length - 1]))
const tipKey = chain[chain.length - 1]
const tip = transferNodes.get(tipKey)
const signers = new Set()
for (const ck of chain) for (const signer of (confirmersOf.get(ck) || [])) if (signer === tip.to) signers.add(signer)
const bill = { author: tip.author, to: tip.to, amount: tip.amount, concept: tip.concept, void: tip.discarded || tomb.has(tipKey) || chain.some(ck => transferForked.has(ck)), signers }
for (const ck of chain) transferAgg.set(ck, bill)
}
const lessonParent = new Map()
for (const [key, target] of lessonReplaces.entries()) {
const node = lessonNodes.get(key)
const orig = lessonNodes.get(target)
if (node && orig && String(orig.author) === String(node.author)) lessonParent.set(key, target)
}
const lessonChildOf = new Map()
for (const [k, t] of lessonParent.entries()) lessonChildOf.set(t, k)
const lessonRootOf = (id) => {
let cur = id
while (lessonParent.has(cur)) cur = lessonParent.get(cur)
return cur
}
const vg = versionGroups(Array.from(courseNodes.values()).map(n => ({ key: n.key, author: n.author, seq: n.seq, ts: n.ts, replaces: typeof n.c.replaces === "string" ? n.c.replaces : null, claimedAuthor: typeof n.c.author === "string" ? n.c.author : null })))
const rootOf = vg.rootOf
const tipOf = vg.tipOf
const tipByRoot = new Map()
for (const r of vg.roots) tipByRoot.set(r, tipOf(r))
const courseAuthorOf = (id) => { const n = courseNodes.get(rootOf(id)); return n ? n.author : null }
for (const [key, node] of lessonNodes.entries()) if (courseAuthorOf(node.c.courseId) !== node.author) lessonNodes.delete(key)
for (const [key, node] of certNodes.entries()) if (courseAuthorOf(node.c.courseId) !== node.author) certNodes.delete(key)
for (const [key, node] of examNodes.entries()) if (courseAuthorOf(node.c.courseId) !== node.author) examNodes.delete(key)
for (const [key, node] of questionNodes.entries()) if (courseAuthorOf(node.c.courseId) !== node.author) questionNodes.delete(key)
for (const ch of chatMsgs) {
if (chatByCourse.has(ch.courseId) || courseAuthorOf(ch.courseId) !== ch.author) continue
chatByCourse.set(ch.courseId, ch.key)
}
const enrollByCourse = new Map()
for (const { courseId, author, value, transferId, keyProof } of enrollLatest.values()) {
const root = rootOf(courseId)
if (!enrollByCourse.has(root)) enrollByCourse.set(root, new Map())
const enrollments = enrollByCourse.get(root)
if (value) enrollments.set(author, { transferId, keyProof: keyProof || null })
else enrollments.delete(author)
}
const opinionsByCourse = new Map()
for (const op of opinionMsgs) {
const root = rootOf(op.target)
if (!courseNodes.has(root)) continue
if (!opinionsByCourse.has(root)) opinionsByCourse.set(root, { byAuthor: new Map() })
const agg = opinionsByCourse.get(root)
if (!op.author || agg.byAuthor.has(op.author)) continue
agg.byAuthor.set(op.author, op.category)
}
const progressByCourse = new Map()
for (const pr of progressLatest.values()) {
if (!pr.value) continue
const root = rootOf(pr.courseId)
if (!courseNodes.has(root)) continue
if (!progressByCourse.has(root)) progressByCourse.set(root, new Map())
const perStudent = progressByCourse.get(root)
if (!perStudent.has(pr.author)) perStudent.set(pr.author, new Set())
perStudent.get(pr.author).add(lessonRootOf(pr.lessonId))
}
const grantsByCourse = new Map()
const myId = ssbClient ? ssbClient.id : null
for (const tk of tribeKeyMsgs) {
const root = rootOf(tk.tribeId)
if (!courseNodes.has(root)) continue
if (courseNodes.get(root).author !== tk.author) continue
if (!grantsByCourse.has(root)) grantsByCourse.set(root, new Set())
for (const member of Object.keys(tk.memberKeys)) grantsByCourse.get(root).add(member)
if (schoolCrypto && myId && tk.memberKeys[myId]) {
try {
const cfg = require("../server/ssb_config")
const unboxed = ssbKeys.unbox(tk.memberKeys[myId], cfg.keys)
if (typeof unboxed === "string" && unboxed) schoolCrypto.addNewKey(root, unboxed)
} catch {}
}
}
return { tomb, courseNodes, lessonNodes, certNodes, examNodes, vg, rootOf, tipOf, tipByRoot, enrollByCourse, transferAgg, grantsByCourse, opinionsByCourse, progressByCourse, examResultLatest, lessonRootOf, lessonChildOf, questionNodes, chatByCourse }
}
const keyProofFor = (keyHex, studentId) => {
try { return nodeCrypto.createHmac("sha256", Buffer.from(keyHex, "hex")).update(String(studentId), "utf8").digest("hex") } catch (_) { return null }
}
const buildCourseObject = (node, rootId, enrollments, transferAgg, grants, opinionAgg, chatByCourse = new Map()) => {
let c = node.c || {}
const ringKeys = schoolCrypto ? (schoolCrypto.getKeys(rootId) || []) : []
if (c.encryptedPayload) {
const dec = ringKeys.length && schoolCrypto ? schoolCrypto.decryptContent(c, ringKeys.map(k => [k])) : { ...c, _undecryptable: true }
if (dec._undecryptable) return { undecryptable: true, id: node.key, rootId, author: node.author }
c = dec
}
let blobId = c.image || null
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
const priceN = toNum(c.price)
const paid = Number.isFinite(priceN) && priceN > 0
const students = []
const pending = []
const grantedViaInvite = []
for (const [student, info] of (enrollments || new Map())) {
if (info.keyProof && ringKeys.some(k => keyProofFor(k, student) === info.keyProof)) grantedViaInvite.push(student)
if (paid) {
const bill = info.transferId ? (transferAgg && transferAgg.get(info.transferId)) : null
const settled = !!bill && !bill.void && bill.author === student && bill.to === node.author && bill.amount >= priceN && bill.concept.includes(rootId) && bill.signers.has(node.author)
if (settled) students.push(student)
else pending.push({ author: student, transferId: info.transferId || null })
} else {
students.push(student)
}
}
const studentOpinions = { opinions: {}, voters: [] }
for (const [author, category] of ((opinionAgg && opinionAgg.byAuthor) || new Map())) {
if (!students.includes(author)) continue
studentOpinions.voters.push(author)
if (category) studentOpinions.opinions[category] = (studentOpinions.opinions[category] || 0) + 1
}
return {
id: node.key,
rootId,
title: c.title,
description: c.description,
tags: Array.isArray(c.tags) ? c.tags : normalizeTags(c.tags),
image: blobId,
price: Number.isFinite(priceN) && priceN > 0 ? priceN.toFixed(6) : "0.000000",
visibility: String(c.visibility || "PUBLIC").toUpperCase() === "INVITE" ? "INVITE" : "PUBLIC",
status: String(c.status || "ONGOING").toUpperCase() === "CLOSED" ? "CLOSED" : "ONGOING",
invited: normalizeIds(c.invited).filter(id => !students.includes(id)),
startDate: c.startDate || null,
chatId: c.chatId || chatByCourse.get(rootId) || null,
inviteCode: c.inviteCode || null,
author: node.author,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
updatedAt: c.updatedAt || null,
students,
pending,
granted: Array.from(new Set([...(grants ? Array.from(grants) : []), ...grantedViaInvite])),
opinions: studentOpinions.opinions,
opinions_inhabitants: studentOpinions.voters
}
}
const isProtected = (course) => Number(course.price) > 0 || course.visibility === "INVITE"
const addToChat = async (ssbClient, course, members) => {
if (!course.chatId || !members.length) return
for (const member of members) {
try {
await new Promise((res) => ssbClient.publish({ type: "chatMember", target: course.chatId, member, on: true, createdAt: new Date().toISOString() }, () => res()))
} catch {}
}
if (chatsModel && typeof chatsModel.listAll === "function") {
try { await chatsModel.listAll({ filter: "all", viewerId: ssbClient.id }) } catch {}
}
}
const canView = (course, viewer) =>
course.visibility === "PUBLIC" ||
course.author === viewer ||
course.invited.includes(viewer) ||
course.students.includes(viewer) ||
course.pending.some(p => p.author === viewer)
const ensureCourseKey = async (ssbClient, rootId) => {
if (!schoolCrypto) return null
let key = schoolCrypto.getKey(rootId)
if (key) return key
key = schoolCrypto.generateTribeKey()
schoolCrypto.setKey(rootId, key, 1)
await publishKeyGrant(ssbClient, rootId, key, [ssbClient.id])
return key
}
const publishKeyGrant = (ssbClient, rootId, key, members) => {
const memberKeys = {}
for (const member of members) {
try { memberKeys[member] = schoolCrypto.boxKeyForMember(key, member, ssbKeys) } catch {}
}
if (!Object.keys(memberKeys).length) return Promise.resolve(null)
return new Promise((res) => ssbClient.publish({ type: "tribe-keys", tribeId: rootId, generation: 1, memberKeys }, () => res(null)))
}
return {
type: "schoolCourse",
async createCourse(data) {
const ssbClient = await openSsb()
const title = String(data.title || "").trim()
const description = String(data.description || "").trim()
if (!title) throw new Error("Invalid title")
if (!description) throw new Error("Invalid description")
let blobId = data.image || null
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
const priceN = toNum(data.price)
const protectedCourse = (Number.isFinite(priceN) && priceN > 0) || String(data.visibility || "PUBLIC").toUpperCase() === "INVITE"
const content = {
type: "schoolCourse",
title,
description,
tags: normalizeTags(data.tags),
image: blobId,
price: Number.isFinite(priceN) && priceN > 0 ? priceN.toFixed(6) : "0.000000",
visibility: String(data.visibility || "PUBLIC").toUpperCase() === "INVITE" ? "INVITE" : "PUBLIC",
status: "ONGOING",
invited: normalizeIds(data.invited),
startDate: (() => {
const d = normalizeDate(data.startDate)
if (d && d.slice(0, 10) < new Date().toISOString().slice(0, 10)) throw new Error("Start date cannot be in the past")
return d
})(),
chatId: null,
author: ssbClient.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
let toPublish = content
let courseKey = null
if (content.visibility === "INVITE" && schoolCrypto) {
courseKey = schoolCrypto.generateTribeKey()
toPublish = schoolCrypto.encryptContent(content, [courseKey], true)
}
const published = await new Promise((res, rej) => ssbClient.publish(toPublish, (e, m) => e ? rej(e) : res(m)))
if (published && published.key && chatsModel) {
try {
await chatsModel.createChat(`Course: ${title}`, description, blobId, "school", protectedCourse ? "INVITE-ONLY" : "OPEN", normalizeTags(data.tags), null, { courseId: published.key })
} catch {}
}
if (published && published.key) {
if (courseKey) {
schoolCrypto.setKey(published.key, courseKey, 1)
await publishKeyGrant(ssbClient, published.key, courseKey, [ssbClient.id])
} else if (Number(content.price) > 0) {
await ensureCourseKey(ssbClient, published.key)
}
}
return published
},
async resolveCurrentId(courseId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const { tomb, vg } = indexOf(messages, ssbClient)
const cur = vg.tipOf(courseId)
if (tomb.has(cur) || vg.isForeignCopy(vg.rootOf(cur))) throw new Error("Course not found")
return cur
},
async resolveRootId(courseId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const { tomb, vg } = indexOf(messages, ssbClient)
if (tomb.has(vg.tipOf(courseId)) || vg.isForeignCopy(vg.rootOf(courseId))) throw new Error("Course not found")
return vg.rootOf(courseId)
},
async updateCourse(id, data) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const tipId = await this.resolveCurrentId(id)
const node = idx.courseNodes.get(tipId)
if (!node || !node.c) throw new Error("Course not found")
let existingContent = node.c
if (existingContent.encryptedPayload) {
const rootForKeys = idx.rootOf(tipId)
const keys = schoolCrypto ? (schoolCrypto.getKeys(rootForKeys) || []) : []
const dec = keys.length && schoolCrypto ? schoolCrypto.decryptContent(existingContent, keys.map(k => [k])) : { ...existingContent, _undecryptable: true }
if (dec._undecryptable) throw new Error("Course not found")
delete dec._decrypted
existingContent = dec
}
const author = existingContent.author
if (author !== ssbClient.id) throw new Error("Unauthorized")
const patch = {}
if (data.title !== undefined) {
const t = String(data.title || "").trim()
if (!t) throw new Error("Invalid title")
patch.title = t
}
if (data.description !== undefined) {
const d = String(data.description || "").trim()
if (!d) throw new Error("Invalid description")
patch.description = d
}
if (data.tags !== undefined) patch.tags = normalizeTags(data.tags)
if (data.image !== undefined) {
let blobId = data.image
if (blobId && /\(([^)]+)\)/.test(String(blobId))) blobId = String(blobId).match(/\(([^)]+)\)/)[1]
patch.image = blobId || null
}
if (data.price !== undefined) {
const p = toNum(data.price)
if (Number.isFinite(p) && p < 0) throw new Error("Invalid price")
patch.price = Number.isFinite(p) && p > 0 ? p.toFixed(6) : "0.000000"
}
if (data.visibility !== undefined) {
patch.visibility = String(data.visibility || "PUBLIC").toUpperCase() === "INVITE" ? "INVITE" : "PUBLIC"
}
if (data.status !== undefined) {
const s = String(data.status || "").toUpperCase()
if (!["ONGOING", "CLOSED"].includes(s)) throw new Error("Invalid status")
patch.status = s
}
if (data.invited !== undefined) patch.invited = normalizeIds(data.invited)
if (data.inviteCode !== undefined) patch.inviteCode = String(data.inviteCode || "") || null
if (data.startDate !== undefined) patch.startDate = normalizeDate(data.startDate)
const next = {
...existingContent,
...patch,
author,
createdAt: existingContent.createdAt,
updatedAt: new Date().toISOString(),
replaces: tipId,
type: "schoolCourse"
}
const tombMsg = {
type: "tombstone",
target: tipId,
deletedAt: new Date().toISOString(),
author: ssbClient.id
}
await new Promise((res, rej) => ssbClient.publish(tombMsg, (e) => e ? rej(e) : res()))
let toPublish = next
if (String(next.visibility || "").toUpperCase() === "INVITE" && schoolCrypto) {
const key = await ensureCourseKey(ssbClient, idx.rootOf(tipId))
if (key) toPublish = schoolCrypto.encryptContent(next, [key], true)
}
const published = await new Promise((res, rej) => ssbClient.publish(toPublish, (e, m) => e ? rej(e) : res(m)))
const linkedChatId = existingContent.chatId || idx.chatByCourse.get(idx.rootOf(tipId)) || null
if (patch.image !== undefined && linkedChatId && chatsModel && typeof chatsModel.updateChatById === "function") {
try { await chatsModel.updateChatById(linkedChatId, { image: patch.image }) } catch {}
}
return published
},
async updateCourseStatus(id, status) {
return this.updateCourse(id, { status: String(status || "").toUpperCase() })
},
async deleteCourse(id) {
const ssbClient = await openSsb()
const tipId = await this.resolveCurrentId(id)
const course = await this.getCourseById(tipId)
if (!course || course.author !== ssbClient.id) throw new Error("Unauthorized")
const tombMsg = {
type: "tombstone",
target: tipId,
deletedAt: new Date().toISOString(),
author: ssbClient.id
}
return new Promise((res, rej) => ssbClient.publish(tombMsg, (e, r) => e ? rej(e) : res(r)))
},
async inviteStudent(id, studentId) {
const ssbClient = await openSsb()
const course = await this.getCourseById(id)
const ids = normalizeIds(studentId)
if (!ids.length) throw new Error("Invalid student id")
const updated = await this.updateCourse(course.id, { invited: course.invited.concat(ids) })
if (isProtected(course) && schoolCrypto) {
const key = await ensureCourseKey(ssbClient, course.rootId)
if (key) await publishKeyGrant(ssbClient, course.rootId, key, ids)
}
return updated
},
async uninviteStudent(id, studentId) {
const course = await this.getCourseById(id)
const drop = new Set(normalizeIds(studentId))
return this.updateCourse(course.id, { invited: course.invited.filter(s => !drop.has(s)) })
},
async enroll(id) {
const ssbClient = await openSsb()
const me = ssbClient.id
const course = await this.getCourseById(id)
if (course.author === me) throw new Error("Cannot enroll in your own course")
if (course.status !== "ONGOING") throw new Error("Course is closed")
if (course.students.includes(me)) return { alreadyEnrolled: true }
if (course.pending.some(p => p.author === me)) return { alreadyPending: true }
if (course.visibility === "INVITE" && !course.invited.includes(me)) throw new Error("Invitation required")
const rootId = course.rootId || (await this.resolveRootId(id))
let transferId = null
if (Number(course.price) > 0) {
if (!transfersModel) throw new Error("Transfers module unavailable")
const deadline = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
const bill = await transfersModel.createTransfer(course.author, `SCHOOL ${rootId}`, course.price, deadline, ["SCHOOL"], "ECONOMIC")
transferId = bill && bill.key ? bill.key : null
}
const msg = {
type: "schoolEnroll",
courseId: rootId,
value: true,
transferId,
createdAt: new Date().toISOString()
}
if (isProtected(course)) {
return new Promise((res, rej) => ssbClient.private.publish(msg, [me, course.author], (e, m) => e ? rej(e) : res(m)))
}
const published = await new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
if (course.chatId && chatsModel && typeof chatsModel.joinChat === "function") {
try { await chatsModel.joinChat(course.chatId) } catch {}
}
return published
},
async unenroll(id) {
const ssbClient = await openSsb()
const me = ssbClient.id
const course = await this.getCourseById(id)
if (!course.students.includes(me) && !course.pending.some(pn => pn.author === me)) return { notEnrolled: true }
const rootId = course.rootId || (await this.resolveRootId(id))
const msg = {
type: "schoolEnroll",
courseId: rootId,
value: false,
createdAt: new Date().toISOString()
}
if (isProtected(course)) {
return new Promise((res, rej) => ssbClient.private.publish(msg, [me, course.author], (e, m) => e ? rej(e) : res(m)))
}
return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
},
async addLesson(courseId, data) {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
if (course.author !== ssbClient.id) throw new Error("Unauthorized")
const title = String(data.title || "").trim()
const text = String(data.text || "").trim()
if (!title) throw new Error("Invalid title")
if (!text) throw new Error("Invalid text")
const rootId = course.rootId || (await this.resolveRootId(courseId))
const orderN = toNum(data.order)
let content = {
type: "schoolLesson",
courseId: rootId,
title,
text,
unit: String(data.unit || "").trim() || null,
order: Number.isFinite(orderN) ? orderN : null,
sessionDate: (() => {
const d = normalizeDate(data.sessionDate)
if (d && d.slice(0, 10) < new Date().toISOString().slice(0, 10)) throw new Error("Session date cannot be in the past")
return d
})(),
author: ssbClient.id,
createdAt: new Date().toISOString()
}
if (isProtected(course) && schoolCrypto) {
const key = await ensureCourseKey(ssbClient, rootId)
if (key) content = schoolCrypto.encryptContent(content, [key], true)
}
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async createOpinion(courseId, category) {
if (!opinionCategories.includes(category)) throw new Error("Invalid voting category")
const ssbClient = await openSsb()
const me = ssbClient.id
const course = await this.getCourseById(courseId)
if (course.author === me) throw new Error("Cannot rate your own course")
if (!course.students.includes(me)) throw new Error("Only students can rate a course")
if (course.opinions_inhabitants.includes(me)) throw new Error("Already voted")
const content = { type: "schoolOpinion", target: course.rootId, category, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async markLesson(courseId, lessonId, value = true) {
const ssbClient = await openSsb()
const me = ssbClient.id
const course = await this.getCourseById(courseId)
if (!course.students.includes(me)) throw new Error("Only students can track progress")
const msg = {
type: "schoolProgress",
courseId: course.rootId,
lessonId,
value: value !== false,
createdAt: new Date().toISOString()
}
if (isProtected(course)) {
return new Promise((res, rej) => ssbClient.private.publish(msg, [me, course.author], (e, m) => e ? rej(e) : res(m)))
}
return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
},
async progressForCourse(courseId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const rootId = idx.rootOf(courseId)
const perStudent = idx.progressByCourse.get(rootId) || new Map()
const out = {}
for (const [student, lessonSet] of perStudent) out[student] = lessonSet.size
return out
},
async createExam(courseId, title, opts = {}) {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
if (course.author !== ssbClient.id) throw new Error("Unauthorized")
if (!isProtected(course)) throw new Error("Exams are only for paid or invite courses")
if (!schoolCrypto) throw new Error("School crypto unavailable")
const examTitle = String(title || "").trim()
if (!examTitle) throw new Error("Invalid title")
const rootId = course.rootId
const key = await ensureCourseKey(ssbClient, rootId)
if (!key) throw new Error("Course key unavailable")
let content = {
type: "schoolExam",
courseId: rootId,
title: examTitle,
lessonId: opts.lessonId ? String(opts.lessonId) : null,
author: ssbClient.id,
createdAt: new Date().toISOString()
}
content = schoolCrypto.encryptContent(content, [key], true)
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async addExamQuestion(courseId, examId, data) {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
if (course.author !== ssbClient.id) throw new Error("Unauthorized")
if (!schoolCrypto) throw new Error("School crypto unavailable")
const q = String(data.q || "").trim()
const options = [data.o1, data.o2, data.o3, data.o4].map(o => String(o || "").trim())
const correct = parseInt(String(data.correct), 10)
if (!q) throw new Error("Invalid question")
if (options.some(o => !o)) throw new Error("Four options are required")
if (!Number.isFinite(correct) || correct < 0 || correct > 3) throw new Error("Invalid correct option")
const key = await ensureCourseKey(ssbClient, course.rootId)
if (!key) throw new Error("Course key unavailable")
let content = {
type: "schoolExamQuestion",
courseId: course.rootId,
target: examId,
q,
options,
correct,
author: ssbClient.id,
createdAt: new Date().toISOString()
}
content = schoolCrypto.encryptContent(content, [key], true)
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async deleteExamQuestion(questionId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const node = idx.questionNodes.get(questionId)
if (!node || idx.tomb.has(questionId)) throw new Error("Question not found")
if (node.author !== ssbClient.id) throw new Error("Unauthorized")
const tombMsg = { type: "tombstone", target: questionId, deletedAt: new Date().toISOString(), author: ssbClient.id }
return new Promise((res, rej) => ssbClient.publish(tombMsg, (e, r) => e ? rej(e) : res(r)))
},
async deleteExam(examId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const node = idx.examNodes.get(examId)
if (!node || node.isMedia || idx.tomb.has(examId)) throw new Error("Exam not found")
if (node.author !== ssbClient.id) throw new Error("Unauthorized")
const tombMsg = { type: "tombstone", target: examId, deletedAt: new Date().toISOString(), author: ssbClient.id }
return new Promise((res, rej) => ssbClient.publish(tombMsg, (e, r) => e ? rej(e) : res(r)))
},
async listExams(courseId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const rootId = idx.rootOf(courseId)
const keys = schoolCrypto ? (schoolCrypto.getKeys(rootId) || []) : []
const exams = []
for (const node of idx.examNodes.values()) {
if (node.isMedia) continue
if (idx.tomb.has(node.key)) continue
if (idx.rootOf(node.c.courseId) !== rootId) continue
let c = node.c
let locked = false
if (c.encryptedPayload) {
const dec = keys.length && schoolCrypto ? schoolCrypto.decryptContent(c, keys.map(k => [k])) : { ...c, _undecryptable: true }
if (dec._undecryptable) locked = true
else c = dec
}
const myResult = idx.examResultLatest.get(`${node.key}::${ssbClient.id}`) || null
const results = []
for (const r of idx.examResultLatest.values()) {
if (r.examId === node.key) results.push({ author: r.author, score: r.score, total: r.total })
}
const questions = []
if (!locked) {
for (const qn of idx.questionNodes.values()) {
if (idx.tomb.has(qn.key)) continue
if (qn.c.target !== node.key) continue
let qc = qn.c
if (qc.encryptedPayload) {
const dec = keys.length && schoolCrypto ? schoolCrypto.decryptContent(qc, keys.map(k => [k])) : { ...qc, _undecryptable: true }
if (dec._undecryptable) continue
qc = dec
}
questions.push({ id: qn.key, q: qc.q, options: qc.options, correct: qc.correct, createdAt: qc.createdAt || new Date(qn.ts).toISOString() })
}
questions.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
}
const passMark = locked ? null : 10
exams.push({
id: node.key,
courseId: rootId,
locked,
title: locked ? null : c.title,
lessonId: locked ? null : (c.lessonId ? idx.lessonRootOf(c.lessonId) : null),
passMark,
questions,
author: node.author,
createdAt: c.createdAt || new Date(node.ts).toISOString(),
myResult: myResult ? { score: myResult.score, total: myResult.total, at: myResult.ts, passed: passMark != null && Number(myResult.score) >= passMark } : null,
results: results.map(r => ({ ...r, passed: passMark != null && Number(r.score) >= passMark }))
})
}
return exams.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
},
async takeExam(courseId, examId, answers) {
const ssbClient = await openSsb()
const me = ssbClient.id
const course = await this.getCourseById(courseId)
if (course.author === me) throw new Error("Cannot take your own exam")
if (!course.students.includes(me)) throw new Error("Only students can take exams")
const exams = await this.listExams(courseId)
const exam = exams.find(x => x.id === examId)
if (!exam || exam.locked) throw new Error("Exam not found")
if (exam.myResult && exam.myResult.passed) throw new Error("Exam already passed")
if (exam.myResult && Date.now() - Number(exam.myResult.at || 0) < 24 * 60 * 60 * 1000) throw new Error("Retry available after 24 hours")
if (!exam.questions.length) throw new Error("Exam has no questions")
let correctCount = 0
exam.questions.forEach((question, i) => {
const given = parseInt(String(Array.isArray(answers) ? answers[i] : (answers || {})[`q${i}`]), 10)
if (given === question.correct) correctCount += 1
})
const score = Math.round((correctCount / exam.questions.length) * 100) / 10
const msg = {
type: "schoolExamResult",
courseId: course.rootId,
examId,
score,
total: 10,
createdAt: new Date().toISOString()
}
return new Promise((res, rej) => ssbClient.private.publish(msg, [me, course.author], (e, m) => e ? rej(e) : res(m)))
},
async addLessonMaterial(courseId, lessonId, mediaMarkdown, caption = "") {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
if (course.author !== ssbClient.id) throw new Error("Unauthorized")
const media = String(mediaMarkdown || "").trim()
if (!media) throw new Error("Invalid material")
let content = {
type: "schoolLessonMedia",
courseId: course.rootId,
target: lessonId,
media,
caption: String(caption || "").trim() || null,
author: ssbClient.id,
createdAt: new Date().toISOString()
}
if (isProtected(course) && schoolCrypto) {
const key = await ensureCourseKey(ssbClient, course.rootId)
if (key) content = schoolCrypto.encryptContent(content, [key], true)
}
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async deleteLessonMaterial(materialId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const node = idx.examNodes.get("media:" + materialId)
if (!node || idx.tomb.has(materialId)) throw new Error("Material not found")
if (node.author !== ssbClient.id) throw new Error("Unauthorized")
const tombMsg = { type: "tombstone", target: materialId, deletedAt: new Date().toISOString(), author: ssbClient.id }
return new Promise((res, rej) => ssbClient.publish(tombMsg, (e, r) => e ? rej(e) : res(r)))
},
async listLessonMaterials(courseId, lessonId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const rootId = idx.rootOf(courseId)
const keys = schoolCrypto ? (schoolCrypto.getKeys(rootId) || []) : []
const materials = []
for (const node of idx.examNodes.values()) {
if (!node.isMedia) continue
if (idx.tomb.has(node.key)) continue
if (idx.rootOf(node.c.courseId) !== rootId) continue
if (idx.lessonRootOf(node.c.target) !== idx.lessonRootOf(lessonId)) continue
let c = node.c
let locked = false
if (c.encryptedPayload) {
const dec = keys.length && schoolCrypto ? schoolCrypto.decryptContent(c, keys.map(k => [k])) : { ...c, _undecryptable: true }
if (dec._undecryptable) locked = true
else c = dec
}
materials.push({
id: node.key,
lessonId,
locked,
media: locked ? null : (c.media || ""),
caption: locked ? null : (c.caption || null),
author: node.author,
createdAt: c.createdAt || new Date(node.ts).toISOString()
})
}
return materials.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
},
async generateInvite(courseId) {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
if (course.author !== ssbClient.id) throw new Error("Unauthorized")
if (course.visibility !== "INVITE") throw new Error("Only invite courses use invitation codes")
if (!schoolCrypto) throw new Error("School crypto unavailable")
if (course.inviteCode) {
const messages = await readAll(ssbClient)
const alive = messages.some(m => {
const c = m.value && m.value.content
if (!c || c.type !== "school-invite" || c.target !== course.rootId || m.value.author !== course.author) return false
try { return schoolCrypto.hashInviteCode(course.inviteCode, c.salt) === c.codeHash } catch { return false }
})
if (alive) return { code: course.inviteCode, courseId: course.rootId }
const key = await ensureCourseKey(ssbClient, course.rootId)
if (!key) throw new Error("Course key unavailable")
const salt = schoolCrypto.generateInviteSalt()
const ek = schoolCrypto.encryptForInvite(key, course.inviteCode, salt)
await new Promise((res, rej) => ssbClient.publish({
type: "school-invite", target: course.rootId, ek, salt,
codeHash: schoolCrypto.hashInviteCode(course.inviteCode, salt)
}, (e) => e ? rej(e) : res()))
return { code: course.inviteCode, courseId: course.rootId }
}
const key = await ensureCourseKey(ssbClient, course.rootId)
if (!key) throw new Error("Course key unavailable")
const code = nodeCrypto.randomBytes(16).toString("hex")
const salt = schoolCrypto.generateInviteSalt()
const ek = schoolCrypto.encryptForInvite(key, code, salt)
await new Promise((res, rej) => ssbClient.publish({
type: "school-invite", target: course.rootId, ek, salt,
codeHash: schoolCrypto.hashInviteCode(code, salt)
}, (e) => e ? rej(e) : res()))
try { await this.updateCourse(courseId, { inviteCode: code }) } catch (_) {}
return { code, courseId: course.rootId }
},
async joinByInvite(rawCode) {
if (!schoolCrypto) throw new Error("School crypto unavailable")
const ssbClient = await openSsb()
const me = ssbClient.id
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const candidates = Array.from(new Set([String(rawCode || "").trim(), String(rawCode || "").trim().toLowerCase()])).filter(Boolean)
let matched = null
let code = null
for (const m of messages) {
const c = m.value && m.value.content
if (!c || c.type !== "school-invite") continue
const owner = idx.courseNodes.get(idx.rootOf(c.target))
if (!owner || owner.author !== m.value.author) continue
for (const cand of candidates) {
try {
if (schoolCrypto.hashInviteCode(cand, c.salt) === c.codeHash) { matched = c; code = cand; break }
} catch {}
}
if (matched) break
}
if (!matched) throw new Error("Invalid or expired invite code")
const courseKey = schoolCrypto.decryptFromInvite(matched.ek, code, matched.salt)
if (!courseKey) throw new Error("Could not decrypt invite")
schoolCrypto.addNewKey(matched.target, courseKey)
const tipId = idx.tipOf(matched.target)
const node = idx.courseNodes.get(tipId)
if (!node) throw new Error("Course not found")
const teacherId = node.author
let cc = node.c
if (cc.encryptedPayload) {
const dec = schoolCrypto.decryptContent(cc, [[courseKey]])
if (dec && !dec._undecryptable) cc = dec
}
const priceN = toNum(cc.price)
if (teacherId !== me) {
const enrollments = idx.enrollByCourse.get(matched.target) || new Map()
if (!enrollments.has(me)) {
let transferId = null
if (Number.isFinite(priceN) && priceN > 0 && transfersModel) {
const deadline = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString()
const bill = await transfersModel.createTransfer(teacherId, `SCHOOL ${matched.target}`, priceN.toFixed(6), deadline, ["SCHOOL"], "ECONOMIC")
transferId = bill && bill.key ? bill.key : null
}
const msg = { type: "schoolEnroll", courseId: matched.target, value: true, transferId, keyProof: keyProofFor(courseKey, me), createdAt: new Date().toISOString() }
await new Promise((res, rej) => ssbClient.private.publish(msg, [me, teacherId], (e, m) => e ? rej(e) : res(m)))
}
const enrollChatId = cc.chatId || idx.chatByCourse.get(matched.target) || null
if (enrollChatId) {
try {
await new Promise((res) => ssbClient.publish({ type: "chatMember", target: enrollChatId, member: me, on: true, createdAt: new Date().toISOString() }, () => res()))
} catch {}
}
}
return { ok: true, courseId: matched.target }
},
async grantAccess(courseId, studentId) {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
if (course.author !== ssbClient.id) throw new Error("Unauthorized")
if (!schoolCrypto) throw new Error("School crypto unavailable")
const ids = normalizeIds(studentId)
if (!ids.length) throw new Error("Invalid student id")
for (const student of ids) {
const known = course.students.includes(student) || course.invited.includes(student) || course.pending.some(p => p.author === student)
if (!known) throw new Error("Not a student of this course")
}
const key = await ensureCourseKey(ssbClient, course.rootId)
const grant = await publishKeyGrant(ssbClient, course.rootId, key, ids)
await addToChat(ssbClient, course, ids)
return grant
},
async updateLesson(courseId, lessonId, data) {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
if (course.author !== ssbClient.id) throw new Error("Unauthorized")
const lessons = await this.listLessons(course.rootId)
const current = lessons.find(l => l.id === lessonId)
if (!current || current.locked) throw new Error("Lesson not found")
const title = data.title !== undefined ? String(data.title || "").trim() : current.title
const text = data.text !== undefined ? String(data.text || "").trim() : current.text
if (!title || !text) throw new Error("Invalid lesson")
const orderN = toNum(data.order)
let content = {
type: "schoolLesson",
courseId: course.rootId,
title,
text,
unit: data.unit !== undefined ? (String(data.unit || "").trim() || null) : current.unit,
order: data.order !== undefined ? (Number.isFinite(orderN) ? orderN : null) : current.order,
sessionDate: data.sessionDate !== undefined ? normalizeDate(data.sessionDate) : current.sessionDate,
author: ssbClient.id,
createdAt: current.createdAt,
updatedAt: new Date().toISOString(),
replaces: lessonId
}
if (isProtected(course) && schoolCrypto) {
const key = await ensureCourseKey(ssbClient, course.rootId)
if (key) content = schoolCrypto.encryptContent(content, [key], true)
}
const tombMsg = { type: "tombstone", target: lessonId, deletedAt: new Date().toISOString(), author: ssbClient.id }
await new Promise((res, rej) => ssbClient.publish(tombMsg, (e) => e ? rej(e) : res()))
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async deleteLesson(lessonId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const node = idx.lessonNodes.get(lessonId)
if (!node || idx.tomb.has(lessonId)) throw new Error("Lesson not found")
if (node.author !== ssbClient.id) throw new Error("Unauthorized")
const tombMsg = {
type: "tombstone",
target: lessonId,
deletedAt: new Date().toISOString(),
author: ssbClient.id
}
return new Promise((res, rej) => ssbClient.publish(tombMsg, (e, r) => e ? rej(e) : res(r)))
},
async listLessons(courseId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const rootId = idx.rootOf(courseId)
const keys = schoolCrypto ? (schoolCrypto.getKeys(rootId) || []) : []
const lessons = []
for (const node of idx.lessonNodes.values()) {
if (idx.tomb.has(node.key)) continue
if (idx.lessonChildOf.has(node.key)) continue
if (idx.rootOf(node.c.courseId) !== rootId) continue
let c = node.c
let locked = false
if (c.encryptedPayload) {
const dec = keys.length && schoolCrypto ? schoolCrypto.decryptContent(c, keys.map(k => [k])) : { ...c, _undecryptable: true }
if (dec._undecryptable) locked = true
else c = dec
}
lessons.push({
id: node.key,
courseId: rootId,
locked,
title: locked ? null : c.title,
text: locked ? null : c.text,
unit: locked ? null : (c.unit || null),
order: locked ? null : (Number.isFinite(toNum(c.order)) ? toNum(c.order) : null),
sessionDate: locked ? null : (c.sessionDate || null),
author: node.author,
createdAt: c.createdAt || new Date(node.ts).toISOString()
})
}
const myProgress = (idx.progressByCourse.get(rootId) || new Map()).get(ssbClient.id) || new Set()
for (const lesson of lessons) lesson.completed = myProgress.has(idx.lessonRootOf(lesson.id))
return lessons.sort((a, b) =>
((a.order ?? Infinity) - (b.order ?? Infinity)) || (new Date(a.createdAt) - new Date(b.createdAt))
)
},
async hasPassedCourse(courseId, studentId) {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
const student = normalizeIds(studentId)[0] || ssbClient.id
const lessons = await this.listLessons(course.rootId)
if (!lessons.length) return false
const exams = await this.listExams(course.rootId)
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const progressSet = (idx.progressByCourse.get(course.rootId) || new Map()).get(student) || new Set()
for (const lesson of lessons) {
const lessonRoot = idx.lessonRootOf(lesson.id)
const lessonExams = exams.filter(x => !x.locked && x.questions.length && x.lessonId && x.lessonId === lessonRoot)
if (lessonExams.length) {
for (const x of lessonExams) {
const r = idx.examResultLatest.get(`${x.id}::${student}`)
if (!r || x.passMark == null || Number(r.score) < x.passMark) return false
}
} else if (!progressSet.has(lessonRoot)) {
return false
}
}
return true
},
async issueCertificate(courseId, studentId, text = "") {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
if (course.author !== ssbClient.id) throw new Error("Unauthorized")
const student = normalizeIds(studentId)[0]
if (!student) throw new Error("Invalid student id")
if (!course.students.includes(student)) throw new Error("Student is not enrolled")
const lessons = await this.listLessons(course.rootId)
if (lessons.length) {
const exams = await this.listExams(course.rootId)
const messages2 = await readAll(ssbClient)
const idx2 = indexOf(messages2, ssbClient)
const progressSet = (idx2.progressByCourse.get(course.rootId) || new Map()).get(student) || new Set()
for (const lesson of lessons) {
const lessonRoot = idx2.lessonRootOf(lesson.id)
const lessonExams = exams.filter(x => !x.locked && x.lessonId && x.lessonId === lessonRoot)
if (lessonExams.length) {
for (const x of lessonExams) {
const r = idx2.examResultLatest.get(`${x.id}::${student}`)
if (!r || x.passMark == null || Number(r.score) < x.passMark) throw new Error("Student has not passed all lessons")
}
} else if (!progressSet.has(lessonRoot)) {
throw new Error("Student has not completed all lessons")
}
}
}
const rootId = course.rootId || (await this.resolveRootId(courseId))
const existing = await this.listCertificates(rootId)
if (existing.some(cert => cert.student === student)) return { alreadyIssued: true }
const content = {
type: "schoolCertificate",
courseId: rootId,
courseTitle: course.title,
student,
text: String(text || "").trim(),
author: ssbClient.id,
createdAt: new Date().toISOString()
}
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async listCertificates(courseId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const rootId = idx.rootOf(courseId)
const certs = []
for (const node of idx.certNodes.values()) {
if (idx.tomb.has(node.key)) continue
if (idx.rootOf(node.c.courseId) !== rootId) continue
certs.push({
id: node.key,
courseId: rootId,
courseTitle: node.c.courseTitle,
student: node.c.student,
text: node.c.text || "",
author: node.author,
createdAt: node.c.createdAt || new Date(node.ts).toISOString()
})
}
return certs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
},
async listCertificatesForStudent(studentId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const certs = []
for (const node of idx.certNodes.values()) {
if (idx.tomb.has(node.key)) continue
if (node.c.student !== studentId) continue
certs.push({
id: node.key,
courseId: idx.rootOf(node.c.courseId),
courseTitle: node.c.courseTitle,
student: node.c.student,
text: node.c.text || "",
author: node.author,
createdAt: node.c.createdAt || new Date(node.ts).toISOString()
})
}
return certs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
},
async lessonRootOf(lessonId) {
const ssbClient = await openSsb()
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
return idx.lessonRootOf(lessonId) || lessonId
},
async listHiddenComments(courseId) {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
const messages = await readAll(ssbClient)
const hidden = new Set()
for (const m of messages) {
const c = m.value?.content
if (!c || c.type !== "schoolCommentHide" || !c.target) continue
if (m.value?.author !== course.author) continue
if (c.courseId !== course.rootId) continue
hidden.add(c.target)
}
return hidden
},
async hideComment(courseId, commentId) {
const ssbClient = await openSsb()
const course = await this.getCourseById(courseId)
if (course.author !== ssbClient.id) throw new Error("Only the teacher can hide comments")
const content = { type: "schoolCommentHide", courseId: course.rootId, target: commentId, author: ssbClient.id, createdAt: new Date().toISOString() }
return new Promise((res, rej) => ssbClient.publish(content, (e, m) => e ? rej(e) : res(m)))
},
async listCourses(filter = "ALL", viewerId = null, query = {}) {
const ssbClient = await openSsb()
const viewer = viewerId || ssbClient.id
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const courses = []
for (const [rootId, tipId] of idx.tipByRoot.entries()) {
if (idx.tomb.has(tipId)) continue
const node = idx.courseNodes.get(tipId)
if (!node) continue
const course = buildCourseObject(node, rootId, idx.enrollByCourse.get(rootId), idx.transferAgg, idx.grantsByCourse.get(rootId), idx.opinionsByCourse.get(rootId), idx.chatByCourse)
if (course.undecryptable) continue
if (!canView(course, viewer)) continue
courses.push(course)
}
const F = String(filter || "ALL").toUpperCase()
let list = courses
if (F === "MINE") list = list.filter(c => c.author === viewer)
else if (F === "APPLIED") list = list.filter(c => c.students.includes(viewer) || c.pending.some(p => p.author === viewer))
else if (F === "OPEN") list = list.filter(c => !(Number(c.price) > 0))
else if (F === "ONGOING") list = list.filter(c => c.status === "ONGOING")
else if (F === "CLOSED") list = list.filter(c => c.status === "CLOSED")
else if (F === "RECENT") list = list.filter(c => moment(c.createdAt).isAfter(moment().subtract(24, "hours")))
else if (F === "TOP") list = [...list].sort((a, b) => b.students.length - a.students.length)
const search = String(query.q || query.search || "").trim()
if (search) list = list.filter(c => matchSearch(c, search))
const sort = String(query.sort || "").trim()
const byRecent = () => list.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
if (sort === "students") list.sort((a, b) => b.students.length - a.students.length)
else if (sort === "title") list.sort((a, b) => String(a.title).localeCompare(String(b.title)))
else if (F !== "TOP") byRecent()
return list
},
async getCourseById(id, viewerId = null) {
const ssbClient = await openSsb()
const viewer = viewerId || ssbClient.id
const messages = await readAll(ssbClient)
const idx = indexOf(messages, ssbClient)
const tipId = idx.tipOf(id)
const rootId = idx.rootOf(id)
if (idx.tomb.has(tipId) || idx.vg.isForeignCopy(rootId)) throw new Error("Course not found")
const node = idx.courseNodes.get(tipId)
if (!node) throw new Error("Course not found")
const course = buildCourseObject(node, rootId, idx.enrollByCourse.get(rootId), idx.transferAgg, idx.grantsByCourse.get(rootId), idx.opinionsByCourse.get(rootId), idx.chatByCourse)
if (course.undecryptable) throw new Error("Course not found")
if (!canView(course, viewer)) throw new Error("Course not found")
return course
}
}
}