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
+}