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
| Path | Role |
|---|---|
lumie-backend/modules/content/src/main/java/com/lumie/content/adapter/in/web | Public HTTP controllers for announcements, Q&A, and reviews |
lumie-backend/modules/content/src/main/java/com/lumie/content/adapter/in/internal/ContentServiceAdapter.java | Internal monolith API implementation |
lumie-backend/modules/content/src/main/java/com/lumie/content/application/service | Announcement, Q&A, and review services |
lumie-backend/modules/content/src/main/java/com/lumie/content/domain/entity | Content aggregates and supporting tables |
lumie-backend/libs/internal-api/src/main/java/com/lumie/content/api/ContentService.java | Internal contract for announcement creation and visibility checks |
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sql | Baseline 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.sql | Renames 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.sql | Adds version to mutable content tables |
lumie-backend/app/src/main/resources/db/migration/public/V43__qna_boards_add_category_name.sql | Legacy import support for Q&A category data |
lumie-backend/app/src/main/resources/db/migration/public/V55__create_announcement_class_targets.sql | Creates class-target visibility rows for announcements |
lumie-backend/app/src/main/resources/db/migration/public/V56__announcement_class_targets_tenant_safe_fk.sql | Makes the announcement target FK tenant-safe |
lumie-backend/app/src/main/resources/db/migration/public/V62__add_read_receipt_tables.sql | Creates announcement_reads |
lumie-backend/app/src/main/resources/db/migration/public/V65__repair_read_receipt_and_file_download_rls.sql | Repairs tenant-safe RLS and FK behavior for announcement reads |
Public Surface
| Surface | Entrypoints |
|---|---|
| Announcements | GET/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&A | GET/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} |
| Reviews | POST /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
| Aggregate | Table | Notes |
|---|---|---|
Announcement | announcements | Main announcement record |
AnnouncementClassTarget | announcement_class_targets | Optional class-target visibility filter |
AnnouncementRead | announcement_reads | Per-user read receipt inserted idempotently |
QnaBoard | qna_boards | Question thread with is_answered and author_last_read_at |
QnaComment | qna_comments | Admin or student comments |
Review | reviews | Public review text |
ReviewPopupSetting | toggles | Review-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
classIdsfrom student callers and only expose them to staff-side callers. - File links are managed through
FileService.replaceFileLinks(...),deleteFileLinksByEntity(...), anddeleteFilesByEntity(...).
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
| Dependency | Why it exists |
|---|---|
ClassService | Validate announcement class targets and student class visibility |
StudentService | Resolve student names and owner visibility for Q&A or announcement readers |
StaffService | Resolve staff author names |
FileService | Manage 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_FOUNDorCOMMENT_NOT_FOUNDwhen the aggregate or reply is missing. - Review popup state shares the generic
togglestable, 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. AnnouncementCommandServiceTestandAnnouncementQueryServiceTestpass without failures.- Docusaurus finishes without MDX or broken-link errors for
backend/content-svc.