feat: send in-app and push learner notifications for key milestones

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-09 05:11:16 -07:00
parent ad4c739722
commit 5c6cb1b8d3
27 changed files with 563 additions and 53 deletions

View File

@ -22,6 +22,7 @@ import (
"Yimaru-Backend/internal/services/faqs"
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
lessonsservice "Yimaru-Backend/internal/services/lessons"
learnernotificationsservice "Yimaru-Backend/internal/services/learnernotifications"
"Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/messenger"
minioservice "Yimaru-Backend/internal/services/minio"
@ -316,6 +317,8 @@ func main() {
store, // implements SubscriptionStore
)
learnerNotifSvc := learnernotificationsservice.New(notificationSvc)
// Chapa service for subscription checkout payments
chapaSvc := chapa.NewService(
cfg,
@ -324,6 +327,8 @@ func main() {
store,
store,
)
chapaSvc.SetLearnerNotifier(learnerNotifSvc)
arifpaySvc.SetLearnerNotifier(learnerNotifSvc)
// Team management service
teamSvc := team.NewService(
@ -398,6 +403,7 @@ func main() {
analyticsDB,
rbacSvc,
videoEngagementSvc,
learnerNotifSvc,
)
logger.Info("Starting server", "port", cfg.Port)

View File

@ -131,6 +131,30 @@ INSERT INTO lms_user_lesson_progress (user_id, lesson_id)
ON CONFLICT (user_id, lesson_id)
DO NOTHING;
-- name: HasUserCompletedModule :one
SELECT EXISTS(
SELECT 1
FROM lms_user_module_progress
WHERE user_id = $1
AND module_id = $2
) AS completed;
-- name: HasUserCompletedCourse :one
SELECT EXISTS(
SELECT 1
FROM lms_user_course_progress
WHERE user_id = $1
AND course_id = $2
) AS completed;
-- name: HasUserCompletedProgram :one
SELECT EXISTS(
SELECT 1
FROM lms_user_program_progress
WHERE user_id = $1
AND program_id = $2
) AS completed;
-- name: InsertUserModuleProgress :exec
INSERT INTO lms_user_module_progress (user_id, module_id)
VALUES ($1, $2)

View File

@ -166,6 +166,21 @@ JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.status = 'ACTIVE'
AND us.expires_at <= CURRENT_TIMESTAMP;
-- name: ListLearnEnglishSubscriptionsExpiringInSevenDays :many
SELECT
us.id,
us.user_id,
us.plan_id,
us.expires_at,
sp.name AS plan_name,
sp.category AS plan_category
FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
AND sp.category = 'LEARN_ENGLISH'
AND us.expires_at::date = (CURRENT_DATE + INTERVAL '7 days')::date;
-- name: GetExpiringSubscriptions :many
SELECT
us.*,

View File

@ -795,6 +795,69 @@ func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, er
return i, err
}
const HasUserCompletedCourse = `-- name: HasUserCompletedCourse :one
SELECT EXISTS(
SELECT 1
FROM lms_user_course_progress
WHERE user_id = $1
AND course_id = $2
) AS completed
`
type HasUserCompletedCourseParams struct {
UserID int64 `json:"user_id"`
CourseID int64 `json:"course_id"`
}
func (q *Queries) HasUserCompletedCourse(ctx context.Context, arg HasUserCompletedCourseParams) (bool, error) {
row := q.db.QueryRow(ctx, HasUserCompletedCourse, arg.UserID, arg.CourseID)
var completed bool
err := row.Scan(&completed)
return completed, err
}
const HasUserCompletedModule = `-- name: HasUserCompletedModule :one
SELECT EXISTS(
SELECT 1
FROM lms_user_module_progress
WHERE user_id = $1
AND module_id = $2
) AS completed
`
type HasUserCompletedModuleParams struct {
UserID int64 `json:"user_id"`
ModuleID int64 `json:"module_id"`
}
func (q *Queries) HasUserCompletedModule(ctx context.Context, arg HasUserCompletedModuleParams) (bool, error) {
row := q.db.QueryRow(ctx, HasUserCompletedModule, arg.UserID, arg.ModuleID)
var completed bool
err := row.Scan(&completed)
return completed, err
}
const HasUserCompletedProgram = `-- name: HasUserCompletedProgram :one
SELECT EXISTS(
SELECT 1
FROM lms_user_program_progress
WHERE user_id = $1
AND program_id = $2
) AS completed
`
type HasUserCompletedProgramParams struct {
UserID int64 `json:"user_id"`
ProgramID int64 `json:"program_id"`
}
func (q *Queries) HasUserCompletedProgram(ctx context.Context, arg HasUserCompletedProgramParams) (bool, error) {
row := q.db.QueryRow(ctx, HasUserCompletedProgram, arg.UserID, arg.ProgramID)
var completed bool
err := row.Scan(&completed)
return completed, err
}
const InsertUserCourseProgress = `-- name: InsertUserCourseProgress :exec
INSERT INTO lms_user_course_progress (user_id, course_id)
VALUES ($1, $2)

View File

@ -628,6 +628,58 @@ func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]Subscripti
return items, nil
}
const ListLearnEnglishSubscriptionsExpiringInSevenDays = `-- name: ListLearnEnglishSubscriptionsExpiringInSevenDays :many
SELECT
us.id,
us.user_id,
us.plan_id,
us.expires_at,
sp.name AS plan_name,
sp.category AS plan_category
FROM user_subscriptions us
JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.status = 'ACTIVE'
AND us.expires_at > CURRENT_TIMESTAMP
AND sp.category = 'LEARN_ENGLISH'
AND us.expires_at::date = (CURRENT_DATE + INTERVAL '7 days')::date
`
type ListLearnEnglishSubscriptionsExpiringInSevenDaysRow struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
PlanID int64 `json:"plan_id"`
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
PlanName string `json:"plan_name"`
PlanCategory string `json:"plan_category"`
}
func (q *Queries) ListLearnEnglishSubscriptionsExpiringInSevenDays(ctx context.Context) ([]ListLearnEnglishSubscriptionsExpiringInSevenDaysRow, error) {
rows, err := q.db.Query(ctx, ListLearnEnglishSubscriptionsExpiringInSevenDays)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListLearnEnglishSubscriptionsExpiringInSevenDaysRow
for rows.Next() {
var i ListLearnEnglishSubscriptionsExpiringInSevenDaysRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.PlanID,
&i.ExpiresAt,
&i.PlanName,
&i.PlanCategory,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListSubscriptionDisplayStatusesByUserIDs = `-- name: ListSubscriptionDisplayStatusesByUserIDs :many
WITH input AS (
SELECT unnest($1::bigint[])::bigint AS user_id

View File

@ -145,8 +145,10 @@ type Config struct {
AccountDeletionPurgeEnabled bool
AccountDeletionPurgeInterval time.Duration
AccountDeletionPurgeBatchSize int32
PaymentExpiryWorkerEnabled bool
PaymentExpiryWorkerInterval time.Duration
PaymentExpiryWorkerEnabled bool
PaymentExpiryWorkerInterval time.Duration
SubscriptionExpiryReminderWorkerEnabled bool
SubscriptionExpiryReminderWorkerInterval time.Duration
InactiveSubModuleContentPurgeEnabled bool
InactiveSubModuleContentPurgeInterval time.Duration
InactiveSubModuleContentRetentionDays int
@ -611,6 +613,25 @@ func (c *Config) loadEnv() error {
}
}
subscriptionExpiryReminderEnabled := strings.TrimSpace(os.Getenv("SUBSCRIPTION_EXPIRY_REMINDER_WORKER_ENABLED"))
if subscriptionExpiryReminderEnabled == "" {
c.SubscriptionExpiryReminderWorkerEnabled = true
} else {
c.SubscriptionExpiryReminderWorkerEnabled = subscriptionExpiryReminderEnabled == "true" || subscriptionExpiryReminderEnabled == "1"
}
subscriptionExpiryReminderInterval := strings.TrimSpace(os.Getenv("SUBSCRIPTION_EXPIRY_REMINDER_WORKER_INTERVAL"))
if subscriptionExpiryReminderInterval == "" {
c.SubscriptionExpiryReminderWorkerInterval = 24 * time.Hour
} else {
interval, err := time.ParseDuration(subscriptionExpiryReminderInterval)
if err != nil || interval <= 0 {
c.SubscriptionExpiryReminderWorkerInterval = 24 * time.Hour
} else {
c.SubscriptionExpiryReminderWorkerInterval = interval
}
}
// Hard-delete inactive submodule lessons / practices / capstones after a retention period
inactiveContentPurge := strings.TrimSpace(os.Getenv("INACTIVE_SUBMODULE_CONTENT_PURGE_ENABLED"))
if inactiveContentPurge == "" {

View File

@ -23,9 +23,10 @@ type AppleUser struct {
}
type LoginSuccess struct {
UserId int64
Role Role
RfToken string
UserId int64
Role Role
RfToken string
IsNewAccount bool
}
type LoginRequest struct {

View File

@ -0,0 +1,14 @@
package domain
// LMSCompletionMilestone is a newly completed LMS entity for learner notifications.
type LMSCompletionMilestone struct {
ID int64
Name string
}
// LMSPracticeCompletionResult reports rollup milestones reached after a practice completion.
type LMSPracticeCompletionResult struct {
ModuleCompleted *LMSCompletionMilestone
CourseCompleted *LMSCompletionMilestone
ProgramCompleted *LMSCompletionMilestone
}

View File

@ -32,6 +32,11 @@ const (
NOTIFICATION_TYPE_TEAM_MEMBER_CREATED NotificationType = "team_member_created"
NOTIFICATION_TYPE_USER_DELETED NotificationType = "user_deleted"
NOTIFICATION_TYPE_SYSTEM_ALERT NotificationType = "system_alert"
NOTIFICATION_TYPE_USER_WELCOME NotificationType = "user_welcome"
NOTIFICATION_TYPE_MODULE_COMPLETED NotificationType = "module_completed"
NOTIFICATION_TYPE_COURSE_COMPLETED NotificationType = "course_completed"
NOTIFICATION_TYPE_PROGRAM_COMPLETED NotificationType = "program_completed"
NOTIFICATION_TYPE_SUBSCRIPTION_EXPIRING NotificationType = "subscription_expiring"
NotificationRecieverSideAdmin NotificationRecieverSide = "admin"
NotificationRecieverSideCustomer NotificationRecieverSide = "customer"

View File

@ -12,6 +12,13 @@ const (
SubscriptionCategoryDuolingo SubscriptionCategory = "DUOLINGO"
)
// SubscriptionExpiryReminder is a Learn English subscription expiring in seven days.
type SubscriptionExpiryReminder struct {
UserID int64
PlanName string
ExpiresAt time.Time
}
// CategorySubscriptionGateDisabled skips subscription enforcement on learner-facing routes (temporary).
var CategorySubscriptionGateDisabled = true

View File

@ -27,4 +27,5 @@ type SubscriptionStore interface {
UpdateSubscriptionStatus(ctx context.Context, id int64, status string) error
UpdateAutoRenew(ctx context.Context, id int64, autoRenew bool) error
ExtendSubscription(ctx context.Context, id int64, newExpiresAt time.Time) error
ListLearnEnglishSubscriptionsExpiringInSevenDays(ctx context.Context) ([]domain.SubscriptionExpiryReminder, error)
}

View File

@ -6,6 +6,7 @@ import (
"fmt"
dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5"
)
@ -34,7 +35,7 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
return err
}
if err := s.cascadeLMSCompletion(ctx, q, userID, &mod.ID, crs.ID, crs.ProgramID); err != nil {
if _, err := s.cascadeLMSCompletion(ctx, q, userID, &mod.ID, crs.ID, crs.ProgramID); err != nil {
return err
}
@ -46,10 +47,11 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
// CompletePracticeForUser records practice completion and cascades practice-based
// completion upward when all published practices in scope are complete.
func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) (domain.LMSPracticeCompletionResult, error) {
var empty domain.LMSPracticeCompletionResult
q, tx, err := s.BeginTx(ctx)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
return empty, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
@ -57,7 +59,7 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
UserID: userID,
QuestionSetID: questionSetID,
}); err != nil {
return err
return empty, err
}
scope, err := q.GetPracticeScopeByQuestionSetID(ctx, questionSetID)
@ -65,11 +67,11 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
if errors.Is(err, pgx.ErrNoRows) {
// Exam-prep practices are not in lms_practices; completion is tracked in user_practice_progress only.
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
return empty, fmt.Errorf("commit: %w", err)
}
return nil
return empty, nil
}
return err
return empty, err
}
var (
moduleID *int64
@ -81,133 +83,177 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
moduleID = &mid
mod, err := q.GetModuleByID(ctx, mid)
if err != nil {
return err
return empty, err
}
courseID = mod.CourseID
case scope.LessonID.Valid:
lesson, err := q.GetLessonByID(ctx, scope.LessonID.Int64)
if err != nil {
return err
return empty, err
}
mid := lesson.ModuleID
moduleID = &mid
mod, err := q.GetModuleByID(ctx, mid)
if err != nil {
return err
return empty, err
}
courseID = mod.CourseID
case scope.CourseID.Valid:
courseID = scope.CourseID.Int64
default:
return fmt.Errorf("practice %d is not linked to lesson/module/course", questionSetID)
return empty, fmt.Errorf("practice %d is not linked to lesson/module/course", questionSetID)
}
crs, err := q.GetCourseByID(ctx, courseID)
if err != nil {
return err
return empty, err
}
if err := s.cascadeLMSCompletion(ctx, q, userID, moduleID, crs.ID, crs.ProgramID); err != nil {
return err
result, err := s.cascadeLMSCompletion(ctx, q, userID, moduleID, crs.ID, crs.ProgramID)
if err != nil {
return empty, err
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit: %w", err)
return empty, fmt.Errorf("commit: %w", err)
}
return nil
return result, nil
}
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) error {
func (s *Store) cascadeLMSCompletion(ctx context.Context, q *dbgen.Queries, userID int64, moduleID *int64, courseID, programID int64) (domain.LMSPracticeCompletionResult, error) {
var result domain.LMSPracticeCompletionResult
if moduleID != nil {
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID))
if err != nil {
return err
return result, err
}
modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
ModuleID: toPgInt8(moduleID),
UserID: userID,
})
if err != nil {
return err
return result, err
}
modulePracticesComplete := modulePracticesTotal > 0 && modulePracticesDone >= modulePracticesTotal
if !modulePracticesComplete {
return nil
return result, nil
}
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: *moduleID}); err != nil {
return err
alreadyDone, err := q.HasUserCompletedModule(ctx, dbgen.HasUserCompletedModuleParams{
UserID: userID,
ModuleID: *moduleID,
})
if err != nil {
return result, err
}
if !alreadyDone {
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: *moduleID}); err != nil {
return result, err
}
mod, err := q.GetModuleByID(ctx, *moduleID)
if err != nil {
return result, err
}
result.ModuleCompleted = &domain.LMSCompletionMilestone{ID: mod.ID, Name: mod.Name}
}
}
nMods, err := q.CountModulesInCourse(ctx, courseID)
if err != nil {
return err
return result, err
}
nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{
CourseID: courseID,
UserID: userID,
})
if err != nil {
return err
return result, err
}
coursePracticesTotal, err := q.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
if err != nil {
return err
return result, err
}
coursePracticesDone, err := q.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
CourseID: toPgInt8(&courseID),
UserID: userID,
})
if err != nil {
return err
return result, err
}
courseModulesComplete := nMods > 0 && nDoneMods >= nMods
coursePracticesComplete := coursePracticesTotal > 0 && coursePracticesDone >= coursePracticesTotal
if !courseModulesComplete || !coursePracticesComplete {
return nil
return result, nil
}
if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: courseID}); err != nil {
return err
alreadyDone, err := q.HasUserCompletedCourse(ctx, dbgen.HasUserCompletedCourseParams{
UserID: userID,
CourseID: courseID,
})
if err != nil {
return result, err
}
if !alreadyDone {
if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: courseID}); err != nil {
return result, err
}
crs, err := q.GetCourseByID(ctx, courseID)
if err != nil {
return result, err
}
result.CourseCompleted = &domain.LMSCompletionMilestone{ID: crs.ID, Name: crs.Name}
}
nCr, err := q.CountCoursesInProgram(ctx, programID)
if err != nil {
return err
return result, err
}
nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return err
return result, err
}
programPracticesTotal, err := q.CountPublishedPracticesInProgram(ctx, programID)
if err != nil {
return err
return result, err
}
programPracticesDone, err := q.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
ProgramID: programID,
UserID: userID,
})
if err != nil {
return err
return result, err
}
programCoursesComplete := nCr > 0 && nCrDone >= nCr
programPracticesComplete := programPracticesTotal > 0 && programPracticesDone >= programPracticesTotal
if !programCoursesComplete || !programPracticesComplete {
return nil
return result, nil
}
if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: programID}); err != nil {
return err
alreadyDone, err = q.HasUserCompletedProgram(ctx, dbgen.HasUserCompletedProgramParams{
UserID: userID,
ProgramID: programID,
})
if err != nil {
return result, err
}
if !alreadyDone {
if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: programID}); err != nil {
return result, err
}
prog, err := q.GetProgramByID(ctx, programID)
if err != nil {
return result, err
}
result.ProgramCompleted = &domain.LMSCompletionMilestone{ID: prog.ID, Name: prog.Name}
}
return nil
return result, nil
}

View File

@ -330,3 +330,19 @@ func optionalBool(b *bool) pgtype.Bool {
func float64Ptr(f float64) *float64 {
return &f
}
func (s *Store) ListLearnEnglishSubscriptionsExpiringInSevenDays(ctx context.Context) ([]domain.SubscriptionExpiryReminder, error) {
rows, err := s.queries.ListLearnEnglishSubscriptionsExpiringInSevenDays(ctx)
if err != nil {
return nil, err
}
out := make([]domain.SubscriptionExpiryReminder, 0, len(rows))
for _, row := range rows {
out = append(out, domain.SubscriptionExpiryReminder{
UserID: row.UserID,
PlanName: row.PlanName,
ExpiresAt: row.ExpiresAt.Time,
})
}
return out, nil
}

View File

@ -16,6 +16,7 @@ import (
"Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
learnernotifications "Yimaru-Backend/internal/services/learnernotifications"
"github.com/google/uuid"
)
@ -32,6 +33,7 @@ type ArifpayService struct {
httpClient *http.Client
paymentStore ports.PaymentStore
subscriptionStore ports.SubscriptionStore
learnerNotifier *learnernotifications.Service
}
func NewArifpayService(
@ -48,6 +50,16 @@ func NewArifpayService(
}
}
func (s *ArifpayService) SetLearnerNotifier(notifier *learnernotifications.Service) {
s.learnerNotifier = notifier
}
func (s *ArifpayService) notifyLearnPackageSubscribed(userID int64, plan *domain.SubscriptionPlan) {
if s.learnerNotifier != nil && plan != nil {
s.learnerNotifier.MaybeNotifyLearnPackageSubscribed(userID, plan.Category, plan.Name)
}
}
// InitiateSubscriptionPayment creates a payment session for a subscription plan
func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) {
// Get the subscription plan
@ -280,6 +292,7 @@ func (s *ArifpayService) ProcessPaymentWebhook(ctx context.Context, req domain.W
if err := s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID); err != nil {
return fmt.Errorf("failed to link payment to subscription: %w", err)
}
s.notifyLearnPackageSubscribed(payment.UserID, plan)
}
return nil
@ -1035,6 +1048,7 @@ func (s *ArifpayService) VerifyDirectPaymentOTP(ctx context.Context, userID int6
})
if err == nil {
s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID)
s.notifyLearnPackageSubscribed(payment.UserID, plan)
}
}
}

View File

@ -162,6 +162,7 @@ func (s *Service) LoginWithApple(
var user domain.User
var err error
isNewAccount := false
user, err = s.userStore.GetUserByAppleID(ctx, aUser.ID)
if err != nil {
@ -183,6 +184,7 @@ func (s *Service) LoginWithApple(
if err != nil {
return domain.LoginSuccess{}, err
}
isNewAccount = true
} else {
if err := s.userStore.LinkAppleAccount(ctx, user.ID, aUser.ID, aUser.VerifiedEmail); err != nil {
return domain.LoginSuccess{}, err
@ -224,8 +226,9 @@ func (s *Service) LoginWithApple(
}
return domain.LoginSuccess{
UserId: user.ID,
Role: user.Role,
RfToken: refreshToken,
UserId: user.ID,
Role: user.Role,
RfToken: refreshToken,
IsNewAccount: isNewAccount,
}, nil
}

View File

@ -145,6 +145,7 @@ func (s *Service) LoginWithGoogle(
var user domain.User
var err error
isNewAccount := false
user, err = s.userStore.GetUserByGoogleID(ctx, gUser.ID)
if err != nil {
@ -162,6 +163,7 @@ func (s *Service) LoginWithGoogle(
if err != nil {
return domain.LoginSuccess{}, err
}
isNewAccount = true
} else {
if err := s.userStore.LinkGoogleAccount(ctx, user.ID, gUser.ID); err != nil {
return domain.LoginSuccess{}, err
@ -203,9 +205,10 @@ func (s *Service) LoginWithGoogle(
}
return domain.LoginSuccess{
UserId: user.ID,
Role: user.Role,
RfToken: refreshToken,
UserId: user.ID,
Role: user.Role,
RfToken: refreshToken,
IsNewAccount: isNewAccount,
}, nil
}

View File

@ -19,6 +19,7 @@ import (
"Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports"
learnernotifications "Yimaru-Backend/internal/services/learnernotifications"
"github.com/google/uuid"
)
@ -37,6 +38,7 @@ type Service struct {
paymentStore ports.PaymentStore
subscriptionStore ports.SubscriptionStore
userStore ports.UserStore
learnerNotifier *learnernotifications.Service
}
func NewService(
@ -55,6 +57,10 @@ func NewService(
}
}
func (s *Service) SetLearnerNotifier(notifier *learnernotifications.Service) {
s.learnerNotifier = notifier
}
func (s *Service) configured() error {
if s.cfg.CHAPA_SECRET_KEY == "" {
return ErrChapaNotConfigured
@ -388,6 +394,10 @@ func (s *Service) activateSubscription(ctx context.Context, payment *domain.Paym
return fmt.Errorf("failed to link payment to subscription: %w", err)
}
if s.learnerNotifier != nil {
s.learnerNotifier.MaybeNotifyLearnPackageSubscribed(payment.UserID, plan.Category, plan.Name)
}
return nil
}

View File

@ -0,0 +1,108 @@
package learnernotifications
import (
"context"
"fmt"
"time"
"Yimaru-Backend/internal/domain"
notificationservice "Yimaru-Backend/internal/services/notification"
)
// Service sends learner-facing in-app and push notifications.
type Service struct {
notifications *notificationservice.Service
}
func New(notifications *notificationservice.Service) *Service {
return &Service{notifications: notifications}
}
func (s *Service) NotifyWelcome(userID int64) {
s.send(userID, domain.NOTIFICATION_TYPE_USER_WELCOME, domain.NotificationLevelSuccess,
"Welcome to Yimaru Academy",
"Your account is ready. Start learning and track your progress across programs and courses.")
}
func (s *Service) NotifyModuleCompleted(userID int64, moduleName string) {
s.send(userID, domain.NOTIFICATION_TYPE_MODULE_COMPLETED, domain.NotificationLevelSuccess,
"Module completed",
fmt.Sprintf("Great work! You completed the module \"%s\".", moduleName))
}
func (s *Service) NotifyCourseCompleted(userID int64, courseName string) {
s.send(userID, domain.NOTIFICATION_TYPE_COURSE_COMPLETED, domain.NotificationLevelSuccess,
"Course completed",
fmt.Sprintf("Congratulations! You completed the course \"%s\".", courseName))
}
func (s *Service) NotifyProgramCompleted(userID int64, programName string) {
s.send(userID, domain.NOTIFICATION_TYPE_PROGRAM_COMPLETED, domain.NotificationLevelSuccess,
"Program completed",
fmt.Sprintf("Amazing achievement! You completed the program \"%s\".", programName))
}
func (s *Service) NotifyLearnPackageSubscribed(userID int64, planName string) {
s.send(userID, domain.NOTIFICATION_TYPE_SUBSCRIPTION_ACTIVATED, domain.NotificationLevelSuccess,
"Subscription active",
fmt.Sprintf("Your \"%s\" Learn English package is now active. Enjoy your learning journey!", planName))
}
func (s *Service) NotifyLearnPackageExpiringSoon(userID int64, planName string, expiresAt time.Time) {
s.send(userID, domain.NOTIFICATION_TYPE_SUBSCRIPTION_EXPIRING, domain.NotificationLevelWarning,
"Subscription expiring soon",
fmt.Sprintf("Your \"%s\" package expires on %s. Renew to keep uninterrupted access.", planName, expiresAt.Format("Jan 2, 2006")))
}
func (s *Service) MaybeNotifyLearnPackageSubscribed(userID int64, planCategory, planName string) {
if planCategory == string(domain.SubscriptionCategoryLearnEnglish) {
s.NotifyLearnPackageSubscribed(userID, planName)
}
}
func (s *Service) NotifyLMSPracticeMilestones(userID int64, result domain.LMSPracticeCompletionResult) {
if result.ModuleCompleted != nil {
s.NotifyModuleCompleted(userID, result.ModuleCompleted.Name)
}
if result.CourseCompleted != nil {
s.NotifyCourseCompleted(userID, result.CourseCompleted.Name)
}
if result.ProgramCompleted != nil {
s.NotifyProgramCompleted(userID, result.ProgramCompleted.Name)
}
}
func (s *Service) SendLearnPackageExpiryReminders(reminders []domain.SubscriptionExpiryReminder) {
for _, r := range reminders {
s.NotifyLearnPackageExpiringSoon(r.UserID, r.PlanName, r.ExpiresAt)
}
}
func (s *Service) send(userID int64, notifType domain.NotificationType, level domain.NotificationLevel, headline, message string) {
if s == nil || s.notifications == nil || userID == 0 {
return
}
go func() {
ctx := context.Background()
for _, channel := range []domain.DeliveryChannel{
domain.DeliveryChannelInApp,
domain.DeliveryChannelPush,
} {
notification := &domain.Notification{
RecipientID: userID,
ReceiverType: domain.ReceiverTypeUser,
Type: notifType,
Level: level,
Reciever: domain.NotificationRecieverSideCustomer,
DeliveryChannel: channel,
DeliveryStatus: domain.DeliveryStatusPending,
IsRead: false,
Payload: domain.NotificationPayload{
Headline: headline,
Message: message,
},
}
_ = s.notifications.SendNotification(ctx, notification)
}
}()
}

View File

@ -33,7 +33,7 @@ func (s *Service) CompleteLessonForUser(ctx context.Context, userID, lessonID in
}
// CompletePracticeForUser records practice completion and rolls up to module, course, and program when applicable.
func (s *Service) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) error {
func (s *Service) CompletePracticeForUser(ctx context.Context, userID, questionSetID int64) (domain.LMSPracticeCompletionResult, error) {
return s.store.CompletePracticeForUser(ctx, userID, questionSetID)
}

View File

@ -194,6 +194,10 @@ func (s *Service) RenewSubscription(ctx context.Context, subscriptionID int64) (
return s.GetSubscriptionByID(ctx, subscriptionID)
}
func (s *Service) ListLearnEnglishSubscriptionsExpiringInSevenDays(ctx context.Context) ([]domain.SubscriptionExpiryReminder, error) {
return s.store.ListLearnEnglishSubscriptionsExpiringInSevenDays(ctx)
}
// Helper functions
func strPtr(s string) *string {
return &s

View File

@ -15,6 +15,7 @@ import (
"Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/faqs"
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
"Yimaru-Backend/internal/services/learnernotifications"
"Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
minioservice "Yimaru-Backend/internal/services/minio"
@ -94,8 +95,10 @@ type App struct {
analyticsDB *dbgen.Queries
rbacSvc *rbacservice.Service
videoEngagementSvc *videoengagement.Service
stopPurgeWorker context.CancelFunc
stopPaymentExpiryWorker context.CancelFunc
stopPurgeWorker context.CancelFunc
stopPaymentExpiryWorker context.CancelFunc
stopSubscriptionExpiryReminderWorker context.CancelFunc
learnerNotifSvc *learnernotifications.Service
}
func NewApp(
@ -138,6 +141,7 @@ func NewApp(
analyticsDB *dbgen.Queries,
rbacSvc *rbacservice.Service,
videoEngagementSvc *videoengagement.Service,
learnerNotifSvc *learnernotifications.Service,
) *App {
app := fiber.New(fiber.Config{
CaseSensitive: true,
@ -199,6 +203,7 @@ func NewApp(
analyticsDB: analyticsDB,
rbacSvc: rbacSvc,
videoEngagementSvc: videoEngagementSvc,
learnerNotifSvc: learnerNotifSvc,
}
s.initAppRoutes()
@ -211,6 +216,8 @@ func (a *App) Run() error {
defer a.stopAccountDeletionPurgeWorker()
a.startPaymentExpiryWorker()
defer a.stopPaymentExpiryWorkerFunc()
a.startSubscriptionExpiryReminderWorker()
defer a.stopSubscriptionExpiryReminderWorkerFunc()
return a.fiber.Listen(fmt.Sprintf(":%d", a.port))
}
@ -327,3 +334,61 @@ func (a *App) runPaymentExpiryOnce(ctx context.Context) {
a.logger.Info("payment expiry run completed", "expired_count", expiredCount)
}
}
func (a *App) startSubscriptionExpiryReminderWorker() {
if a.cfg == nil || !a.cfg.SubscriptionExpiryReminderWorkerEnabled {
a.logger.Info("subscription expiry reminder worker disabled")
return
}
if a.learnerNotifSvc == nil || a.subscriptionsSvc == nil {
a.logger.Info("subscription expiry reminder worker skipped (missing services)")
return
}
interval := a.cfg.SubscriptionExpiryReminderWorkerInterval
if interval <= 0 {
interval = 24 * time.Hour
}
ctx, cancel := context.WithCancel(context.Background())
a.stopSubscriptionExpiryReminderWorker = cancel
a.logger.Info("starting subscription expiry reminder worker", "interval", interval.String())
go func() {
a.runSubscriptionExpiryReminderOnce(ctx)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
a.logger.Info("subscription expiry reminder worker stopped")
return
case <-ticker.C:
a.runSubscriptionExpiryReminderOnce(ctx)
}
}
}()
}
func (a *App) stopSubscriptionExpiryReminderWorkerFunc() {
if a.stopSubscriptionExpiryReminderWorker != nil {
a.stopSubscriptionExpiryReminderWorker()
}
}
func (a *App) runSubscriptionExpiryReminderOnce(ctx context.Context) {
reminders, err := a.subscriptionsSvc.ListLearnEnglishSubscriptionsExpiringInSevenDays(ctx)
if err != nil {
a.logger.Error("subscription expiry reminder run failed", "error", err)
return
}
if len(reminders) == 0 {
return
}
a.learnerNotifSvc.SendLearnPackageExpiryReminders(reminders)
a.logger.Info("subscription expiry reminder run completed", "notified_count", len(reminders))
}

View File

@ -61,6 +61,7 @@ func (h *Handler) GoogleAndroidLogin(c *fiber.Ctx) error {
Error: err.Error(),
})
}
h.notifyWelcomeIfNewAccount(loginRes)
// Issue backend JWT
accessToken, err := jwtutil.CreateJwt(
@ -132,6 +133,7 @@ func (h *Handler) AppleLogin(c *fiber.Ctx) error {
Error: err.Error(),
})
}
h.notifyWelcomeIfNewAccount(loginRes)
accessToken, err := jwtutil.CreateJwt(
loginRes.UserId,
@ -204,6 +206,7 @@ func (h *Handler) GoogleCallback(c *fiber.Ctx) error {
Error: err.Error(),
})
}
h.notifyWelcomeIfNewAccount(loginRes)
accessToken, err := jwtutil.CreateJwt(
loginRes.UserId,

View File

@ -18,6 +18,7 @@ import (
"Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/faqs"
issuereporting "Yimaru-Backend/internal/services/issue_reporting"
"Yimaru-Backend/internal/services/learnernotifications"
"Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress"
minioservice "Yimaru-Backend/internal/services/minio"
@ -72,6 +73,7 @@ type Handler struct {
logger *slog.Logger
settingSvc *settings.Service
notificationSvc *notificationservice.Service
learnerNotifSvc *learnernotifications.Service
userSvc *user.Service
transactionSvc *transaction.Service
recommendationSvc recommendation.RecommendationService
@ -114,6 +116,7 @@ func New(
logger *slog.Logger,
settingSvc *settings.Service,
notificationSvc *notificationservice.Service,
learnerNotifSvc *learnernotifications.Service,
validator *customvalidator.CustomValidator,
recommendationSvc recommendation.RecommendationService,
userSvc *user.Service,
@ -155,6 +158,7 @@ func New(
logger: logger,
settingSvc: settingSvc,
notificationSvc: notificationSvc,
learnerNotifSvc: learnerNotifSvc,
validator: validator,
userSvc: userSvc,
transactionSvc: transactionSvc,
@ -176,6 +180,12 @@ func New(
}
}
func (h *Handler) notifyWelcomeIfNewAccount(loginRes domain.LoginSuccess) {
if loginRes.IsNewAccount && h.learnerNotifSvc != nil {
h.learnerNotifSvc.NotifyWelcome(loginRes.UserId)
}
}
func (h *Handler) sendInAppNotification(recipientID int64, notifType domain.NotificationType, headline, message string) {
go func() {
notification := &domain.Notification{

View File

@ -1714,12 +1714,16 @@ func (h *Handler) CompletePractice(c *fiber.Ctx) error {
}
}
if err := h.lmsProgressSvc.CompletePracticeForUser(c.Context(), userID, set.ID); err != nil {
result, err := h.lmsProgressSvc.CompletePracticeForUser(c.Context(), userID, set.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to complete practice",
Error: err.Error(),
})
}
if h.learnerNotifSvc != nil {
h.learnerNotifSvc.NotifyLMSPracticeMilestones(userID, result)
}
return c.JSON(domain.Response{
Message: "Practice completed",
})

View File

@ -352,6 +352,14 @@ func (h *Handler) Subscribe(c *fiber.Ctx) error {
})
}
plan, err := h.subscriptionsSvc.GetPlanByID(c.Context(), req.PlanID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid subscription plan",
Error: err.Error(),
})
}
sub, err := h.subscriptionsSvc.Subscribe(c.Context(), userID, req.PlanID, req.PaymentReference, req.PaymentMethod)
if err != nil {
status := fiber.StatusInternalServerError
@ -363,6 +371,9 @@ func (h *Handler) Subscribe(c *fiber.Ctx) error {
Error: err.Error(),
})
}
if h.learnerNotifSvc != nil {
h.learnerNotifSvc.MaybeNotifyLearnPackageSubscribed(userID, plan.Category, plan.Name)
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Subscription created successfully",

View File

@ -1135,7 +1135,7 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error {
}
user.OtpMedium = medium
_, err = h.userSvc.RegisterUser(c.Context(), user)
created, err := h.userSvc.RegisterUser(c.Context(), user)
if err != nil {
h.mongoLoggerSvc.Error("Failed to register user",
zap.String("email", req.Email),
@ -1150,6 +1150,9 @@ func (h *Handler) RegisterUser(c *fiber.Ctx) error {
Error: err.Error(),
})
}
if h.learnerNotifSvc != nil {
h.learnerNotifSvc.NotifyWelcome(created.ID)
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Registration successful",

View File

@ -34,6 +34,7 @@ func (a *App) initAppRoutes() {
a.logger,
a.settingSvc,
a.NotidicationStore,
a.learnerNotifSvc,
a.validator,
a.recommendationSvc,
a.userSvc,