Skip to main content

Content Service

This page covers lumie-backend/modules/content.

Responsibility

The content module owns:

  • announcements, announcement class targeting, and announcement read tracking;
  • Q&A boards and comment threads;
  • reviews and the review-popup toggle.

Source Paths

PathRole
lumie-backend/modules/content/src/main/java/com/lumie/content/adapter/in/webPublic HTTP controllers for announcements, Q&A, and reviews
lumie-backend/modules/content/src/main/java/com/lumie/content/adapter/in/internal/ContentServiceAdapter.javaInternal monolith API implementation
lumie-backend/modules/content/src/main/java/com/lumie/content/application/serviceAnnouncement, Q&A, and review services
lumie-backend/modules/content/src/main/java/com/lumie/content/domain/entityContent aggregates and supporting tables
lumie-backend/libs/internal-api/src/main/java/com/lumie/content/api/ContentService.javaInternal contract for announcement creation and visibility checks
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sqlBaseline tables such as announcements, qna_boards, qna_comments, reviews, and toggles
lumie-backend/app/src/main/resources/db/migration/public/V20__rename_qna_is_it_answered.sqlRenames qna_boards.is_it_answered to is_answered and adds version
lumie-backend/app/src/main/resources/db/migration/public/V24__add_version_to_domain_mutable_entities.sqlAdds version to mutable content tables
lumie-backend/app/src/main/resources/db/migration/public/V43__qna_boards_add_category_name.sqlLegacy import support for Q&A category data
lumie-backend/app/src/main/resources/db/migration/public/V55__create_announcement_class_targets.sqlCreates class-target visibility rows for announcements
lumie-backend/app/src/main/resources/db/migration/public/V56__announcement_class_targets_tenant_safe_fk.sqlMakes the announcement target FK tenant-safe
lumie-backend/app/src/main/resources/db/migration/public/V62__add_read_receipt_tables.sqlCreates announcement_reads
lumie-backend/app/src/main/resources/db/migration/public/V65__repair_read_receipt_and_file_download_rls.sqlRepairs tenant-safe RLS and FK behavior for announcement reads

Public Surface

SurfaceEntrypoints
AnnouncementsGET/POST /v1/announcements, GET /v1/announcements/important, GET /v1/announcements/{id}, GET /v1/announcements/{id}/readers, PATCH /v1/announcements/{id}, DELETE /v1/announcements/{id}
Q&AGET/POST /v1/qna, GET /v1/qna/unanswered, GET /v1/qna/statistics/unanswered-overdue, GET /v1/qna/user/{qnaUserId}, GET/PATCH/DELETE /v1/qna/{id}, POST /v1/qna/{id}/comments, DELETE /v1/qna/{qnaId}/comments/{commentId}
ReviewsPOST /v1/reviews, GET /v1/reviews, DELETE /v1/reviews/{id}, GET /v1/reviews/popup-setting, PUT /v1/reviews/popup-setting

Internal Surface

ContentService is consumed synchronously by other modules and currently exposes:

  • createAnnouncement(...);
  • canUserViewAnnouncement(...);
  • canUserViewQna(...).

The internal API is intentionally operational rather than generic CRUD. It exports visibility checks and a small announcement-creation helper, not full content mutation.

Aggregates And Tables

AggregateTableNotes
AnnouncementannouncementsMain announcement record
AnnouncementClassTargetannouncement_class_targetsOptional class-target visibility filter
AnnouncementReadannouncement_readsPer-user read receipt inserted idempotently
QnaBoardqna_boardsQuestion thread with is_answered and author_last_read_at
QnaCommentqna_commentsAdmin or student comments
ReviewreviewsPublic review text
ReviewPopupSettingtogglesReview-popup switch, keyed by fixed ID

The ReviewPopupSetting aggregate is a special case: it stores the review-popup state in the generic toggles table rather than a content-specific settings table.

Runtime Flows

Announcement visibility and read tracking

What the class-target contract looks like

private void validateClassTargets(List<Long> classIds) {
String tenantSlug = TenantContextHolder.getRequiredTenant();
for (Long classId : classIds) {
if (classService.getClass(tenantSlug, classId).isEmpty()) {
throw new BusinessException(ContentErrorCode.INVALID_CLASS_TARGET);
}
}
}

Class-targeted announcements are validated through the class module. The content module stores only class IDs, not a classroom aggregate copy.

Key Behaviors

Announcements

  • Staff can attach class targets; students only see announcements targeted to one of their enrolled classes or announcements without targets.
  • Student reads call announcementReadRepository.insertIfAbsent(...), so repeated reads do not create duplicate rows.
  • Announcement responses intentionally hide classIds from student callers and only expose them to staff-side callers.
  • File links are managed through FileService.replaceFileLinks(...), deleteFileLinksByEntity(...), and deleteFilesByEntity(...).

Q&A

  • QnaBoard.addComment(...) marks a thread answered when an admin comment is added.
  • Removing the last admin comment flips the thread back to unanswered.
  • Reading a thread as its author updates author_last_read_at.
  • Student visibility is owner-only. Staff visibility is unrestricted inside the tenant.

Representative Contract Example

This example matches CreateAnnouncementRequest, AnnouncementResponse, AnnouncementController, AnnouncementCommandService, and AnnouncementQueryService.

Staff creates a class-targeted announcement

POST /v1/announcements

{
"authorId": 900,
"announcementTitle": "보강 일정 안내",
"announcementContent": "이번 주 토요일 10시에 보강 수업이 있습니다.",
"isItAssetAnnouncement": false,
"isItImportantAnnouncement": true,
"textbookFileIds": [],
"classIds": [2, 3]
}
{
"id": 1,
"authorId": 900,
"authorName": "원장",
"announcementTitle": "보강 일정 안내",
"announcementContent": "이번 주 토요일 10시에 보강 수업이 있습니다.",
"isItAssetAnnouncement": false,
"isItImportantAnnouncement": true,
"createdAt": "2026-06-14T09:00:00Z",
"updatedAt": "2026-06-14T09:00:00Z",
"classIds": [2, 3]
}

Student reads the same announcement

GET /v1/announcements/1

{
"id": 1,
"authorId": 900,
"authorName": "원장",
"announcementTitle": "보강 일정 안내",
"announcementContent": "이번 주 토요일 10시에 보강 수업이 있습니다.",
"isItAssetAnnouncement": false,
"isItImportantAnnouncement": true,
"createdAt": "2026-06-14T09:00:00Z",
"updatedAt": "2026-06-14T09:00:00Z",
"classIds": []
}

The student response intentionally strips classIds through responseClassIds(...). If the current student is not enrolled in one of the target classes, getAnnouncement(...) returns ANNOUNCEMENT_NOT_FOUND instead of a separate visibility error.

Dependencies And Boundaries

DependencyWhy it exists
ClassServiceValidate announcement class targets and student class visibility
StudentServiceResolve student names and owner visibility for Q&A or announcement readers
StaffServiceResolve staff author names
FileServiceManage file links and attachment cleanup for announcements and Q&A

The module does not own student, staff, or class records. It persists only foreign-key-like IDs and resolves names or visibility through internal APIs at query time.

Failure Modes

  • Invalid or unknown class targets throw INVALID_CLASS_TARGET.
  • Hidden announcements are intentionally surfaced as ANNOUNCEMENT_NOT_FOUND, not as a separate permission error.
  • Q&A reads and writes fail with QNA_NOT_FOUND or COMMENT_NOT_FOUND when the aggregate or reply is missing.
  • Review popup state shares the generic toggles table, so this path depends on that row existing for the tenant.

Observability

  • The module logs create, update, delete, and query activity in the application services.
  • There is no queue, retry, or scheduled recovery loop in this module.
  • Pagination-sensitive Q&A reads use batch-fetch-friendly patterns rather than fetch-join pagination.

Verification

./gradlew :modules:content:test
./gradlew :app:test --tests '*Content*'
cd lumie-document/docusaurus && npm run build

Expected success signals:

  • Gradle finishes with BUILD SUCCESSFUL.
  • AnnouncementCommandServiceTest and AnnouncementQueryServiceTest pass without failures.
  • Docusaurus finishes without MDX or broken-link errors for backend/content-svc.