[server] Support for coupons

This commit is contained in:
Neeraj Gupta
2025-09-05 12:10:19 +05:30
parent 49c90a802a
commit cf7a4d989d
9 changed files with 476 additions and 0 deletions

View File

@@ -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(

View File

@@ -0,0 +1,26 @@
{{template "base" .}}
{{define "content"}}
<h2>Your Discount Code is Here! 🎉</h2>
<p>Hi there,</p>
<p>Great news! We've got a special discount code just for you from <strong>{{.ProviderName}}</strong>.</p>
<div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; border-left: 4px solid #0055d4; margin: 20px 0;">
<h3 style="margin-top: 0; color: #0055d4;">Your Discount Code:</h3>
<div style="font-family: 'Courier New', monospace; font-size: 24px; font-weight: bold; color: #333; background-color: #fff; padding: 15px; border-radius: 4px; border: 2px dashed #0055d4; text-align: center; letter-spacing: 2px;">
{{.CouponCode}}
</div>
</div>
<p>This exclusive discount is available to you as a valued Ente customer. Simply use the code above when making your purchase with {{.ProviderName}}.</p>
<p><strong>Important:</strong> This discount code is unique to you and cannot be transferred to another account.</p>
<p>Thank you for being part of the Ente community!</p>
<p>Best regards,<br>
The Ente Team</p>
{{end}}

View File

@@ -0,0 +1,19 @@
{{template "base" .}}
{{define "content"}}
<h2>Ente Friends - Kagi trial code</h2>
<p>Hello,</p>
<p>Here is your coupon code for a free 3 month trial of Kagi Search - <strong>{{.CouponCode}}</strong></p>
<div style="margin: 20px 0;">
Or you can <a href="https://kagi.com/p/{{.CouponCode}}" > click here </a> to start your trial.
</div>
<p>Best,<br>
Team Ente</p>
{{end}}

View File

@@ -0,0 +1,19 @@
{{template "base" .}}
{{define "content"}}
<h2>Ente Friends - Test trial code</h2>
<p>Hello,</p>
<p>Here is your coupon code for a free 3 month trial of Test Search - <strong>{{.CouponCode}}</strong></p>
<div style="margin: 20px 0;">
Or you can <a href="https://Test.com/p/{{.CouponCode}}" > click here </a> to start your trial.
</div>
<p>Best,<br>
Team Ente</p>
{{end}}

View File

@@ -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;

View File

@@ -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();

View File

@@ -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"})
}

View File

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

View File

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