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"
|
"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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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.*,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -145,8 +145,10 @@ type Config struct {
|
||||||
AccountDeletionPurgeEnabled bool
|
AccountDeletionPurgeEnabled bool
|
||||||
AccountDeletionPurgeInterval time.Duration
|
AccountDeletionPurgeInterval time.Duration
|
||||||
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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,10 @@ type AppleUser struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginSuccess struct {
|
type LoginSuccess struct {
|
||||||
UserId int64
|
UserId int64
|
||||||
Role Role
|
Role Role
|
||||||
RfToken string
|
RfToken string
|
||||||
|
IsNewAccount bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginRequest struct {
|
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_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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := q.InsertUserModuleProgress(ctx, dbgen.InsertUserModuleProgressParams{UserID: userID, ModuleID: *moduleID}); err != nil {
|
alreadyDone, err := q.HasUserCompletedModule(ctx, dbgen.HasUserCompletedModuleParams{
|
||||||
return err
|
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)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := q.InsertUserCourseProgress(ctx, dbgen.InsertUserCourseProgressParams{UserID: userID, CourseID: courseID}); err != nil {
|
alreadyDone, err := q.HasUserCompletedCourse(ctx, dbgen.HasUserCompletedCourseParams{
|
||||||
return err
|
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)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := q.InsertUserProgramProgress(ctx, dbgen.InsertUserProgramProgressParams{UserID: userID, ProgramID: programID}); err != nil {
|
alreadyDone, err = q.HasUserCompletedProgram(ctx, dbgen.HasUserCompletedProgramParams{
|
||||||
return err
|
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 {
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -224,8 +226,9 @@ func (s *Service) LoginWithApple(
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain.LoginSuccess{
|
return domain.LoginSuccess{
|
||||||
UserId: user.ID,
|
UserId: user.ID,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
RfToken: refreshToken,
|
RfToken: refreshToken,
|
||||||
|
IsNewAccount: isNewAccount,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -203,9 +205,10 @@ func (s *Service) LoginWithGoogle(
|
||||||
}
|
}
|
||||||
|
|
||||||
return domain.LoginSuccess{
|
return domain.LoginSuccess{
|
||||||
UserId: user.ID,
|
UserId: user.ID,
|
||||||
Role: user.Role,
|
Role: user.Role,
|
||||||
RfToken: refreshToken,
|
RfToken: refreshToken,
|
||||||
|
IsNewAccount: isNewAccount,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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.
|
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -94,8 +95,10 @@ type App struct {
|
||||||
analyticsDB *dbgen.Queries
|
analyticsDB *dbgen.Queries
|
||||||
rbacSvc *rbacservice.Service
|
rbacSvc *rbacservice.Service
|
||||||
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))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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{
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user