diff --git a/backend/alembic/versions/e5f6a7b8c9d0_drop_comments.py b/backend/alembic/versions/e5f6a7b8c9d0_drop_comments.py
new file mode 100644
index 0000000..ab2341d
--- /dev/null
+++ b/backend/alembic/versions/e5f6a7b8c9d0_drop_comments.py
@@ -0,0 +1,37 @@
+"""Drop the comments table.
+
+Comments were a discussion thread under every article and question. Nothing
+read them into anyone's work, so they collected opinion nobody was obliged to
+answer. Feedback replaced them: a message addressed to whoever maintains the
+question, which an educator resolves, replies to, or deletes.
+
+The table was empty when this was written, so nothing is lost. The downgrade
+recreates the shape but cannot recreate rows.
+
+Revision ID: e5f6a7b8c9d0
+Revises: d4e5f6a7b8c9
+"""
+import sqlalchemy as sa
+from alembic import op
+
+revision = "e5f6a7b8c9d0"
+down_revision = "d4e5f6a7b8c9"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.drop_table("comments")
+
+
+def downgrade() -> None:
+ op.create_table(
+ "comments",
+ sa.Column("id", sa.Integer(), primary_key=True, index=True),
+ sa.Column("article_id", sa.Integer(), sa.ForeignKey("articles.id", ondelete="CASCADE"), nullable=True),
+ sa.Column("question_id", sa.Integer(), sa.ForeignKey("questions.id", ondelete="CASCADE"), nullable=True),
+ sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
+ sa.Column("content", sa.Text(), nullable=False),
+ sa.Column("status", sa.String(), server_default="pending"),
+ sa.Column("created_at", sa.DateTime(), nullable=True),
+ )
diff --git a/backend/app/main.py b/backend/app/main.py
index b5cd3c2..50a717b 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -13,7 +13,7 @@ from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote, exams
from app.routers import access
from app.routers import feedback
-from app.routers import study_tools, uploads, articles, comments, share, collections, study_plans, media, search, ai_mode
+from app.routers import study_tools, uploads, articles, share, collections, study_plans, media, search, ai_mode
from app.utils.auth import get_password_hash
@@ -619,7 +619,6 @@ app.include_router(feedback.router, prefix="/api/feedback", tags=["feedback"])
app.include_router(exams.router, prefix="/api/exams", tags=["exams"])
app.include_router(study_plans.router, prefix="/api/study-plans", tags=["study-plans"])
app.include_router(media.router, prefix="/api/media", tags=["media"])
-app.include_router(comments.router, prefix="/api/comments", tags=["comments"])
app.include_router(share.router, prefix="/api/share", tags=["share"])
app.include_router(collections.router, prefix="/api/collections", tags=["collections"])
app.include_router(documents.router, prefix="/api/documents", tags=["documents"])
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index c6dd29c..4c7eae3 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -9,7 +9,6 @@ from app.models.favorite import Favorite
from app.models.user_note import UserNote
from app.models.lab_reference import LabReference, LabReferenceCardLink
from app.models.article import Article, QuestionArticleLink
-from app.models.comment import Comment
from app.models.flashcard import FlashcardDeck, Flashcard, FlashcardDeckRating, FlashcardQuestionLink, FlashcardArticleLink
from app.models.question_category import QuestionCategory, QuestionCategoryLink
from app.models.collection import UserCollection, UserCollectionQuestion
@@ -29,7 +28,6 @@ __all__ = [
"LabReferenceCardLink",
"Article",
"QuestionArticleLink",
- "Comment",
"FlashcardDeck",
"Flashcard",
"FlashcardDeckRating",
diff --git a/backend/app/models/comment.py b/backend/app/models/comment.py
deleted file mode 100644
index fc13b22..0000000
--- a/backend/app/models/comment.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from datetime import datetime
-
-from sqlalchemy import Column, Integer, Text, String, DateTime, ForeignKey
-from sqlalchemy.orm import relationship
-
-from app.database import Base
-
-
-class Comment(Base):
- """Moderated discussion comment on an article or a question."""
-
- __tablename__ = "comments"
-
- id = Column(Integer, primary_key=True, index=True)
- article_id = Column(Integer, ForeignKey("articles.id", ondelete="CASCADE"), nullable=True)
- question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), nullable=True)
- user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
- content = Column(Text, nullable=False)
- status = Column(String, default="pending") # pending | approved | rejected
- created_at = Column(DateTime, default=datetime.utcnow)
-
- user = relationship("User")
diff --git a/backend/app/routers/comments.py b/backend/app/routers/comments.py
deleted file mode 100644
index 49a5776..0000000
--- a/backend/app/routers/comments.py
+++ /dev/null
@@ -1,158 +0,0 @@
-"""Moderated comments on articles and questions."""
-from fastapi import APIRouter, Depends, HTTPException, Query
-from pydantic import BaseModel, field_validator
-from sqlalchemy.orm import Session
-
-from app.database import get_db
-from app.models.article import Article
-from app.models.comment import Comment
-from app.models.question import Question
-from app.models.user import User
-from app.services.quiz_builder import bank_question_predicate
-from app.utils.auth import check_rate_limit, get_current_user, require_moderator
-
-router = APIRouter()
-
-
-class CommentCreate(BaseModel):
- article_id: int | None = None
- question_id: int | None = None
- content: str
-
- @field_validator("content")
- @classmethod
- def content_shape(cls, value):
- value = value.strip()
- if not value:
- raise ValueError("Comment cannot be empty")
- if len(value) > 2000:
- raise ValueError("Comment is too long (max 2000 characters)")
- return value
-
-
-class CommentModerate(BaseModel):
- status: str
-
- @field_validator("status")
- @classmethod
- def status_shape(cls, value):
- if value not in ("pending", "approved", "rejected"):
- raise ValueError("Invalid moderation status")
- return value
-
-
-def _target(db: Session, article_id: int | None, question_id: int | None, user: User):
- if (article_id is None) == (question_id is None):
- raise HTTPException(400, "Provide exactly one of article_id or question_id")
- if article_id is not None:
- article = db.get(Article, article_id)
- if not article:
- raise HTTPException(404, "Article not found")
- if article.status != "published" and not user.is_moderator:
- raise HTTPException(404, "Article not found")
- return "article_id", article
- question = db.get(Question, question_id)
- if not question or not db.query(Question.id).filter(
- Question.id == question_id, bank_question_predicate(user)).first():
- raise HTTPException(404, "Question not found")
- return "question_id", question
-
-
-@router.post("/")
-def create_comment(
- data: CommentCreate,
- db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user),
-):
- target_field, _ = _target(db, data.article_id, data.question_id, current_user)
- check_rate_limit(
- key=f"comments:daily:{current_user.id}",
- max_calls=20,
- window_seconds=86400,
- detail="You've reached today's comment limit. Try again tomorrow.",
- user=current_user,
- )
- comment = Comment(article_id=data.article_id, question_id=data.question_id,
- user_id=current_user.id, content=data.content, status="pending")
- db.add(comment)
- db.commit()
- db.refresh(comment)
- return _json(db, comment, current_user)
-
-
-def _json(db: Session, comment: Comment, user: User):
- author = db.get(User, comment.user_id) if comment.user_id else None
- return {
- "id": comment.id,
- "article_id": comment.article_id,
- "question_id": comment.question_id,
- "user_id": comment.user_id,
- "author_name": author.name if author else "Unknown",
- "content": comment.content,
- "status": comment.status,
- "created_at": comment.created_at,
- "own": comment.user_id == user.id,
- "can_moderate": user.is_moderator,
- }
-
-
-@router.get("/")
-def list_comments(
- article_id: int | None = Query(None),
- question_id: int | None = Query(None),
- limit: int = Query(20, le=100),
- offset: int = Query(0),
- db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user),
-):
- target_field, target = _target(db, article_id, question_id, current_user)
- target_id = getattr(target, "id")
- query = db.query(Comment).filter(getattr(Comment, target_field) == target_id).filter(
- (Comment.status == "approved") | (Comment.user_id == current_user.id),
- )
- total = query.count()
- comments = query.order_by(Comment.created_at.desc()).offset(offset).limit(limit).all()
- return {"total": total, "comments": [_json(db, c, current_user) for c in comments]}
-
-
-@router.get("/moderation")
-def list_pending_comments(
- limit: int = Query(50, le=200),
- offset: int = Query(0),
- db: Session = Depends(get_db),
- current_user: User = Depends(require_moderator),
-):
- query = db.query(Comment).filter(Comment.status == "pending")
- total = query.count()
- comments = query.order_by(Comment.created_at.asc()).offset(offset).limit(limit).all()
- return {"total": total, "comments": [_json(db, c, current_user) for c in comments]}
-
-
-@router.patch("/{comment_id}")
-def moderate_comment(
- comment_id: int,
- data: CommentModerate,
- db: Session = Depends(get_db),
- current_user: User = Depends(require_moderator),
-):
- comment = db.get(Comment, comment_id)
- if not comment:
- raise HTTPException(404, "Comment not found")
- comment.status = data.status
- db.commit()
- return _json(db, comment, current_user)
-
-
-@router.delete("/{comment_id}", status_code=204)
-def delete_comment(
- comment_id: int,
- db: Session = Depends(get_db),
- current_user: User = Depends(get_current_user),
-):
- comment = db.get(Comment, comment_id)
- if not comment:
- raise HTTPException(404, "Comment not found")
- if comment.user_id != current_user.id and not current_user.is_moderator:
- raise HTTPException(403, "Not your comment")
- db.delete(comment)
- db.commit()
diff --git a/backend/tests/test_comments_ai.py b/backend/tests/test_article_ai.py
similarity index 67%
rename from backend/tests/test_comments_ai.py
rename to backend/tests/test_article_ai.py
index 422edb8..866d842 100644
--- a/backend/tests/test_comments_ai.py
+++ b/backend/tests/test_article_ai.py
@@ -1,4 +1,4 @@
-"""Comments moderation and AI authoring endpoints on disposable SQLite; no network/AI."""
+"""AI authoring endpoints on disposable SQLite; no network, no model calls."""
import json
import re
import sys
@@ -7,10 +7,9 @@ from unittest.mock import Mock, patch
import test_quiz_builder as fixtures
from app.models.article import Article
-from app.models.comment import Comment
from app.models.flashcard import Flashcard, FlashcardDeck, FlashcardArticleLink
from app.models.question import Question
-from app.routers import articles, comments
+from app.routers import articles
from app.tasks.quiz_tasks import generate_article_draft, generate_article_cards
from sqlalchemy.orm import sessionmaker
@@ -23,13 +22,12 @@ DRAFT_RESPONSE = {
}
-class CommentsAiTests(unittest.TestCase):
+class ArticleAiTests(unittest.TestCase):
def setUp(self):
self.bank = fixtures.BuilderTests()
self.bank.setUp()
self.client = self.bank.client
self.client.app.include_router(articles.router, prefix='/articles')
- self.client.app.include_router(comments.router, prefix='/comments')
self.redis = Mock()
self.redis.from_url.return_value = self.redis
self.redis.get.return_value = None
@@ -49,49 +47,6 @@ class CommentsAiTests(unittest.TestCase):
def publish(self, article_id):
return self.client.post(f'/articles/{article_id}/publish', json={'published': True})
- def test_comment_visibility_moderation_and_bounds(self):
- self.bank.user = self.bank.mod
- article = self.client.post('/articles/', json={
- "title": "Discussed", "slug": "discussed", "content": "Intro", "sections": []}).json()
- self.publish(article['id'])
- self.bank.user = self.bank.owner
- created = self.client.post('/comments/', json={'article_id': article['id'], 'content': 'Owner pending note'})
- self.assertEqual(created.status_code, 200, created.text)
- self.assertEqual(created.json()['status'], 'pending')
- self.assertEqual(created.json()['own'], True)
- for payload in ({'article_id': article['id'], 'question_id': 1, 'content': 'x'},
- {'article_id': article['id'], 'content': ' '},
- {'article_id': article['id'], 'content': 'x' * 2001},
- {'article_id': 999, 'content': 'x'},
- {'question_id': 999, 'content': 'x'}):
- self.assertIn(self.client.post('/comments/', json=payload).status_code, (400, 404, 422), payload)
- self.assertEqual(self.client.post('/comments/', json={'article_id': article['id'], 'content': 'Rate limited'}).status_code, 429)
- listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
- self.assertEqual(listing['total'], 1) # Own pending visible.
- self.bank.user = self.bank.peer
- listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
- self.assertEqual(listing['total'], 0) # Others' pending hidden.
- self.assertEqual(self.client.get('/comments/moderation').status_code, 403)
- self.assertEqual(self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'approved'}).status_code, 403)
- self.bank.user = self.bank.mod
- pending = self.client.get('/comments/moderation').json()
- self.assertEqual(pending['total'], 1)
- approved = self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'approved'})
- self.assertEqual(approved.status_code, 200)
- self.assertEqual(approved.json()['author_name'], 'Owner')
- self.bank.user = self.bank.peer
- listing = self.client.get('/comments/', params={'article_id': article['id']}).json()
- self.assertEqual(listing['total'], 1)
- self.assertEqual(self.client.patch(f"/comments/{created.json()['id']}", json={'status': 'rejected'}).status_code, 403)
- # Question comments follow bank visibility.
- self.bank.user = self.bank.peer
- self.assertEqual(self.client.post('/comments/', json={'question_id': 3, 'content': 'Private leak'}).status_code, 404)
- self.redis.incr.side_effect = [1]
- self.bank.user = self.bank.owner
- self.assertEqual(self.client.post('/comments/', json={'question_id': 3, 'content': 'Own question'}).status_code, 200)
- self.bank.user = self.bank.mod
- self.assertEqual(self.client.delete(f"/comments/{created.json()['id']}").status_code, 204)
-
def test_ai_endpoints_queue_and_poll(self):
self.bank.user = self.bank.mod
response = self.client.post('/articles/ai-draft', json={'topic': 'Neonatal jaundice', 'instructions': 'Two sections'})
diff --git a/docs/TODO.md b/docs/TODO.md
index d664bd3..46c440c 100644
--- a/docs/TODO.md
+++ b/docs/TODO.md
@@ -301,6 +301,44 @@ Captured so nothing is lost while the article writing runs.
toggle, "Continue your study", and a study-analysis donut. The current
dashboard becomes this; a separate signed-out landing page comes later.
+## Collections, as shown 2026-09-11 (evening)
+
+- [ ] **A collections page.** Favorites and the question libraries in one
+ place, as AMBOSS has it: a Table view / Card view pair, a sort control
+ ("Last used", descending by default), a count line — "Showing: 1
+ collection" — and a search by title or date. Each collection is a card
+ with its name, its item count, whether it is private, and a ⋯ menu.
+ `/collections/` already backs the folders; this is the page that is
+ missing.
+
+## Search and AI Mode, as shown 2026-09-11 (evening)
+
+Two screenshots, one flow.
+
+- [ ] **The search overlay.** A panel that opens over whatever you are on, with
+ a *Search* / *AI Mode* pair of tabs at the top — the same box asks the
+ corpus or asks the model, and which one is a toggle rather than two
+ separate destinations. Below the field: SEARCH HISTORY, the previous
+ queries, and the keys spelled out — `Ctrl+K` open, `↑↓` navigate
+ suggestions, `Space` use a suggestion, `Enter` submit. Opens from
+ anywhere with Ctrl+K.
+- [ ] **The overlay in AI Mode.** Switching the toggle to *AI Mode* changes
+ what the same field does: the history and the key hints go, a mode
+ picker appears beside the tabs ("Learning ⌄" — what the assistant is
+ being asked to be), a clear button appears in the field once there is
+ text, and the submit arrow fills in. The panel's own title tracks the
+ chosen mode.
+- [ ] **Submitting from AI Mode lands in the conversation.** The overlay is
+ only where the question is typed: pressing submit goes to the AI Mode
+ chat with that question already asked and being answered — not to a
+ results list, and not back to an empty box.
+- [ ] **AI Mode as a page.** A conversation rail on the left grouped by age
+ ("Previous 7 days") with a collapse and a compose control; the empty
+ state centres one large ask box — "How can PedsHub help you today?" —
+ with attach and dictate in the box, a tip line under it, and three or
+ four starting prompts as pills with a SHOW MORE beneath. The existing
+ `AiModePage` becomes this.
+
## Taxonomy
- [x] **Systems subsystems** — done 2026-09-10. 69 subsystems created, 305
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 7a0fdec..29aca0d 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -15,7 +15,6 @@ const DocumentDetailPage = lazyPage(() => import('./pages/DocumentDetailPage'))
const QuizPage = lazyPage(() => import('./pages/QuizPage'))
const CustomQuizPage = lazyPage(() => import('./pages/CustomQuizPage'))
const ResultsPage = lazyPage(() => import('./pages/ResultsPage'))
-const AdminPage = lazyPage(() => import('./pages/AdminPage'))
const AccountPage = lazyPage(() => import('./pages/AccountPage'))
const SettingsPage = lazyPage(() => import('./pages/SettingsPage'))
const QuestionBankPage = lazyPage(() => import('./pages/QuestionBankPage'))
@@ -154,7 +153,10 @@ function AppRoutes() {
Your account, how the site looks, and what it shows you.
+Your account, how the site looks, and — if it is yours to set — how the site behaves.