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" "Yimaru-Backend/internal/services/faqs"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
lessonsservice "Yimaru-Backend/internal/services/lessons" lessonsservice "Yimaru-Backend/internal/services/lessons"
learnernotificationsservice "Yimaru-Backend/internal/services/learnernotifications"
"Yimaru-Backend/internal/services/lmsprogress" "Yimaru-Backend/internal/services/lmsprogress"
"Yimaru-Backend/internal/services/messenger" "Yimaru-Backend/internal/services/messenger"
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
@ -316,6 +317,8 @@ func main() {
store, // implements SubscriptionStore store, // implements SubscriptionStore
) )
learnerNotifSvc := learnernotificationsservice.New(notificationSvc)
// Chapa service for subscription checkout payments // Chapa service for subscription checkout payments
chapaSvc := chapa.NewService( chapaSvc := chapa.NewService(
cfg, cfg,
@ -324,6 +327,8 @@ func main() {
store, store,
store, store,
) )
chapaSvc.SetLearnerNotifier(learnerNotifSvc)
arifpaySvc.SetLearnerNotifier(learnerNotifSvc)
// Team management service // Team management service
teamSvc := team.NewService( teamSvc := team.NewService(
@ -398,6 +403,7 @@ func main() {
analyticsDB, analyticsDB,
rbacSvc, rbacSvc,
videoEngagementSvc, videoEngagementSvc,
learnerNotifSvc,
) )
logger.Info("Starting server", "port", cfg.Port) 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) ON CONFLICT (user_id, lesson_id)
DO NOTHING; 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 -- name: InsertUserModuleProgress :exec
INSERT INTO lms_user_module_progress (user_id, module_id) INSERT INTO lms_user_module_progress (user_id, module_id)
VALUES ($1, $2) VALUES ($1, $2)

View File

@ -166,6 +166,21 @@ JOIN subscription_plans sp ON sp.id = us.plan_id
WHERE us.status = 'ACTIVE' WHERE us.status = 'ACTIVE'
AND us.expires_at <= CURRENT_TIMESTAMP; 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 -- name: GetExpiringSubscriptions :many
SELECT SELECT
us.*, us.*,

View File

@ -795,6 +795,69 @@ func (q *Queries) GetPreviousProgram(ctx context.Context, id int64) (Program, er
return i, err 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 const InsertUserCourseProgress = `-- name: InsertUserCourseProgress :exec
INSERT INTO lms_user_course_progress (user_id, course_id) INSERT INTO lms_user_course_progress (user_id, course_id)
VALUES ($1, $2) VALUES ($1, $2)

View File

@ -628,6 +628,58 @@ func (q *Queries) ListActiveSubscriptionPlans(ctx context.Context) ([]Subscripti
return items, nil 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 const ListSubscriptionDisplayStatusesByUserIDs = `-- name: ListSubscriptionDisplayStatusesByUserIDs :many
WITH input AS ( WITH input AS (
SELECT unnest($1::bigint[])::bigint AS user_id SELECT unnest($1::bigint[])::bigint AS user_id

View File

@ -147,6 +147,8 @@ type Config struct {
AccountDeletionPurgeBatchSize int32 AccountDeletionPurgeBatchSize int32
PaymentExpiryWorkerEnabled bool PaymentExpiryWorkerEnabled bool
PaymentExpiryWorkerInterval time.Duration PaymentExpiryWorkerInterval time.Duration
SubscriptionExpiryReminderWorkerEnabled bool
SubscriptionExpiryReminderWorkerInterval time.Duration
InactiveSubModuleContentPurgeEnabled bool InactiveSubModuleContentPurgeEnabled bool
InactiveSubModuleContentPurgeInterval time.Duration InactiveSubModuleContentPurgeInterval time.Duration
InactiveSubModuleContentRetentionDays int 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 // Hard-delete inactive submodule lessons / practices / capstones after a retention period
inactiveContentPurge := strings.TrimSpace(os.Getenv("INACTIVE_SUBMODULE_CONTENT_PURGE_ENABLED")) inactiveContentPurge := strings.TrimSpace(os.Getenv("INACTIVE_SUBMODULE_CONTENT_PURGE_ENABLED"))
if inactiveContentPurge == "" { if inactiveContentPurge == "" {

View File

@ -26,6 +26,7 @@ type LoginSuccess struct {
UserId int64 UserId int64
Role Role Role Role
RfToken string RfToken string
IsNewAccount bool
} }
type LoginRequest struct { 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_TEAM_MEMBER_CREATED NotificationType = "team_member_created"
NOTIFICATION_TYPE_USER_DELETED NotificationType = "user_deleted" NOTIFICATION_TYPE_USER_DELETED NotificationType = "user_deleted"
NOTIFICATION_TYPE_SYSTEM_ALERT NotificationType = "system_alert" 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" NotificationRecieverSideAdmin NotificationRecieverSide = "admin"
NotificationRecieverSideCustomer NotificationRecieverSide = "customer" NotificationRecieverSideCustomer NotificationRecieverSide = "customer"

View File

@ -12,6 +12,13 @@ const (
SubscriptionCategoryDuolingo SubscriptionCategory = "DUOLINGO" 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). // CategorySubscriptionGateDisabled skips subscription enforcement on learner-facing routes (temporary).
var CategorySubscriptionGateDisabled = true var CategorySubscriptionGateDisabled = true

View File

@ -27,4 +27,5 @@ type SubscriptionStore interface {
UpdateSubscriptionStatus(ctx context.Context, id int64, status string) error UpdateSubscriptionStatus(ctx context.Context, id int64, status string) error
UpdateAutoRenew(ctx context.Context, id int64, autoRenew bool) error UpdateAutoRenew(ctx context.Context, id int64, autoRenew bool) error
ExtendSubscription(ctx context.Context, id int64, newExpiresAt time.Time) 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" "fmt"
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
"Yimaru-Backend/internal/domain"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
) )
@ -34,7 +35,7 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
return err 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 return err
} }
@ -46,10 +47,11 @@ func (s *Store) CompleteLessonForUser(ctx context.Context, userID, lessonID int6
// CompletePracticeForUser records practice completion and cascades practice-based // CompletePracticeForUser records practice completion and cascades practice-based
// completion upward when all published practices in scope are complete. // 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) q, tx, err := s.BeginTx(ctx)
if err != nil { if err != nil {
return fmt.Errorf("begin tx: %w", err) return empty, fmt.Errorf("begin tx: %w", err)
} }
defer func() { _ = tx.Rollback(ctx) }() defer func() { _ = tx.Rollback(ctx) }()
@ -57,7 +59,7 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
UserID: userID, UserID: userID,
QuestionSetID: questionSetID, QuestionSetID: questionSetID,
}); err != nil { }); err != nil {
return err return empty, err
} }
scope, err := q.GetPracticeScopeByQuestionSetID(ctx, questionSetID) 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) { if errors.Is(err, pgx.ErrNoRows) {
// Exam-prep practices are not in lms_practices; completion is tracked in user_practice_progress only. // Exam-prep practices are not in lms_practices; completion is tracked in user_practice_progress only.
if err := tx.Commit(ctx); err != nil { 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 ( var (
moduleID *int64 moduleID *int64
@ -81,133 +83,177 @@ func (s *Store) CompletePracticeForUser(ctx context.Context, userID, questionSet
moduleID = &mid moduleID = &mid
mod, err := q.GetModuleByID(ctx, mid) mod, err := q.GetModuleByID(ctx, mid)
if err != nil { if err != nil {
return err return empty, err
} }
courseID = mod.CourseID courseID = mod.CourseID
case scope.LessonID.Valid: case scope.LessonID.Valid:
lesson, err := q.GetLessonByID(ctx, scope.LessonID.Int64) lesson, err := q.GetLessonByID(ctx, scope.LessonID.Int64)
if err != nil { if err != nil {
return err return empty, err
} }
mid := lesson.ModuleID mid := lesson.ModuleID
moduleID = &mid moduleID = &mid
mod, err := q.GetModuleByID(ctx, mid) mod, err := q.GetModuleByID(ctx, mid)
if err != nil { if err != nil {
return err return empty, err
} }
courseID = mod.CourseID courseID = mod.CourseID
case scope.CourseID.Valid: case scope.CourseID.Valid:
courseID = scope.CourseID.Int64 courseID = scope.CourseID.Int64
default: 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) crs, err := q.GetCourseByID(ctx, courseID)
if err != nil { if err != nil {
return err return empty, err
} }
if err := s.cascadeLMSCompletion(ctx, q, userID, moduleID, crs.ID, crs.ProgramID); err != nil { result, err := s.cascadeLMSCompletion(ctx, q, userID, moduleID, crs.ID, crs.ProgramID)
return err if err != nil {
return empty, err
} }
if err := tx.Commit(ctx); err != nil { 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 { if moduleID != nil {
modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID)) modulePracticesTotal, err := q.CountPublishedPracticesInModule(ctx, toPgInt8(moduleID))
if err != nil { if err != nil {
return err return result, err
} }
modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{ modulePracticesDone, err := q.CountUserCompletedPublishedPracticesInModule(ctx, dbgen.CountUserCompletedPublishedPracticesInModuleParams{
ModuleID: toPgInt8(moduleID), ModuleID: toPgInt8(moduleID),
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return err return result, err
} }
modulePracticesComplete := modulePracticesTotal > 0 && modulePracticesDone >= modulePracticesTotal modulePracticesComplete := modulePracticesTotal > 0 && modulePracticesDone >= modulePracticesTotal
if !modulePracticesComplete { if !modulePracticesComplete {
return nil return result, nil
} }
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 { if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: *moduleID}); err != nil {
return err 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) nMods, err := q.CountModulesInCourse(ctx, courseID)
if err != nil { if err != nil {
return err return result, err
} }
nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{ nDoneMods, err := q.CountUserCompletedModulesInCourse(ctx, dbgen.CountUserCompletedModulesInCourseParams{
CourseID: courseID, CourseID: courseID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return err return result, err
} }
coursePracticesTotal, err := q.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID)) coursePracticesTotal, err := q.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
if err != nil { if err != nil {
return err return result, err
} }
coursePracticesDone, err := q.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{ coursePracticesDone, err := q.CountUserCompletedPublishedPracticesInCourse(ctx, dbgen.CountUserCompletedPublishedPracticesInCourseParams{
CourseID: toPgInt8(&courseID), CourseID: toPgInt8(&courseID),
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return err return result, err
} }
courseModulesComplete := nMods > 0 && nDoneMods >= nMods courseModulesComplete := nMods > 0 && nDoneMods >= nMods
coursePracticesComplete := coursePracticesTotal > 0 && coursePracticesDone >= coursePracticesTotal coursePracticesComplete := coursePracticesTotal > 0 && coursePracticesDone >= coursePracticesTotal
if !courseModulesComplete || !coursePracticesComplete { if !courseModulesComplete || !coursePracticesComplete {
return nil return result, nil
} }
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 { if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: courseID}); err != nil {
return err 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) nCr, err := q.CountCoursesInProgram(ctx, programID)
if err != nil { if err != nil {
return err return result, err
} }
nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{ nCrDone, err := q.CountUserCompletedCoursesInProgram(ctx, dbgen.CountUserCompletedCoursesInProgramParams{
ProgramID: programID, ProgramID: programID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return err return result, err
} }
programPracticesTotal, err := q.CountPublishedPracticesInProgram(ctx, programID) programPracticesTotal, err := q.CountPublishedPracticesInProgram(ctx, programID)
if err != nil { if err != nil {
return err return result, err
} }
programPracticesDone, err := q.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{ programPracticesDone, err := q.CountUserCompletedPublishedPracticesInProgram(ctx, dbgen.CountUserCompletedPublishedPracticesInProgramParams{
ProgramID: programID, ProgramID: programID,
UserID: userID, UserID: userID,
}) })
if err != nil { if err != nil {
return err return result, err
} }
programCoursesComplete := nCr > 0 && nCrDone >= nCr programCoursesComplete := nCr > 0 && nCrDone >= nCr
programPracticesComplete := programPracticesTotal > 0 && programPracticesDone >= programPracticesTotal programPracticesComplete := programPracticesTotal > 0 && programPracticesDone >= programPracticesTotal
if !programCoursesComplete || !programPracticesComplete { if !programCoursesComplete || !programPracticesComplete {
return nil return result, nil
} }
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 { if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: programID}); err != nil {
return err 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 { func float64Ptr(f float64) *float64 {
return &f 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/config"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports" "Yimaru-Backend/internal/ports"
learnernotifications "Yimaru-Backend/internal/services/learnernotifications"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -32,6 +33,7 @@ type ArifpayService struct {
httpClient *http.Client httpClient *http.Client
paymentStore ports.PaymentStore paymentStore ports.PaymentStore
subscriptionStore ports.SubscriptionStore subscriptionStore ports.SubscriptionStore
learnerNotifier *learnernotifications.Service
} }
func NewArifpayService( 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 // InitiateSubscriptionPayment creates a payment session for a subscription plan
func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) { func (s *ArifpayService) InitiateSubscriptionPayment(ctx context.Context, userID int64, req domain.InitiateSubscriptionPaymentRequest) (*domain.InitiateSubscriptionPaymentResponse, error) {
// Get the subscription plan // 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 { if err := s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID); err != nil {
return fmt.Errorf("failed to link payment to subscription: %w", err) return fmt.Errorf("failed to link payment to subscription: %w", err)
} }
s.notifyLearnPackageSubscribed(payment.UserID, plan)
} }
return nil return nil
@ -1035,6 +1048,7 @@ func (s *ArifpayService) VerifyDirectPaymentOTP(ctx context.Context, userID int6
}) })
if err == nil { if err == nil {
s.paymentStore.LinkPaymentToSubscription(ctx, payment.ID, subscription.ID) 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 user domain.User
var err error var err error
isNewAccount := false
user, err = s.userStore.GetUserByAppleID(ctx, aUser.ID) user, err = s.userStore.GetUserByAppleID(ctx, aUser.ID)
if err != nil { if err != nil {
@ -183,6 +184,7 @@ func (s *Service) LoginWithApple(
if err != nil { if err != nil {
return domain.LoginSuccess{}, err return domain.LoginSuccess{}, err
} }
isNewAccount = true
} else { } else {
if err := s.userStore.LinkAppleAccount(ctx, user.ID, aUser.ID, aUser.VerifiedEmail); err != nil { if err := s.userStore.LinkAppleAccount(ctx, user.ID, aUser.ID, aUser.VerifiedEmail); err != nil {
return domain.LoginSuccess{}, err return domain.LoginSuccess{}, err
@ -227,5 +229,6 @@ func (s *Service) LoginWithApple(
UserId: user.ID, UserId: user.ID,
Role: user.Role, Role: user.Role,
RfToken: refreshToken, RfToken: refreshToken,
IsNewAccount: isNewAccount,
}, nil }, nil
} }

View File

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

View File

@ -19,6 +19,7 @@ import (
"Yimaru-Backend/internal/config" "Yimaru-Backend/internal/config"
"Yimaru-Backend/internal/domain" "Yimaru-Backend/internal/domain"
"Yimaru-Backend/internal/ports" "Yimaru-Backend/internal/ports"
learnernotifications "Yimaru-Backend/internal/services/learnernotifications"
"github.com/google/uuid" "github.com/google/uuid"
) )
@ -37,6 +38,7 @@ type Service struct {
paymentStore ports.PaymentStore paymentStore ports.PaymentStore
subscriptionStore ports.SubscriptionStore subscriptionStore ports.SubscriptionStore
userStore ports.UserStore userStore ports.UserStore
learnerNotifier *learnernotifications.Service
} }
func NewService( func NewService(
@ -55,6 +57,10 @@ func NewService(
} }
} }
func (s *Service) SetLearnerNotifier(notifier *learnernotifications.Service) {
s.learnerNotifier = notifier
}
func (s *Service) configured() error { func (s *Service) configured() error {
if s.cfg.CHAPA_SECRET_KEY == "" { if s.cfg.CHAPA_SECRET_KEY == "" {
return ErrChapaNotConfigured 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) 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 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. // 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) 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) return s.GetSubscriptionByID(ctx, subscriptionID)
} }
func (s *Service) ListLearnEnglishSubscriptionsExpiringInSevenDays(ctx context.Context) ([]domain.SubscriptionExpiryReminder, error) {
return s.store.ListLearnEnglishSubscriptionsExpiringInSevenDays(ctx)
}
// Helper functions // Helper functions
func strPtr(s string) *string { func strPtr(s string) *string {
return &s return &s

View File

@ -15,6 +15,7 @@ import (
"Yimaru-Backend/internal/services/examprep" "Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/faqs" "Yimaru-Backend/internal/services/faqs"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
"Yimaru-Backend/internal/services/learnernotifications"
"Yimaru-Backend/internal/services/lessons" "Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress" "Yimaru-Backend/internal/services/lmsprogress"
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
@ -96,6 +97,8 @@ type App struct {
videoEngagementSvc *videoengagement.Service videoEngagementSvc *videoengagement.Service
stopPurgeWorker context.CancelFunc stopPurgeWorker context.CancelFunc
stopPaymentExpiryWorker context.CancelFunc stopPaymentExpiryWorker context.CancelFunc
stopSubscriptionExpiryReminderWorker context.CancelFunc
learnerNotifSvc *learnernotifications.Service
} }
func NewApp( func NewApp(
@ -138,6 +141,7 @@ func NewApp(
analyticsDB *dbgen.Queries, analyticsDB *dbgen.Queries,
rbacSvc *rbacservice.Service, rbacSvc *rbacservice.Service,
videoEngagementSvc *videoengagement.Service, videoEngagementSvc *videoengagement.Service,
learnerNotifSvc *learnernotifications.Service,
) *App { ) *App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
CaseSensitive: true, CaseSensitive: true,
@ -199,6 +203,7 @@ func NewApp(
analyticsDB: analyticsDB, analyticsDB: analyticsDB,
rbacSvc: rbacSvc, rbacSvc: rbacSvc,
videoEngagementSvc: videoEngagementSvc, videoEngagementSvc: videoEngagementSvc,
learnerNotifSvc: learnerNotifSvc,
} }
s.initAppRoutes() s.initAppRoutes()
@ -211,6 +216,8 @@ func (a *App) Run() error {
defer a.stopAccountDeletionPurgeWorker() defer a.stopAccountDeletionPurgeWorker()
a.startPaymentExpiryWorker() a.startPaymentExpiryWorker()
defer a.stopPaymentExpiryWorkerFunc() defer a.stopPaymentExpiryWorkerFunc()
a.startSubscriptionExpiryReminderWorker()
defer a.stopSubscriptionExpiryReminderWorkerFunc()
return a.fiber.Listen(fmt.Sprintf(":%d", a.port)) 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) 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(), Error: err.Error(),
}) })
} }
h.notifyWelcomeIfNewAccount(loginRes)
// Issue backend JWT // Issue backend JWT
accessToken, err := jwtutil.CreateJwt( accessToken, err := jwtutil.CreateJwt(
@ -132,6 +133,7 @@ func (h *Handler) AppleLogin(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
h.notifyWelcomeIfNewAccount(loginRes)
accessToken, err := jwtutil.CreateJwt( accessToken, err := jwtutil.CreateJwt(
loginRes.UserId, loginRes.UserId,
@ -204,6 +206,7 @@ func (h *Handler) GoogleCallback(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
h.notifyWelcomeIfNewAccount(loginRes)
accessToken, err := jwtutil.CreateJwt( accessToken, err := jwtutil.CreateJwt(
loginRes.UserId, loginRes.UserId,

View File

@ -18,6 +18,7 @@ import (
"Yimaru-Backend/internal/services/examprep" "Yimaru-Backend/internal/services/examprep"
"Yimaru-Backend/internal/services/faqs" "Yimaru-Backend/internal/services/faqs"
issuereporting "Yimaru-Backend/internal/services/issue_reporting" issuereporting "Yimaru-Backend/internal/services/issue_reporting"
"Yimaru-Backend/internal/services/learnernotifications"
"Yimaru-Backend/internal/services/lessons" "Yimaru-Backend/internal/services/lessons"
"Yimaru-Backend/internal/services/lmsprogress" "Yimaru-Backend/internal/services/lmsprogress"
minioservice "Yimaru-Backend/internal/services/minio" minioservice "Yimaru-Backend/internal/services/minio"
@ -72,6 +73,7 @@ type Handler struct {
logger *slog.Logger logger *slog.Logger
settingSvc *settings.Service settingSvc *settings.Service
notificationSvc *notificationservice.Service notificationSvc *notificationservice.Service
learnerNotifSvc *learnernotifications.Service
userSvc *user.Service userSvc *user.Service
transactionSvc *transaction.Service transactionSvc *transaction.Service
recommendationSvc recommendation.RecommendationService recommendationSvc recommendation.RecommendationService
@ -114,6 +116,7 @@ func New(
logger *slog.Logger, logger *slog.Logger,
settingSvc *settings.Service, settingSvc *settings.Service,
notificationSvc *notificationservice.Service, notificationSvc *notificationservice.Service,
learnerNotifSvc *learnernotifications.Service,
validator *customvalidator.CustomValidator, validator *customvalidator.CustomValidator,
recommendationSvc recommendation.RecommendationService, recommendationSvc recommendation.RecommendationService,
userSvc *user.Service, userSvc *user.Service,
@ -155,6 +158,7 @@ func New(
logger: logger, logger: logger,
settingSvc: settingSvc, settingSvc: settingSvc,
notificationSvc: notificationSvc, notificationSvc: notificationSvc,
learnerNotifSvc: learnerNotifSvc,
validator: validator, validator: validator,
userSvc: userSvc, userSvc: userSvc,
transactionSvc: transactionSvc, 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) { func (h *Handler) sendInAppNotification(recipientID int64, notifType domain.NotificationType, headline, message string) {
go func() { go func() {
notification := &domain.Notification{ 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{ return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Failed to complete practice", Message: "Failed to complete practice",
Error: err.Error(), Error: err.Error(),
}) })
} }
if h.learnerNotifSvc != nil {
h.learnerNotifSvc.NotifyLMSPracticeMilestones(userID, result)
}
return c.JSON(domain.Response{ return c.JSON(domain.Response{
Message: "Practice completed", 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) sub, err := h.subscriptionsSvc.Subscribe(c.Context(), userID, req.PlanID, req.PaymentReference, req.PaymentMethod)
if err != nil { if err != nil {
status := fiber.StatusInternalServerError status := fiber.StatusInternalServerError
@ -363,6 +371,9 @@ func (h *Handler) Subscribe(c *fiber.Ctx) error {
Error: err.Error(), Error: err.Error(),
}) })
} }
if h.learnerNotifSvc != nil {
h.learnerNotifSvc.MaybeNotifyLearnPackageSubscribed(userID, plan.Category, plan.Name)
}
return c.Status(fiber.StatusCreated).JSON(domain.Response{ return c.Status(fiber.StatusCreated).JSON(domain.Response{
Message: "Subscription created successfully", Message: "Subscription created successfully",

View File

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

View File

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