Skip to content

feat: add USSD management feature#938

Open
Diallola wants to merge 2 commits into
NdoleStudio:mainfrom
Diallola:feature/ussd-management
Open

feat: add USSD management feature#938
Diallola wants to merge 2 commits into
NdoleStudio:mainfrom
Diallola:feature/ussd-management

Conversation

@Diallola

@Diallola Diallola commented Jul 1, 2026

Copy link
Copy Markdown
  • Add USSD entity with session management (types, directions, statuses)
  • Add USSD repository (interface + GORM implementation)
  • Add USSD events (received and sent)
  • Add USSD request/response models
  • Add USSD service for receiving and sending USSD messages
  • Add USSD handler with routes for receive, send, index, delete
  • Add USSD validator
  • Register USSD routes, listeners, and DB migration in DI container

API endpoints:
POST /v1/ussd/receive - Receive USSD request from phone POST /v1/ussd/send - Send USSD response to phone GET /v1/ussd - List USSD session history DELETE /v1/ussd/:ussdID - Delete a USSD session

- Add USSD entity with session management (types, directions, statuses)
- Add USSD repository (interface + GORM implementation)
- Add USSD events (received and sent)
- Add USSD request/response models
- Add USSD service for receiving and sending USSD messages
- Add USSD handler with routes for receive, send, index, delete
- Add USSD validator
- Register USSD routes, listeners, and DB migration in DI container

API endpoints:
  POST /v1/ussd/receive - Receive USSD request from phone
  POST /v1/ussd/send - Send USSD response to phone
  GET /v1/ussd - List USSD session history
  DELETE /v1/ussd/:ussdID - Delete a USSD session
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


MacBook Air seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account.
You have signed the CLA already but the status is still pending? Let us recheck it.

@Diallola

Diallola commented Jul 1, 2026

Copy link
Copy Markdown
Author

USSD management feature

@codacy-production

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 119 complexity · 55 duplication

Metric Results
Complexity 119
Duplication 55

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a full USSD session management feature: entity, repository, service, handler, validator, events, and DI wiring. The implementation follows the existing codebase patterns well in most areas, but several data-mapping bugs would result in every stored USSD record having an incorrect (zero) phone_id and wrong owner/contact values for outbound sessions.

  • PhoneID is always the zero UUID: the handler hard-codes uuid.Nil when calling service.Receive, and the Send service method never sets PhoneID at all. This breaks the phone_id filter on the index endpoint and the DeleteAllForPhone cascade.
  • Owner/Contact swapped for outbound USSD: ToUSSDSendParams passes input.To (the subscriber) as Owner and input.From (the app phone) as Contact, which is the inverse of the correct mapping used in the receive path.
  • Duplicate route registration: both RegisterRoutes and RegisterPhoneAPIKeyRoutes register POST /v1/ussd/receive and POST /v1/ussd/send; since RegisterRoutes is wired first, the Phone API Key variants are shadowed and unreachable.

Confidence Score: 2/5

Not ready to merge — every USSD record written to the database will have the wrong phone_id and outbound records will have owner/contact inverted, requiring a data migration if merged as-is.

Three independent data-correctness bugs compound each other: phone_id is always zero for both inbound and outbound sessions, and the owner/contact fields are swapped for every sent USSD. On top of that, the duplicate route registration makes the Phone API Key authentication path for USSD completely unreachable at runtime. These defects affect the core write path and would produce corrupted data from the moment the feature is enabled.

api/pkg/handlers/ussd_handler.go, api/pkg/services/ussd_service.go, and api/pkg/requests/ussd_send_request.go all need fixes before this is safe to merge.

Important Files Changed

Filename Overview
api/pkg/handlers/ussd_handler.go Hardcodes uuid.Nil as phoneID on every Receive call; RegisterPhoneAPIKeyRoutes registers overlapping paths that are shadowed by RegisterRoutes, making phone-API-key auth for USSD unreachable; Delete returns HTTP 200 instead of 204.
api/pkg/services/ussd_service.go Send method never assigns PhoneID to the USSD entity, so every outbound record is stored with a nil phone_id, silently breaking phone-scoped queries.
api/pkg/requests/ussd_send_request.go ToUSSDSendParams has Owner and Contact swapped — Owner is set to input.To (the subscriber) instead of input.From (the app phone).
api/pkg/entities/ussd.go New USSD entity with well-structured type/direction/status enums, GORM table mapping, and lifecycle helpers; looks correct in isolation.
api/pkg/repositories/gorm_ussd_repository.go GORM repository implementation with correct not-found error propagation, paginated queries, and user-scoped access control.
api/pkg/di/container.go RegisterUSSDRoutes calls RegisterRoutes before RegisterPhoneAPIKeyRoutes, causing the PhoneAPIKey variants to be shadowed; RegisterUSSDListeners is empty but still registered.
api/pkg/validators/ussd_handler_validator.go Validation logic covers all required fields and UUID format checks; correctly rejects empty session IDs and malformed phone_id values.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Phone as Mobile Phone
    participant API as API Server
    participant Svc as USSDService
    participant Repo as GormUSSDRepository
    participant ED as EventDispatcher

    Phone->>API: POST /v1/ussd/receive
    API->>API: Validate (USSDHandlerValidator)
    API->>Svc: Receive(params, uuid.Nil)
    Note over Svc: PhoneID = uuid.Nil (bug)
    Svc->>Repo: Store(ussd)
    Repo-->>Svc: ok
    Svc->>ED: Dispatch(ussd.phone.received)
    ED-->>Svc: ok
    Svc-->>API: "*USSD"
    API-->>Phone: 200 OK

    Phone->>API: POST /v1/ussd/send
    API->>API: Validate (USSDHandlerValidator)
    API->>Svc: Send(params)
    Note over Svc: PhoneID not set (bug)
    Note over Svc: Owner = To (subscriber) — bug
    Svc->>Repo: Store(ussd)
    Repo-->>Svc: ok
    Svc->>ED: Dispatch(ussd.phone.sent)
    ED-->>Svc: ok
    Svc-->>API: "*USSD"
    API-->>Phone: 200 OK

    Phone->>API: "GET /v1/ussd?phone_id=X"
    API->>Svc: Index(user, params, phoneID)
    Svc->>Repo: IndexByPhoneID(user, phoneID, params)
    Note over Repo: Returns empty — phone_id stored as nil UUID
    Repo-->>Svc: []
    Svc-->>API: []
    API-->>Phone: 200 OK (empty)

    Phone->>API: DELETE /v1/ussd/:id
    API->>Svc: Delete(source, userID, ussdID)
    Svc->>Repo: Load(userID, ussdID)
    Repo-->>Svc: "*USSD"
    Svc->>Repo: Delete(userID, ussdID)
    Repo-->>Svc: ok
    Svc->>ED: Dispatch(ussd.deleted)
    ED-->>Svc: ok
    Svc-->>API: nil
    API-->>Phone: 200 OK (should be 204)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Phone as Mobile Phone
    participant API as API Server
    participant Svc as USSDService
    participant Repo as GormUSSDRepository
    participant ED as EventDispatcher

    Phone->>API: POST /v1/ussd/receive
    API->>API: Validate (USSDHandlerValidator)
    API->>Svc: Receive(params, uuid.Nil)
    Note over Svc: PhoneID = uuid.Nil (bug)
    Svc->>Repo: Store(ussd)
    Repo-->>Svc: ok
    Svc->>ED: Dispatch(ussd.phone.received)
    ED-->>Svc: ok
    Svc-->>API: "*USSD"
    API-->>Phone: 200 OK

    Phone->>API: POST /v1/ussd/send
    API->>API: Validate (USSDHandlerValidator)
    API->>Svc: Send(params)
    Note over Svc: PhoneID not set (bug)
    Note over Svc: Owner = To (subscriber) — bug
    Svc->>Repo: Store(ussd)
    Repo-->>Svc: ok
    Svc->>ED: Dispatch(ussd.phone.sent)
    ED-->>Svc: ok
    Svc-->>API: "*USSD"
    API-->>Phone: 200 OK

    Phone->>API: "GET /v1/ussd?phone_id=X"
    API->>Svc: Index(user, params, phoneID)
    Svc->>Repo: IndexByPhoneID(user, phoneID, params)
    Note over Repo: Returns empty — phone_id stored as nil UUID
    Repo-->>Svc: []
    Svc-->>API: []
    API-->>Phone: 200 OK (empty)

    Phone->>API: DELETE /v1/ussd/:id
    API->>Svc: Delete(source, userID, ussdID)
    Svc->>Repo: Load(userID, ussdID)
    Repo-->>Svc: "*USSD"
    Svc->>Repo: Delete(userID, ussdID)
    Repo-->>Svc: ok
    Svc->>ED: Dispatch(ussd.deleted)
    ED-->>Svc: ok
    Svc-->>API: nil
    API-->>Phone: 200 OK (should be 204)
Loading

Comments Outside Diff (3)

  1. api/pkg/handlers/ussd_handler.go, line 319-330 (link)

    P1 Duplicate route registration makes PhoneAPIKey routes unreachable

    Both RegisterRoutes and RegisterPhoneAPIKeyRoutes register POST /v1/ussd/receive and POST /v1/ussd/send with the same paths. In container.RegisterUSSDRoutes, RegisterRoutes is called first, so both paths are matched by the AuthenticatedMiddleware-only route before Fiber ever reaches the PhoneAPIKeyMiddleware variant. Any device using a Phone API key to POST to these endpoints will receive a 401 from the first (wrong) route every time. The RegisterPhoneAPIKeyRoutes method should register unique paths or — following the pattern used by MessageHandler — the overlapping paths should only appear in one of the two methods.

  2. api/pkg/handlers/ussd_handler.go, line 367 (link)

    P1 PhoneID hardcoded to a nil UUID on every receive

    uuid.Nil is passed as the phoneID argument on every call to service.Receive, so every USSD row stored in the database will have phone_id set to the zero UUID. This silently breaks the GET /v1/ussd?phone_id=<id> filter (every lookup by a real phone ID returns nothing) and the DeleteAllForPhone cascade method, since no stored record will ever match a real phone's UUID. The phone ID needs to be resolved from the Owner phone number via a PhoneService lookup, or passed in the request parameters.

  3. api/pkg/services/ussd_service.go, line 1017-1029 (link)

    P1 PhoneID not set in Send — always stored as zero UUID

    The Send method builds a USSD entity without assigning PhoneID, so Go initialises it to the zero value. Since USSDSendParams also has no PhoneID field, there is no way for the caller to supply a real value. Every outbound USSD record will be stored with a nil phone_id, breaking phone-scoped queries and the cascade-delete-by-phone path. Both USSDSendParams and the entity construction need to carry and apply the resolved phone UUID.

Reviews (1): Last reviewed commit: "feat: add USSD management feature" | Re-trigger Greptile

Comment on lines +66 to +76
// ToUSSDSendParams converts USSDSend to services.USSDSendParams
func (input *USSDSend) ToUSSDSendParams(userID entities.UserID, source string) *services.USSDSendParams {
return &services.USSDSendParams{
Source: source,
UserID: userID,
Owner: input.To,
Contact: input.From,
Content: input.Content,
SessionID: input.SessionID,
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Owner and Contact are swapped for the send direction. In USSDSend, From is the app's phone number (the owner) and To is the mobile subscriber (the contact). Passing input.To as Owner stores the subscriber's number in the owner column and the app phone number in the unused contact field, producing wrong data for every outbound USSD record. Compare with USSDReceive.ToUSSDReceiveParams where Owner: input.To is correct because in the receive direction To carries the app phone number.

Suggested change
// ToUSSDSendParams converts USSDSend to services.USSDSendParams
func (input *USSDSend) ToUSSDSendParams(userID entities.UserID, source string) *services.USSDSendParams {
return &services.USSDSendParams{
Source: source,
UserID: userID,
Owner: input.To,
Contact: input.From,
Content: input.Content,
SessionID: input.SessionID,
}
}
// ToUSSDSendParams converts USSDSend to services.USSDSendParams
func (input *USSDSend) ToUSSDSendParams(userID entities.UserID, source string) *services.USSDSendParams {
return &services.USSDSendParams{
Source: source,
UserID: userID,
Owner: input.From,
Contact: input.To,
Content: input.Content,
SessionID: input.SessionID,
}
}

Comment on lines +244 to +245
return h.responseOK(c, "USSD session deleted successfully", nil)
} No newline at end of file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The swagger annotation declares @Success 204 but the handler calls h.responseOK which returns HTTP 200. This is inconsistent with both the documented contract and the convention used by every other delete handler in the codebase (e.g., MessageHandler.Delete uses h.responseNoContent).

Suggested change
return h.responseOK(c, "USSD session deleted successfully", nil)
}
return h.responseNoContent(c, "USSD session deleted successfully")
}

Comment thread api/pkg/di/container.go
}

// RegisterUSSDListeners registers event listeners for USSD events
func (container *Container) RegisterUSSDListeners() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add listener to delete all USSD messages when the phone is deleted and when the user account is deleted.

Comment thread api/pkg/entities/ussd.go

const (
// USSDDirectionMoToApp means the USSD message comes from the mobile phone to the application
USSDDirectionMoToApp = USSDDirection("MO-to-App")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use the same MobileOriginated or MobileTerminated directions which we use for the Message entity.

Comment thread api/pkg/entities/ussd.go
}

// MarkActive marks the USSD session as active
func (ussd *USSD) MarkActive() *USSD {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of this you can use SetStatusActive() etc.

}

// USSDSend is the payload for sending a USSD response to a phone
type USSDSend struct {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Put this in a separate file., we use 1 file per request struct.

request
From string `json:"from" example:"+18005550199"`
To string `json:"to" example:"+18005550100"`
Content string `json:"content" example:"*123#"`

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be the response from the phone so the example should be something like "Welcome to USSD Menu"


// ValidateReceive validates a USSD receive request
func (v *USSDHandlerValidator) ValidateReceive(ctx context.Context, request requests.USSDReceive) url.Values {
_, span := v.tracer.Start(ctx)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use govalidate library to validate this struct.


errs := url.Values{}

if matched, _ := regexp.MatchString("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$", request.USSDID); !matched {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have a UUID validator.

@AchoArnold

AchoArnold commented Jul 1, 2026

Copy link
Copy Markdown
Member

@Diallola Thanks for this work.

  1. Can you sign the CLA?
  2. This work addss USSD management on the bakend what about the Android App and Frontend?
  3. How did you test that feature works as expected? You can add 1 or 2 integration tests here https://github.com/NdoleStudio/httpsms/blob/main/tests/integration_test.go

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants