feat: send in-app and push learner notifications for key milestones
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ad4c739722
commit
5c6cb1b8d3
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.*,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
14
internal/domain/lms_progress_events.go
Normal file
14
internal/domain/lms_progress_events.go
Normal 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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
108
internal/services/learnernotifications/service.go
Normal file
108
internal/services/learnernotifications/service.go
Normal 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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ func (a *App) initAppRoutes() {
|
|||
a.logger,
|
||||
a.settingSvc,
|
||||
a.NotidicationStore,
|
||||
a.learnerNotifSvc,
|
||||
a.validator,
|
||||
a.recommendationSvc,
|
||||
a.userSvc,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user