diff --git a/pkg/authkit/models/programs.go b/pkg/authkit/models/programs.go new file mode 100644 index 0000000..8c4f27c --- /dev/null +++ b/pkg/authkit/models/programs.go @@ -0,0 +1,33 @@ +package models + +import ( + "time" + + "gorm.io/datatypes" +) + +type ProgramPrice struct { + Currency string `json:"currency"` + Amount float64 `json:"amount"` +} + +type Program struct { + BaseModel + + Name string `json:"name"` + Description string `json:"description"` + Alias string `json:"alias" gorm:"uniqueIndex"` + ExpRequirement int64 `json:"exp_requirement"` + Price datatypes.JSONType[ProgramPrice] `json:"price"` + Appearance datatypes.JSONMap `json:"appearance"` +} + +type ProgramMember struct { + BaseModel + + LastPaid *time.Time `json:"last_paid"` + Account Account `json:"account"` + AccountID uint `json:"account_id"` + Program Program `json:"program"` + ProgramID uint `json:"program_id"` +} diff --git a/pkg/internal/database/migrator.go b/pkg/internal/database/migrator.go index 8cf9702..e86a063 100644 --- a/pkg/internal/database/migrator.go +++ b/pkg/internal/database/migrator.go @@ -30,6 +30,8 @@ var AutoMaintainRange = []any{ &models.PreferenceNotification{}, &models.PreferenceAuth{}, &models.AbuseReport{}, + &models.Program{}, + &models.ProgramMember{}, } func RunMigration(source *gorm.DB) error { diff --git a/pkg/internal/services/programs.go b/pkg/internal/services/programs.go new file mode 100644 index 0000000..d4500c3 --- /dev/null +++ b/pkg/internal/services/programs.go @@ -0,0 +1,88 @@ +package services + +import ( + "context" + "fmt" + "time" + + "git.solsynth.dev/hypernet/passport/pkg/authkit/models" + "git.solsynth.dev/hypernet/passport/pkg/internal/database" + "git.solsynth.dev/hypernet/passport/pkg/internal/gap" + "git.solsynth.dev/hypernet/wallet/pkg/proto" + "github.com/samber/lo" +) + +func JoinProgram(user models.Account, program models.Program) (models.ProgramMember, error) { + var member models.ProgramMember + if err := database.C.Where("account_id = ? AND program_id = ?", user.ID, program.ID).First(&member).Error; err == nil { + return member, fmt.Errorf("program member already exists") + } + var profile models.AccountProfile + if err := database.C.Where("account_id = ?", user.ID).Select("Experience").First(&profile).Error; err != nil { + return member, err + } + if program.ExpRequirement < int64(profile.Experience) { + return member, fmt.Errorf("insufficient experience") + } + member = models.ProgramMember{ + LastPaid: lo.ToPtr(time.Now()), + Account: user, + AccountID: user.ID, + Program: program, + ProgramID: program.ID, + } + if err := ChargeForProgram(member); err != nil { + return member, err + } + if err := database.C.Create(&member).Error; err != nil { + return member, err + } + return member, nil +} + +func LeaveProgram(user models.Account, program models.Program) error { + var member models.ProgramMember + if err := database.C.Where("account_id = ? AND program_id = ?", user.ID, program.ID).First(&member).Error; err != nil { + return err + } + if err := database.C.Delete(&member).Error; err != nil { + return err + } + return nil +} + +func ChargeForProgram(member models.ProgramMember) error { + pricing := member.Program.Price.Data() + if pricing.Amount == 0 { + return nil + } + conn, err := gap.Nx.GetClientGrpcConn("wa") + if err != nil { + return err + } + wc := proto.NewPaymentServiceClient(conn) + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + _, err = wc.MakeTransactionWithAccount(ctx, &proto.MakeTransactionWithAccountRequest{ + PayeeAccountId: lo.ToPtr(uint64(member.AccountID)), + Amount: pricing.Amount, + Currency: pricing.Currency, + Remark: fmt.Sprintf("Program Membership: %s", member.Program.Name), + }) + return err +} + +func PeriodicChargeProgramFee() { + var members []models.ProgramMember + if err := database.C.Preload("Program").Find(&members).Error; err != nil { + return + } + for _, member := range members { + // every month paid once + if member.LastPaid == nil || time.Since(*member.LastPaid) < time.Hour*24*30 { + if err := ChargeForProgram(member); err == nil { + database.C.Model(&member).Update("last_paid", time.Now()) + } + } + } +} diff --git a/pkg/internal/web/api/index.go b/pkg/internal/web/api/index.go index ed7f4c0..199a80e 100644 --- a/pkg/internal/web/api/index.go +++ b/pkg/internal/web/api/index.go @@ -166,6 +166,15 @@ func MapControllers(app *fiber.App, baseURL string) { realms.Delete("/:realm/me", leaveRealm) } + programs := api.Group("/programs").Name("Programs API") + { + programs.Get("/", listProgram) + programs.Get("/members", listProgramMembership) + programs.Get("/:programId", getProgram) + programs.Post("/:programId", joinProgram) + programs.Delete("/:programId", leaveProgram) + } + developers := api.Group("/dev").Name("Developers API") { developers.Post("/notify/:user", notifyUser) diff --git a/pkg/internal/web/api/programs_api.go b/pkg/internal/web/api/programs_api.go new file mode 100644 index 0000000..7f420dd --- /dev/null +++ b/pkg/internal/web/api/programs_api.go @@ -0,0 +1,72 @@ +package api + +import ( + "git.solsynth.dev/hypernet/passport/pkg/authkit/models" + "git.solsynth.dev/hypernet/passport/pkg/internal/database" + "git.solsynth.dev/hypernet/passport/pkg/internal/services" + "git.solsynth.dev/hypernet/passport/pkg/internal/web/exts" + "github.com/gofiber/fiber/v2" +) + +func listProgram(c *fiber.Ctx) error { + var programs []models.Program + if err := database.C.Find(&programs).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return c.JSON(programs) +} + +func getProgram(c *fiber.Ctx) error { + var program models.Program + programId, _ := c.ParamsInt("programId") + if err := database.C.Where("id = ?", programId).First(&program).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + return c.JSON(program) +} + +func listProgramMembership(c *fiber.Ctx) error { + if err := exts.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(models.Account) + var members []models.ProgramMember + if err := database.C.Where("account_id = ?", user.ID).Preload("Program").Find(&members).Error; err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return c.JSON(members) +} + +func joinProgram(c *fiber.Ctx) error { + if err := exts.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(models.Account) + programId, _ := c.ParamsInt("programId") + var program models.Program + if err := database.C.Where("id = ?", programId).First(&program).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + if member, err := services.JoinProgram(user, program); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else { + return c.JSON(member) + } +} + +func leaveProgram(c *fiber.Ctx) error { + if err := exts.EnsureAuthenticated(c); err != nil { + return err + } + user := c.Locals("user").(models.Account) + programId, _ := c.ParamsInt("programId") + var program models.Program + if err := database.C.Where("id = ?", programId).First(&program).Error; err != nil { + return fiber.NewError(fiber.StatusNotFound, err.Error()) + } + if err := services.LeaveProgram(user, program); err != nil { + return fiber.NewError(fiber.StatusBadRequest, err.Error()) + } else { + return c.SendStatus(fiber.StatusNoContent) + } +} diff --git a/pkg/main.go b/pkg/main.go index e82ce0c..84967b4 100644 --- a/pkg/main.go +++ b/pkg/main.go @@ -104,6 +104,7 @@ func main() { quartz.AddFunc("@every 60m", services.DoAutoDatabaseCleanup) quartz.AddFunc("@midnight", services.RecycleUnConfirmAccount) quartz.AddFunc("@every 60s", services.SaveEventChanges) + quartz.AddFunc("@midnight", services.PeriodicChargeProgramFee) quartz.Start() // Messages