Skip to main content

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

PathRole
lumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/in/web/{SmsController,SmsSenderSettingController,SmsTemplateController,SolapiSmsWebhookController,InternalSmsSenderSettingController}.javaPublic 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.javaShedLock-protected scheduled dispatcher for due PENDING messages
lumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/out/external/{MockSmsProviderAdapter,SolapiSmsProviderAdapter}.javaSwappable 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.javaSMS provider selection plus required SOLAPI credentials and base URL
lumie-backend/modules/notification/src/main/java/com/lumie/notification/adapter/out/persistence/SmsSenderSettingPersistenceAdapter.javaTenant-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}.javaSend, 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}.javaMain notification aggregates, including the tenant sender-setting row
lumie-backend/app/src/main/java/com/lumie/app/config/internal/InternalHmacAuthFilter.javaHMAC gate for /internal/**, including tenant-bound sender-setting approval updates
lumie-backend/app/src/main/java/com/lumie/app/config/SecurityConfig.javaStaff-role access gate for /v1/sms/** and /v1/sms-templates/**
lumie-backend/app/src/main/resources/db/migration/public/V18__rls_baseline.sqlSource-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.sqlScheduling, cancellation, pricing metadata, optimistic locking, and SMS history indexes
lumie-backend/app/src/main/resources/db/migration/public/V73__create_sms_recipient_groups.sqlStaff-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.sqlOptional sms_messages.recipient_group_id link and tenant-safe FK
lumie-backend/app/src/main/resources/db/migration/public/V76__sms_provider_dispatch_tracking.sqlProvider, 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.sqlsms_sender_settings table, tenant RLS, unique tenant/provider contract, and nullable sms_messages.sender_number backfill column

Public Surface

EndpointPurpose
GET /v1/sms/sender-settingOWNER-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/statusStaff-safe sender-setting status check for the send UI; returns provider and registration status only
PUT /v1/sms/sender-settingOWNER-only upsert of the tenant's SOLAPI sender number
POST /v1/sms/sendSend an SMS to either an explicit recipient list or a stored recipient group
GET /v1/sms/recipient-groupsList 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-groupsCreate 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}/cancelCancel a pending scheduled send
POST /v1/sms/send/class/{classId}Expand recipients from class enrollment and send in one call
POST /v1/sms/solapi/webhookSOLAPI 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

DependencyRole
InternalHmacAuthFilterAuthenticates /internal/** with X-Tenant-Slug, X-Timestamp, and X-Signature, then restores tenant context so RLS-protected sender-setting rows are visible
StaffServiceResolves the sender from UserContextHolder for both direct and class-targeted sends
ClassServiceValidates class existence and resolves enrolled student IDs
StudentServiceExpands student IDs into names and guardian phone numbers
SmsRecipientGroupPersistencePortPersists staff-owned recipient groups and loads groups by id plus ownerStaffId
SmsSenderSettingPersistencePortLoads and saves the tenant's single solapi sender-setting row under RLS
SmsProviderPortAbstracts the outbound provider. lumie.sms.provider=mock is the safe default; solapi enables live sends and now receives the sender number per message.
TenantServiceLets 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

AggregateNotes
SmsMessageStores 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
SmsRecipientGroupStores a staff-owned group name, icon, and JSON recipient list; tenant and owner checks are enforced before reads and sends
SmsSenderSettingStores 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
SmsTemplateStores 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(...) and sendToClass(...) reject requests when the tenant does not have an APPROVED sender-setting row. The error comes from SmsErrorCode.SENDER_SETTING_NOT_APPROVED.
  • PUT /v1/sms/sender-setting validates the sender number with PhoneNumberUtils.isValid(...) and normalizes it to digits before storage.
  • PATCH /internal/sms/sender-settings/\{tenantSlug\}/approval rejects statuses other than APPROVED or REJECTED, rejects REJECTED without a reason, rejects unknown tenants, and returns 403 when the HMAC-authenticated tenant context does not match the path slug.
  • The unique (tenant_id, provider) constraint plus the service-level solapi lookup 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 mix recipientGroupId with inline recipients.
  • Recipient groups are owned by the current staff member. Listing uses a summary DTO without recipient phone numbers; detail and send paths load by id and ownerStaffId.
  • sendToClass(...) also rejects missing classes and classes with no enrolled students.
  • scheduledAt must 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/history returns recipientCount, senderNumber, the stored message content preview, and an empty recipients array. 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 @RequiresNewTransaction template and bind app.tenant_id through RlsTenantTransactionBinder before 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 stay PENDING until lumie.sms.max-dispatch-attempts is reached, then the message becomes FAILED.
  • Legacy rows with NULL in sms_messages.sender_number can still dispatch through resolveSenderNumberForDispatch(...), 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, or PARTIAL once no recipients remain pending or dispatched.
  • History queries remain tenant-scoped under RLS because sms_messages, sms_sender_settings, and sms_templates are tenant tables. Owners and managers read tenant-wide history; instructors are additionally scoped to their own senderId.

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 legacy NULL sender_number fallback path.
  • The integration checks prove the existing transactional RLS aspect still binds tenant context and the dispatch flow receives the REQUIRES_NEW transaction template used for post-commit dispatch updates.
  • SmsProperties still exposes only provider, dispatchBatchSize, maxDispatchAttempts, and solapi.{apiKey,apiSecret,baseUrl,webhookToken}, and V78__sms_sender_settings.sql still defines the RLS-protected sms_sender_settings table plus nullable sms_messages.sender_number.