diff --git a/cmd/main.go b/cmd/main.go index 86a27cf..0cdb8fc 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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) diff --git a/db/query/lms_progress.sql b/db/query/lms_progress.sql index f7599c1..1b5aa9c 100644 --- a/db/query/lms_progress.sql +++ b/db/query/lms_progress.sql @@ -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) diff --git a/db/query/subscriptions.sql b/db/query/subscriptions.sql index ed961c5..aad736e 100644 --- a/db/query/subscriptions.sql +++ b/db/query/subscriptions.sql @@ -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.*, diff --git a/gen/db/lms_progress.sql.go b/gen/db/lms_progress.sql.go index 2c768e9..4987f82 100644 --- a/gen/db/lms_progress.sql.go +++ b/gen/db/lms_progress.sql.go @@ -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) diff --git a/gen/db/subscriptions.sql.go b/gen/db/subscriptions.sql.go index b6f65b8..f071733 100644 --- a/gen/db/subscriptions.sql.go +++ b/gen/db/subscriptions.sql.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index a5c0f6f..0ed565c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 == "" { diff --git a/internal/domain/auth.go b/internal/domain/auth.go index e791cd9..b8a5500 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -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 { diff --git a/internal/domain/lms_progress_events.go b/internal/domain/lms_progress_events.go new file mode 100644 index 0000000..c1eadbf --- /dev/null +++ b/internal/domain/lms_progress_events.go @@ -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 +} diff --git a/internal/domain/notification.go b/internal/domain/notification.go index dc3a660..e8bd9bf 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -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" diff --git a/internal/domain/subscriptions.go b/internal/domain/subscriptions.go index 740aea1..6c64db6 100644 --- a/internal/domain/subscriptions.go +++ b/internal/domain/subscriptions.go @@ -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 diff --git a/internal/ports/subscriptions.go b/internal/ports/subscriptions.go index d0e5ef8..27611c9 100644 --- a/internal/ports/subscriptions.go +++ b/internal/ports/subscriptions.go @@ -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) } diff --git a/internal/repository/lms_progress_tx.go b/internal/repository/lms_progress_tx.go index bcde9bf..9288aff 100644 --- a/internal/repository/lms_progress_tx.go +++ b/internal/repository/lms_progress_tx.go @@ -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 } diff --git a/internal/repository/subscriptions.go b/internal/repository/subscriptions.go index 21fef62..a414262 100644 --- a/internal/repository/subscriptions.go +++ b/internal/repository/subscriptions.go @@ -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 +} diff --git a/internal/services/arifpay/service.go b/internal/services/arifpay/service.go index b953877..d58a9a9 100644 --- a/internal/services/arifpay/service.go +++ b/internal/services/arifpay/service.go @@ -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) } } } diff --git a/internal/services/authentication/apple.go b/internal/services/authentication/apple.go index 1927601..99cfac9 100644 --- a/internal/services/authentication/apple.go +++ b/internal/services/authentication/apple.go @@ -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 } diff --git a/internal/services/authentication/google.go b/internal/services/authentication/google.go index d791965..cccfa4d 100644 --- a/internal/services/authentication/google.go +++ b/internal/services/authentication/google.go @@ -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 } diff --git a/internal/services/chapa/service.go b/internal/services/chapa/service.go index 6b7faf1..30bdd48 100644 --- a/internal/services/chapa/service.go +++ b/internal/services/chapa/service.go @@ -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 } diff --git a/internal/services/learnernotifications/service.go b/internal/services/learnernotifications/service.go new file mode 100644 index 0000000..fe55c79 --- /dev/null +++ b/internal/services/learnernotifications/service.go @@ -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) + } + }() +} diff --git a/internal/services/lmsprogress/service.go b/internal/services/lmsprogress/service.go index 7317d6c..9c27b3a 100644 --- a/internal/services/lmsprogress/service.go +++ b/internal/services/lmsprogress/service.go @@ -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) } diff --git a/internal/services/subscriptions/service.go b/internal/services/subscriptions/service.go index b2549df..3e1ef48 100644 --- a/internal/services/subscriptions/service.go +++ b/internal/services/subscriptions/service.go @@ -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 diff --git a/internal/web_server/app.go b/internal/web_server/app.go index 38532f3..a1c0eee 100644 --- a/internal/web_server/app.go +++ b/internal/web_server/app.go @@ -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)) +} diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index 7559817..5462f9e 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -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, diff --git a/internal/web_server/handlers/handlers.go b/internal/web_server/handlers/handlers.go index 873875a..1b202e3 100644 --- a/internal/web_server/handlers/handlers.go +++ b/internal/web_server/handlers/handlers.go @@ -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{ diff --git a/internal/web_server/handlers/questions.go b/internal/web_server/handlers/questions.go index 2684ed4..593a9b5 100644 --- a/internal/web_server/handlers/questions.go +++ b/internal/web_server/handlers/questions.go @@ -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", }) diff --git a/internal/web_server/handlers/subscriptions.go b/internal/web_server/handlers/subscriptions.go index 4d7dd86..327bf14 100644 --- a/internal/web_server/handlers/subscriptions.go +++ b/internal/web_server/handlers/subscriptions.go @@ -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", diff --git a/internal/web_server/handlers/user.go b/internal/web_server/handlers/user.go index a1a6de2..2b2f2f0 100644 --- a/internal/web_server/handlers/user.go +++ b/internal/web_server/handlers/user.go @@ -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", diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 78d7d5b..5010c06 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -34,6 +34,7 @@ func (a *App) initAppRoutes() { a.logger, a.settingSvc, a.NotidicationStore, + a.learnerNotifSvc, a.validator, a.recommendationSvc, a.userSvc,