Notification Service
This is the reference page for lumie-backend/modules/notification, the tenant-scoped SMS module for sender-number registration state, send requests, send history, class-targeted recipient expansion, reusable SMS templates, and staff-owned SMS recipient groups.
Source Paths
| Path | Role |
|---|---|
lumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/in/web/{SmsController,SmsSenderSettingController,SmsTemplateController,SolapiSmsWebhookController,InternalSmsSenderSettingController}.java | Public SMS surface, tenant sender-setting endpoints, SOLAPI callback receiver, and internal approval route |
lumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/in/scheduling/SmsDispatchScheduler.java | ShedLock-protected scheduled dispatcher for due PENDING messages |
lumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/out/external/{MockSmsProviderAdapter,SolapiSmsProviderAdapter}.java | Swappable outbound SMS provider adapters. SOLAPI now reads the sender number from SendCommand.from() instead of config. |
lumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/out/config/SmsProperties.java | SMS provider selection plus required SOLAPI credentials and base URL |
lumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/out/persistence/SmsSenderSettingPersistenceAdapter.java | Tenant-scoped persistence adapter for sms_sender_settings |
lumie-backend/modules/notification/src/main/java/com/lumie/notification/application/service/{SmsCommandService,SmsDispatchService,SmsSenderSettingService,SmsWebhookService,SmsQueryService,SmsRecipientGroupService}.java | Send, dispatch, sender-setting, webhook, list, history, recipient group, and template logic |
lumie-backend/modules/notification/src/main/java/com/lumie/notification/domain/entity/{SmsMessage,SmsRecipientGroup,SmsSenderSetting,SmsTemplate}.java | Main notification aggregates, including the tenant sender-setting row |
lumie-backend/app/src/main/java/com/lumie/app/config/internal/InternalHmacAuthFilter.java | HMAC gate for /internal/**, including tenant-bound sender-setting approval updates |
lumie-backend/app/src/main/java/com/lumie/app/config/SecurityConfig.java | Staff-role access gate for /v1/sms/** and /v1/sms-templates/** |
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sql | Source-of-truth table creation and RLS for sms_messages and sms_templates |
lumie-backend/app/src/main/resources/db/migration/public/V71__sms_message_mvp_metadata.sql | Scheduling, cancellation, pricing metadata, optimistic locking, and SMS history indexes |
lumie-backend/app/src/main/resources/db/migration/public/V73__create_sms_recipient_groups.sql | Staff-owned recipient group table, JSON recipients, tenant RLS, and owner-staff FK |
lumie-backend/app/src/main/resources/db/migration/public/V74__link_sms_messages_to_recipient_groups.sql | Optional sms_messages.recipient_group_id link and tenant-safe FK |
lumie-backend/app/src/main/resources/db/migration/public/V76__sms_provider_dispatch_tracking.sql | Provider, provider group id, dispatch attempts, provider status/error, and dispatch/confirmation timestamps |
lumie-backend/app/src/main/resources/db/migration/public/V78__sms_sender_settings.sql | sms_sender_settings table, tenant RLS, unique tenant/provider contract, and nullable sms_messages.sender_number backfill column |
Public Surface
| Endpoint | Purpose |
|---|---|
GET /v1/sms/sender-setting | OWNER-only read of the current tenant's SOLAPI sender-setting row, or a synthetic NOT_CONFIGURED response when no row exists |
GET /v1/sms/sender-setting/status | Staff-safe sender-setting status check for the send UI; returns provider and registration status only |
PUT /v1/sms/sender-setting | OWNER-only upsert of the tenant's SOLAPI sender number |
POST /v1/sms/send | Send an SMS to either an explicit recipient list or a stored recipient group |
GET /v1/sms/recipient-groups | List the current staff member's recipient groups without recipient-level phone data |
GET /v1/sms/recipient-groups/{id} | Read one current-staff-owned recipient group with recipients |
POST /v1/sms/recipient-groups | Create a current-staff-owned recipient group |
GET /v1/sms/history, GET /v1/sms/history/{id} | Read paged SMS history and individual sends |
POST /v1/sms/history/{id}/cancel | Cancel a pending scheduled send |
POST /v1/sms/send/class/{classId} | Expand recipients from class enrollment and send in one call |
POST /v1/sms/solapi/webhook | SOLAPI callback receiver protected by X-Solapi-Secret: sha1(SOLAPI_WEBHOOK_TOKEN) |
GET /v1/sms-templates, POST /v1/sms-templates, PATCH /v1/sms-templates/{id}, DELETE /v1/sms-templates/{id} | SMS template CRUD |
/v1/sms/** and /v1/sms-templates/** require a staff role (OWNER, MANAGER, or INSTRUCTOR). Full sender-setting reads and writes add an explicit AuthorizationGuard.requireOwner() check inside the controller. The staff-safe /status endpoint exists so the send UI can block unapproved sends without exposing the actual sender number or rejection reason to non-owner staff. Direct send, class send, and recipient group creation require Idempotency-Key; SMS writes use idempotency without storing the response body because SMS responses can carry recipient PII. The send UI keeps one idempotency key for the same draft and retry attempt, then rotates it only after a confirmed success or after the user changes the draft content, recipient group, template, or schedule.
The sender-setting public surface is intentionally v1-scoped to a single provider and a single sender number per tenant/provider pair. The code only upserts the solapi row.
Internal Surface And Dependencies
| Dependency | Role |
|---|---|
InternalHmacAuthFilter | Authenticates /internal/** with X-Tenant-Slug, X-Timestamp, and X-Signature, then restores tenant context so RLS-protected sender-setting rows are visible |
StaffService | Resolves the sender from UserContextHolder for both direct and class-targeted sends |
ClassService | Validates class existence and resolves enrolled student IDs |
StudentService | Expands student IDs into names and guardian phone numbers |
SmsRecipientGroupPersistencePort | Persists staff-owned recipient groups and loads groups by id plus ownerStaffId |
SmsSenderSettingPersistencePort | Loads and saves the tenant's single solapi sender-setting row under RLS |
SmsProviderPort | Abstracts the outbound provider. lumie.sms.provider=mock is the safe default; solapi enables live sends and now receives the sender number per message. |
TenantService | Lets the internal approval route, scheduler, and webhook processing restore tenant context before reading tenant-scoped SMS rows under RLS |
There is no published libs/internal-api notification contract in the codebase right now.
Aggregates And Tables
| Aggregate | Notes |
|---|---|
SmsMessage | Stores sender, nullable senderNumber, recipient type, JSON recipient list, content, schedule/cancel timestamps, SMS/LMS estimate, price estimate, provider tracking fields, status, and success/fail counts |
SmsRecipientGroup | Stores a staff-owned group name, icon, and JSON recipient list; tenant and owner checks are enforced before reads and sends |
SmsSenderSetting | Stores the tenant-scoped SMS sender number for one provider with registrationStatus, optional rejectionReason, approvedAt, rejectedAt, timestamps, optimistic-lock version, and a unique (tenant_id, provider) constraint |
SmsTemplate | Stores reusable template name, content, and category |
V78__sms_sender_settings.sql enables and forces RLS on sms_sender_settings with the same app.tenant_id policy shape used by other tenant tables. The migration also adds nullable sms_messages.sender_number so dispatch history can preserve the actual sender number used for a message. New messages save the approved sender number at creation time; legacy rows with NULL in sender_number can still dispatch through the fallback described below.
Runtime Flow
Contract Notes
The sender-setting flow is hard-coded to the solapi provider in SmsSenderSettingService.SOLAPI_PROVIDER. GET /v1/sms/sender-setting returns a normal SmsSenderSettingResponse when the tenant has a row, or a synthetic NOT_CONFIGURED response with null identifiers and timestamps when no row exists.
SmsSenderSetting normalizes the stored number to digits, while SmsSenderSettingResponse re-hyphenates the sender number for the settings API response. SmsMessageResponse returns the stored senderNumber directly from sms_messages, so history rows preserve the exact saved dispatch number instead of reformatting it.
Changing the sender number clears approval metadata and resets the row to PENDING. Re-submitting the same already-approved number is a no-op and leaves the row APPROVED.
// lumie-backend/modules/notification/src/main/java/com/lumie/notification/domain/entity/SmsSenderSetting.java
public void submit(String senderNumber) {
String normalized = normalizeSenderNumber(senderNumber);
if (Objects.equals(this.senderNumber, normalized)
&& this.registrationStatus == SmsSenderSettingStatus.APPROVED) {
return;
}
this.senderNumber = normalized;
this.registrationStatus = SmsSenderSettingStatus.PENDING;
this.rejectionReason = null;
this.approvedAt = null;
this.rejectedAt = null;
}
When lumie.sms.provider=solapi, SMS send creation requires an approved tenant sender number before the message can be saved. Both sendSms(...) and sendToClass(...) resolve the sender number before creating the message, and the approved number is persisted into sms_messages.sender_number for immediate and scheduled sends. The default mock provider is a local/dev provider and does not require a sender-setting approval row.
// lumie-backend/modules/notification/src/main/java/com/lumie/notification/application/service/SmsSenderSettingService.java
public String resolveSenderNumberForDispatch(String savedSenderNumber) {
if (savedSenderNumber != null && !savedSenderNumber.isBlank()) {
return savedSenderNumber;
}
return requireApprovedSenderNumber();
}
Immediate sends are first stored as PENDING, then SmsDispatchService dispatches them after the database commit. Scheduled sends remain PENDING until SmsDispatchScheduler finds them due. Provider HTTP calls happen outside the transaction; provider results are saved in a separate transaction. DISPATCHED means the provider accepted the message and final delivery confirmation is still pending.
Legacy live-provider rows with NULL in sms_messages.sender_number still dispatch by falling back to the tenant's current approved sender number at dispatch time. This keeps pre-migration history rows sendable, but they still fail if the tenant no longer has an approved sender number. The mock provider bypasses that fallback because it does not use a real sender number.
The module supports a swappable SMS provider. lumie.sms.provider defaults to mock; solapi enables live SOLAPI sends. SmsProperties.Solapi only defines apiKey, apiSecret, baseUrl, and webhookToken, and SolapiSmsProviderAdapter rejects missing credentials or webhook token. SOLAPI_FROM_NUMBER is no longer read by the code.
Representative shape from lumie-backend/app/src/main/resources/application.yaml, bound by lumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/out/config/SmsProperties.java:
lumie:
sms:
provider: solapi
solapi:
api-key: ${SOLAPI_API_KEY}
api-secret: ${SOLAPI_API_SECRET}
base-url: ${SOLAPI_BASE_URL:https://api.solapi.com}
webhook-token: ${SOLAPI_WEBHOOK_TOKEN}
PATCH /internal/sms/sender-settings/\{tenantSlug\}/approval is a manual ops-only step in v1. The route only accepts APPROVED or REJECTED, requires a rejection reason for REJECTED, runs behind /internal/** HMAC auth, and rebinds the tenant context from the path slug before updating the RLS-protected row. v1 does not automate SOLAPI sender registration or document submission, and it does not support multiple sender numbers for one tenant/provider pair.
SOLAPI callbacks use per-recipient customFields (lumieTenant, lumieSmsId, and lumieRecipient) so SmsWebhookService can restore tenant context before updating the tenant-scoped row under RLS. SmsDispatchService applies callbacks by the Lumie message id and recipient index, validates the provider message id, and validates the provider group id when both the stored row and callback include one. If SOLAPI omits a group id, Lumie preserves any existing stored group id; if an older row has no stored group id, the callback group id is recorded when present. The callback endpoint requires X-Solapi-Secret with sha1(SOLAPI_WEBHOOK_TOKEN).
Direct send requests must provide recipients. Group send requests must provide recipientGroupId and no inline recipients; SmsCommandService loads the group by the authenticated staff member's ownerStaffId, derives the recipients server-side, and infers the message recipient type from the stored group recipients. INSTRUCTOR users can cancel only their own scheduled messages; OWNER and MANAGER users can cancel other staff members' scheduled messages.
Example Contracts
These examples come directly from SmsController, SmsSenderSettingController, InternalSmsSenderSettingController, SendSmsRequest, UpsertSmsSenderSettingRequest, UpdateSmsSenderSettingApprovalRequest, RecipientRequest, CreateSmsRecipientGroupRequest, SmsMessageResponse, SmsSenderSettingResponse, SmsSenderSettingStatusResponse, SmsRecipientGroupResponse, SendToClassRequest, PageResponse, and app/src/test/resources/openapi/api-docs.json.
Sender Setting Read
GET /v1/sms/sender-setting
HTTP/1.1 200 OK
{
"id": null,
"provider": "solapi",
"senderNumber": null,
"registrationStatus": "NOT_CONFIGURED",
"rejectionReason": null,
"approvedAt": null,
"rejectedAt": null,
"createdAt": null,
"updatedAt": null
}
Sender Setting Status
GET /v1/sms/sender-setting/status
HTTP/1.1 200 OK
{
"provider": "solapi",
"registrationStatus": "PENDING"
}
Sender Setting Update
PUT /v1/sms/sender-setting
Content-Type: application/json
{
"senderNumber": "021-234-5678"
}
HTTP/1.1 200 OK
{
"id": 1,
"provider": "solapi",
"senderNumber": "02-1234-5678",
"registrationStatus": "PENDING",
"rejectionReason": null,
"approvedAt": null,
"rejectedAt": null,
"createdAt": "<timestamp>",
"updatedAt": "<timestamp>"
}
Internal Approval Update
PATCH /internal/sms/sender-settings/inst-acme/approval
X-Tenant-Slug: inst-acme
X-Timestamp: 1782700800
X-Signature: <hex hmac>
Content-Type: application/json
{
"registrationStatus": "APPROVED"
}
HTTP/1.1 200 OK
{
"id": 1,
"provider": "solapi",
"senderNumber": "02-1234-5678",
"registrationStatus": "APPROVED",
"rejectionReason": null,
"approvedAt": "<timestamp>",
"rejectedAt": null,
"createdAt": "<timestamp>",
"updatedAt": "<timestamp>"
}
Send SMS
POST /v1/sms/send
Idempotency-Key: sms-20260701-01
Content-Type: application/json
{
"recipients": [
{
"phone": "01012345678",
"name": "Kim Student",
"studentId": 101,
"className": null,
"recipientRole": "GUARDIAN"
}
],
"title": "Class reminder",
"content": "{학생명} 학부모님, 10:00 수업 안내드립니다.",
"templateId": 3,
"scheduledAt": "2026-07-01T02:00:00Z"
}
HTTP/1.1 201 Created
{
"id": 55,
"senderId": 7,
"senderNumber": "0212345678",
"recipientType": "GUARDIAN",
"recipients": [
{
"phone": "010-****-5678",
"name": "Kim Student",
"studentId": 101,
"className": null,
"recipientRole": "GUARDIAN",
"deliveryStatus": "PENDING"
}
],
"recipientCount": 1,
"title": "Class reminder",
"recipientGroupId": null,
"content": "{학생명} 학부모님, 10:00 수업 안내드립니다.",
"templateId": 3,
"scheduledAt": "2026-07-01T02:00:00Z",
"cancelledAt": null,
"sentAt": null,
"messageType": "SMS",
"estimatedSegments": 1,
"unitPrice": 20,
"totalPrice": 20,
"status": "PENDING",
"successCount": 0,
"failCount": 0,
"createdAt": "<timestamp>",
"updatedAt": "<timestamp>"
}
Recipient Groups
POST /v1/sms/recipient-groups
Idempotency-Key: sms-group-20260701-01
Content-Type: application/json
{
"name": "Final class guardians",
"icon": "book",
"recipients": [
{
"phone": "01012345678",
"name": "Kim Student",
"studentId": 101,
"className": null,
"recipientRole": "GUARDIAN"
}
]
}
HTTP/1.1 201 Created
{
"id": 10,
"name": "Final class guardians",
"icon": "book",
"recipients": [
{
"phone": "010-****-5678",
"name": "Kim Student",
"studentId": 101,
"className": null,
"recipientRole": "GUARDIAN",
"deliveryStatus": "PENDING"
}
],
"createdAt": "<timestamp>",
"updatedAt": "<timestamp>"
}
History Page
GET /v1/sms/history?page=0&size=20&status=SENT
HTTP/1.1 200 OK
{
"items": [
{
"id": 55,
"senderId": 7,
"senderNumber": "0212345678",
"recipientType": "GUARDIAN",
"recipientCount": 1,
"recipients": [],
"title": "Class reminder",
"recipientGroupId": null,
"content": "{학생명} 학부모님, 10:00 수업 안내드립니다.",
"templateId": 3,
"scheduledAt": null,
"cancelledAt": null,
"sentAt": "<timestamp>",
"messageType": "SMS",
"estimatedSegments": 1,
"unitPrice": 20,
"totalPrice": 20,
"status": "SENT",
"successCount": 1,
"failCount": 0,
"createdAt": "<timestamp>",
"updatedAt": "<timestamp>"
}
],
"page": 0,
"perPage": 20,
"total": 1,
"totalPages": 1,
"hasNext": false
}
Failure, Retry, And Observability
sendSms(...)andsendToClass(...)reject requests when the tenant does not have anAPPROVEDsender-setting row. The error comes fromSmsErrorCode.SENDER_SETTING_NOT_APPROVED.PUT /v1/sms/sender-settingvalidates the sender number withPhoneNumberUtils.isValid(...)and normalizes it to digits before storage.PATCH /internal/sms/sender-settings/\{tenantSlug\}/approvalrejects statuses other thanAPPROVEDorREJECTED, rejectsREJECTEDwithout a reason, rejects unknown tenants, and returns403when the HMAC-authenticated tenant context does not match the path slug.- The unique
(tenant_id, provider)constraint plus the service-levelsolapilookup means v1 supports one sender-setting row per tenant for SOLAPI only. - Both send paths reject missing senders, missing or invalid recipient phone numbers, and empty recipient sets.
- Direct sends reject payloads that omit
recipients; group sends reject payloads that mixrecipientGroupIdwith inlinerecipients. - Recipient groups are owned by the current staff member. Listing uses a summary DTO without recipient phone numbers; detail and send paths load by
idandownerStaffId. sendToClass(...)also rejects missing classes and classes with no enrolled students.scheduledAtmust be in the future. Past or current timestamps are rejected instead of falling through to immediate dispatch.- The send endpoints require idempotency keys, but template creation and template updates do not use the idempotency layer.
GET /v1/sms/historyreturnsrecipientCount,senderNumber, the stored messagecontentpreview, and an emptyrecipientsarray. List views do not receive recipient-level phone numbers or personalized rendered bodies.GET /v1/sms/history/{id}returns the full stored message body for the guarded detail view, masks recipient phone numbers, and also omits personalized rendered bodies.- Programmatic dispatch transactions use the
@RequiresNewTransactiontemplate and bindapp.tenant_idthroughRlsTenantTransactionBinderbefore claim, result, failure, and webhook update queries. Missing tenant context fails before repository access instead of silently producing empty RLS reads. - Provider send failures are recorded on
sms_messages.last_provider_error; retryable failures stayPENDINGuntillumie.sms.max-dispatch-attemptsis reached, then the message becomesFAILED. - Legacy rows with
NULLinsms_messages.sender_numbercan still dispatch throughresolveSenderNumberForDispatch(...), but only if the tenant has a currently approved sender-setting row at dispatch time. - SOLAPI delivery callbacks update recipient-level provider ids/status codes and aggregate the message to
SENT,FAILED, orPARTIALonce no recipients remain pending or dispatched. - History queries remain tenant-scoped under RLS because
sms_messages,sms_sender_settings, andsms_templatesare tenant tables. Owners and managers read tenant-wide history; instructors are additionally scoped to their ownsenderId.
Verification
cd lumie-backend
./gradlew :modules:notification:test --tests '*SmsSenderSetting*'
./gradlew :modules:notification:test --tests '*SmsDispatchServiceTest'
./gradlew :modules:notification:test --tests '*SmsQueryServiceTest' --tests '*SmsControllerTest'
./gradlew :modules:notification:test --tests '*SmsCommandServiceTest'
./gradlew -Pintegration :libs:common:test --tests com.lumie.common.tenant.RlsTenantContextAspectIntegrationTest
./gradlew -Pintegration :app:test --tests com.lumie.app.config.RlsTenantTransactionBinderIntegrationTest --tests com.lumie.app.SmsDispatchServiceWiringTest
Expected success signals:
- Gradle exits with
BUILD SUCCESSFUL, and the notification tests still cover sender-setting read/write, internal approval, approved-sender gating, history preview-versus-detail content, and the legacyNULL sender_numberfallback path. - The integration checks prove the existing transactional RLS aspect still binds tenant context and the dispatch flow receives the
REQUIRES_NEWtransaction template used for post-commit dispatch updates. SmsPropertiesstill exposes onlyprovider,dispatchBatchSize,maxDispatchAttempts, andsolapi.{apiKey,apiSecret,baseUrl,webhookToken}, andV78__sms_sender_settings.sqlstill defines the RLS-protectedsms_sender_settingstable plus nullablesms_messages.sender_number.