[server] Support for coupons
This commit is contained in:
@@ -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(
|
||||
|
||||
26
server/mail-templates/discount_coupon.html
Normal file
26
server/mail-templates/discount_coupon.html
Normal 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}}
|
||||
19
server/mail-templates/discount_coupon_kagi.html
Normal file
19
server/mail-templates/discount_coupon_kagi.html
Normal 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}}
|
||||
19
server/mail-templates/discount_coupon_test.html
Normal file
19
server/mail-templates/discount_coupon_test.html
Normal 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}}
|
||||
3
server/migrations/105_add_discount_coupons.down.sql
Normal file
3
server/migrations/105_add_discount_coupons.down.sql
Normal 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;
|
||||
21
server/migrations/105_add_discount_coupons.up.sql
Normal file
21
server/migrations/105_add_discount_coupons.up.sql
Normal 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();
|
||||
38
server/pkg/api/discountcoupon.go
Normal file
38
server/pkg/api/discountcoupon.go
Normal 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"})
|
||||
}
|
||||
203
server/pkg/controller/discountcoupon/controller.go
Normal file
203
server/pkg/controller/discountcoupon/controller.go
Normal 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
|
||||
}
|
||||
133
server/pkg/repo/discountcoupon/repository.go
Normal file
133
server/pkg/repo/discountcoupon/repository.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user