diff --git a/server/cmd/museum/main.go b/server/cmd/museum/main.go index abdd9e58ce..cf05b263e8 100644 --- a/server/cmd/museum/main.go +++ b/server/cmd/museum/main.go @@ -31,6 +31,7 @@ import ( cache2 "github.com/ente-io/museum/ente/cache" "github.com/ente-io/museum/pkg/controller/discord" + discountCouponCtrl "github.com/ente-io/museum/pkg/controller/discountcoupon" "github.com/ente-io/museum/pkg/controller/offer" "github.com/ente-io/museum/pkg/controller/usercache" @@ -56,6 +57,7 @@ import ( authenticatorRepo "github.com/ente-io/museum/pkg/repo/authenticator" castRepo "github.com/ente-io/museum/pkg/repo/cast" "github.com/ente-io/museum/pkg/repo/datacleanup" + discountCouponRepo "github.com/ente-io/museum/pkg/repo/discountcoupon" "github.com/ente-io/museum/pkg/repo/embedding" fileDataRepo "github.com/ente-io/museum/pkg/repo/filedata" "github.com/ente-io/museum/pkg/repo/kex" @@ -804,6 +806,18 @@ func main() { offerHandler := &api.OfferHandler{Controller: offerController} publicAPI.GET("/offers/black-friday", offerHandler.GetBlackFridayOffers) + discountCouponRepository := &discountCouponRepo.Repository{DB: db} + discountCouponController := &discountCouponCtrl.Controller{ + Repo: discountCouponRepository, + UserRepo: userRepo, + BillingController: billingController, + EmailNotificationCtrl: emailNotificationCtrl, + DiscordController: discordController, + } + discountCouponHandler := &api.DiscountCouponHandler{Controller: discountCouponController} + publicAPI.POST("/discount/claim", discountCouponHandler.ClaimCoupon) + adminAPI.POST("/discount/add-coupons", discountCouponHandler.AddCoupons) + setKnownAPIs(server.Routes()) setupAndStartBackgroundJobs(objectCleanupController, replicationController3, fileDataCtrl) setupAndStartCrons( diff --git a/server/mail-templates/discount_coupon.html b/server/mail-templates/discount_coupon.html new file mode 100644 index 0000000000..faf91c3499 --- /dev/null +++ b/server/mail-templates/discount_coupon.html @@ -0,0 +1,26 @@ +{{template "base" .}} + +{{define "content"}} +

Your Discount Code is Here! 🎉

+ +

Hi there,

+ +

Great news! We've got a special discount code just for you from {{.ProviderName}}.

+ +
+

Your Discount Code:

+
+ {{.CouponCode}} +
+
+ +

This exclusive discount is available to you as a valued Ente customer. Simply use the code above when making your purchase with {{.ProviderName}}.

+ +

Important: This discount code is unique to you and cannot be transferred to another account.

+ +

Thank you for being part of the Ente community!

+ +

Best regards,
+The Ente Team

+ +{{end}} \ No newline at end of file diff --git a/server/mail-templates/discount_coupon_kagi.html b/server/mail-templates/discount_coupon_kagi.html new file mode 100644 index 0000000000..50f4d0091c --- /dev/null +++ b/server/mail-templates/discount_coupon_kagi.html @@ -0,0 +1,19 @@ +{{template "base" .}} + +{{define "content"}} +

Ente Friends - Kagi trial code

+ +

Hello,

+ +

Here is your coupon code for a free 3 month trial of Kagi Search - {{.CouponCode}}

+ +
+ + Or you can click here to start your trial. + +
+ +

Best,
+Team Ente

+ +{{end}} \ No newline at end of file diff --git a/server/mail-templates/discount_coupon_test.html b/server/mail-templates/discount_coupon_test.html new file mode 100644 index 0000000000..4e3fb5381d --- /dev/null +++ b/server/mail-templates/discount_coupon_test.html @@ -0,0 +1,19 @@ +{{template "base" .}} + +{{define "content"}} +

Ente Friends - Test trial code

+ +

Hello,

+ +

Here is your coupon code for a free 3 month trial of Test Search - {{.CouponCode}}

+ +
+ + Or you can click here to start your trial. + +
+ +

Best,
+ Team Ente

+ +{{end}} \ No newline at end of file diff --git a/server/migrations/105_add_discount_coupons.down.sql b/server/migrations/105_add_discount_coupons.down.sql new file mode 100644 index 0000000000..25a7eeab64 --- /dev/null +++ b/server/migrations/105_add_discount_coupons.down.sql @@ -0,0 +1,3 @@ +DROP TRIGGER IF EXISTS update_discount_coupons_updated_at ON discount_coupons; +DROP INDEX IF EXISTS discount_coupons_provider_user_unique; +DROP TABLE IF EXISTS discount_coupons; \ No newline at end of file diff --git a/server/migrations/105_add_discount_coupons.up.sql b/server/migrations/105_add_discount_coupons.up.sql new file mode 100644 index 0000000000..4fede2cbdf --- /dev/null +++ b/server/migrations/105_add_discount_coupons.up.sql @@ -0,0 +1,21 @@ +CREATE TABLE discount_coupons ( + provider_name TEXT NOT NULL, + code TEXT NOT NULL, + claimed_by_user_id BIGINT DEFAULT NULL, + claimed_at BIGINT DEFAULT NULL, + sent_count INTEGER DEFAULT 0, + created_at BIGINT NOT NULL DEFAULT now_utc_micro_seconds(), + updated_at BIGINT NOT NULL DEFAULT now_utc_micro_seconds(), + CONSTRAINT discount_coupons_provider_code_unique UNIQUE (provider_name, code) +); + +CREATE UNIQUE INDEX discount_coupons_provider_user_unique +ON discount_coupons (provider_name, claimed_by_user_id) +WHERE claimed_by_user_id IS NOT NULL; + +CREATE TRIGGER update_discount_coupons_updated_at + BEFORE UPDATE + ON discount_coupons + FOR EACH ROW +EXECUTE PROCEDURE + trigger_updated_at_microseconds_column(); \ No newline at end of file diff --git a/server/pkg/api/discountcoupon.go b/server/pkg/api/discountcoupon.go new file mode 100644 index 0000000000..4dc343a5e8 --- /dev/null +++ b/server/pkg/api/discountcoupon.go @@ -0,0 +1,38 @@ +package api + +import ( + "net/http" + + "github.com/ente-io/museum/pkg/controller/discountcoupon" + "github.com/ente-io/museum/pkg/utils/handler" + "github.com/gin-gonic/gin" +) + +type DiscountCouponHandler struct { + Controller *discountcoupon.Controller +} + +func (h *DiscountCouponHandler) ClaimCoupon(c *gin.Context) { + var req discountcoupon.ClaimCouponRequest + if err := c.ShouldBindJSON(&req); err != nil { + handler.Error(c, err) + return + } + h.Controller.ClaimCoupon(c, req) + c.JSON(http.StatusOK, gin.H{"message": "If you are paid subscriber, you should shortly get an email."}) +} + +func (h *DiscountCouponHandler) AddCoupons(c *gin.Context) { + var req discountcoupon.AddCouponsRequest + if err := c.ShouldBindJSON(&req); err != nil { + handler.Error(c, err) + return + } + + err := h.Controller.AddCoupons(c, req) + if err != nil { + handler.Error(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Coupons added successfully"}) +} diff --git a/server/pkg/controller/discountcoupon/controller.go b/server/pkg/controller/discountcoupon/controller.go new file mode 100644 index 0000000000..eea38a2ab4 --- /dev/null +++ b/server/pkg/controller/discountcoupon/controller.go @@ -0,0 +1,203 @@ +package discountcoupon + +import ( + "context" + "fmt" + "github.com/ente-io/museum/ente" + "github.com/ente-io/museum/pkg/controller" + "github.com/ente-io/museum/pkg/controller/discord" + "github.com/ente-io/museum/pkg/controller/email" + "github.com/ente-io/museum/pkg/repo" + "github.com/ente-io/museum/pkg/repo/discountcoupon" + emailUtil "github.com/ente-io/museum/pkg/utils/email" + "github.com/ente-io/stacktrace" + "github.com/gin-contrib/requestid" + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + "strings" +) + +const MaxSendCount = 10 + +// AllowedProviders is a set of valid provider names for discount coupons. +// While adding new providers, consider adding customized templates in email package. +var AllowedProviders = map[string]bool{ + "Kagi": true, + "Test": true, +} + +type Controller struct { + Repo *discountcoupon.Repository + UserRepo *repo.UserRepository + BillingController *controller.BillingController + EmailNotificationCtrl *email.EmailNotificationController + DiscordController *discord.DiscordController +} + +type ClaimCouponRequest struct { + ProviderName string `json:"providerName" binding:"required"` + Email string `json:"email" binding:"required"` +} + +type AddCouponsRequest struct { + ProviderName string `json:"providerName" binding:"required"` + Codes []string `json:"codes" binding:"required"` +} + +func (c *Controller) ClaimCoupon(ctx *gin.Context, req ClaimCouponRequest) { + go c.processClaimRequest(ctx, req) +} + +func (c *Controller) processClaimRequest(ctx *gin.Context, req ClaimCouponRequest) { + logger := + log.WithField("provider", req.ProviderName). + WithField("email", req.Email). + WithField("req_id", requestid.Get(ctx)) + + if !AllowedProviders[req.ProviderName] { + logger.Info("Invalid provider name for discount coupon") + return + } + userID, err := c.UserRepo.GetUserIDWithEmail(req.Email) + if err != nil { + logger.WithError(err).Info("User not found for discount coupon claim") + return + } + + logger = logger.WithField("userID", userID) + + user, err := c.UserRepo.GetUserByIDInternal(userID) + if err != nil { + logger.WithError(err).Error("Failed to get user details") + return + } + + eligible, err := c.isUserEligible(user) + if err != nil { + logger.WithError(err).Error("Failed to check user eligibility") + return + } + + if !eligible { + logger.Info("User not eligible for discount coupon") + return + } + + existingCoupon, err := c.Repo.GetClaimedCoupon(ctx, req.ProviderName, user.ID) + if err != nil { + logger.WithError(err).Error("Failed to get existing claimed coupon") + return + } + + if existingCoupon != nil { + if existingCoupon.SentCount >= MaxSendCount { + logger.Info("User has reached maximum send count for coupon") + return + } + + err = c.sendCouponEmail(ctx, user, existingCoupon.Code, req.ProviderName) + if err != nil { + logger.WithError(err).Error("Failed to resend coupon email") + return + } + + err = c.Repo.IncrementSentCount(ctx, req.ProviderName, existingCoupon.Code) + if err != nil { + logger.WithError(err).Error("Failed to increment sent count") + } + return + } + + unclaimedCoupon, err := c.Repo.GetUnclaimedCoupon(ctx, req.ProviderName) + if err != nil { + logger.WithError(err).Error("Failed to get unclaimed coupon") + return + } + + if unclaimedCoupon == nil { + c.alertCouponsDepletedDiscord(req.ProviderName) + logger.Warn("No unclaimed coupons available") + return + } + + err = c.Repo.ClaimCoupon(ctx, req.ProviderName, unclaimedCoupon.Code, user.ID) + if err != nil { + logger.WithError(err).Error("Failed to claim coupon") + return + } + + err = c.sendCouponEmail(ctx, user, unclaimedCoupon.Code, req.ProviderName) + if err != nil { + logger.WithError(err).Error("Failed to send coupon email") + return + } + + logger.Info("Successfully claimed and sent coupon") +} + +func (c *Controller) isUserEligible(user ente.User) (bool, error) { + userID := user.ID + if user.FamilyAdminID != nil && *user.FamilyAdminID != userID { + return false, nil + } + + err := c.BillingController.HasActiveSelfOrFamilySubscription(userID, true) + if err != nil { + return false, stacktrace.Propagate(err, "failed to check active subscription") + } + + return true, nil +} + +func (c *Controller) sendCouponEmail(ctx context.Context, user ente.User, couponCode, providerName string) error { + templateData := map[string]interface{}{ + "CouponCode": couponCode, + "ProviderName": providerName, + } + + var subject, templateName string + switch providerName { + case "Kagi": + subject = "Ente Friends - Kagi trial code" + templateName = "discount_coupon_kagi.html" + case "Test": + subject = "Ente Friends - Test trial code" + templateName = "discount_coupon_test.html" + default: + subject = fmt.Sprintf("Your %s Discount Code", providerName) + templateName = "discount_coupon.html" + } + return emailUtil.SendTemplatedEmailV2([]string{user.Email}, "Ente", "team@ente.io", subject, "base.html", templateName, templateData, nil) +} + +func (c *Controller) alertCouponsDepletedDiscord(providerName string) { + message := fmt.Sprintf("🚨 Alert: All discount coupons for provider **%s** have been claimed!", providerName) + c.DiscordController.NotifyAdminAction(message) +} + +func (c *Controller) AddCoupons(ctx *gin.Context, req AddCouponsRequest) error { + if !AllowedProviders[req.ProviderName] { + return ente.NewBadRequestWithMessage("Invalid provider name") + } + if len(req.Codes) == 0 { + return ente.NewBadRequestWithMessage("No coupon codes provided") + } + + // Filter out empty codes and validate + validCodes := make([]string, 0, len(req.Codes)) + for _, code := range req.Codes { + trimmed := strings.TrimSpace(code) + if trimmed != "" { + validCodes = append(validCodes, trimmed) + } + } + if len(validCodes) == 0 { + return nil + } + err := c.Repo.AddCoupons(ctx, req.ProviderName, req.Codes) + if err != nil { + return stacktrace.Propagate(err, "failed to add coupons") + } + + return nil +} diff --git a/server/pkg/repo/discountcoupon/repository.go b/server/pkg/repo/discountcoupon/repository.go new file mode 100644 index 0000000000..66f7689b98 --- /dev/null +++ b/server/pkg/repo/discountcoupon/repository.go @@ -0,0 +1,133 @@ +package discountcoupon + +import ( + "context" + "database/sql" + "fmt" + "github.com/ente-io/stacktrace" + "strings" +) + +type Repository struct { + DB *sql.DB +} + +type DiscountCoupon struct { + ProviderName string + Code string + ClaimedByUserID *int64 + ClaimedAt *int64 + SentCount int + CreatedAt int64 + UpdatedAt int64 +} + +func (r *Repository) AddCoupons(ctx context.Context, providerName string, codes []string) error { + query := `INSERT INTO discount_coupons (provider_name, code) VALUES ` + var values []interface{} + var placeholders []string + + for i, code := range codes { + placeholders = append(placeholders, + fmt.Sprintf("($%d, $%d)", i*2+1, i*2+2)) + values = append(values, providerName, code) + } + + query += strings.Join(placeholders, ", ") + + " ON CONFLICT (provider_name, code) DO NOTHING" + + _, err := r.DB.ExecContext(ctx, query, values...) + if err != nil { + return stacktrace.Propagate(err, + "failed to insert %d discount coupons for provider %s", + len(codes), providerName) + } + + return nil +} + +func (r *Repository) GetUnclaimedCoupon(ctx context.Context, providerName string) (*DiscountCoupon, error) { + query := `SELECT provider_name, code, claimed_by_user_id, claimed_at, sent_count, created_at, updated_at + FROM discount_coupons + WHERE provider_name = $1 AND claimed_by_user_id IS NULL + ORDER BY created_at ASC + LIMIT 1` + + var coupon DiscountCoupon + row := r.DB.QueryRowContext(ctx, query, providerName) + err := row.Scan(&coupon.ProviderName, &coupon.Code, &coupon.ClaimedByUserID, &coupon.ClaimedAt, &coupon.SentCount, &coupon.CreatedAt, &coupon.UpdatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, stacktrace.Propagate(err, "failed to get unclaimed coupon") + } + + return &coupon, nil +} + +func (r *Repository) ClaimCoupon(ctx context.Context, providerName, code string, userID int64) error { + query := `UPDATE discount_coupons + SET claimed_by_user_id = $1, claimed_at = now_utc_micro_seconds() + WHERE provider_name = $2 AND code = $3 AND claimed_by_user_id IS NULL` + + result, err := r.DB.ExecContext(ctx, query, userID, providerName, code) + if err != nil { + return stacktrace.Propagate(err, "failed to claim coupon") + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return stacktrace.Propagate(err, "failed to get affected rows") + } + + if rowsAffected == 0 { + return stacktrace.NewError("coupon not found or already claimed") + } + + return nil +} + +func (r *Repository) GetClaimedCoupon(ctx context.Context, providerName string, userID int64) (*DiscountCoupon, error) { + query := `SELECT provider_name, code, claimed_by_user_id, claimed_at, sent_count, created_at, updated_at + FROM discount_coupons + WHERE provider_name = $1 AND claimed_by_user_id = $2` + + var coupon DiscountCoupon + row := r.DB.QueryRowContext(ctx, query, providerName, userID) + err := row.Scan(&coupon.ProviderName, &coupon.Code, &coupon.ClaimedByUserID, &coupon.ClaimedAt, &coupon.SentCount, &coupon.CreatedAt, &coupon.UpdatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, stacktrace.Propagate(err, "failed to get claimed coupon") + } + + return &coupon, nil +} + +func (r *Repository) IncrementSentCount(ctx context.Context, providerName, code string) error { + query := `UPDATE discount_coupons + SET sent_count = sent_count + 1 + WHERE provider_name = $1 AND code = $2` + + _, err := r.DB.ExecContext(ctx, query, providerName, code) + if err != nil { + return stacktrace.Propagate(err, "failed to increment sent count") + } + + return nil +} + +func (r *Repository) HasUnclaimedCoupons(ctx context.Context, providerName string) (bool, error) { + query := `SELECT COUNT(*) FROM discount_coupons WHERE provider_name = $1 AND claimed_by_user_id IS NULL` + + var count int + row := r.DB.QueryRowContext(ctx, query, providerName) + err := row.Scan(&count) + if err != nil { + return false, stacktrace.Propagate(err, "failed to count unclaimed coupons") + } + + return count > 0, nil +}