282 lines
6.1 KiB
Go
282 lines
6.1 KiB
Go
package data
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/meilisearch/meilisearch-go"
|
|
"github.com/spf13/viper"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type Checkin struct {
|
|
Id uint `json:"id" gorm:"primarykey;autoIncrement"`
|
|
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
|
|
CheckinId uuid.UUID `json:"checkin_id" gorm:"type:uuid;uniqueIndex;not null"`
|
|
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
|
|
UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueIndex:unique_event_user;not null"`
|
|
CheckinAt time.Time `json:"checkin_at"`
|
|
}
|
|
|
|
type CheckinSearchDoc struct {
|
|
CheckinId string `json:"checkin_id"`
|
|
EventId string `json:"event_id"`
|
|
UserId string `json:"user_id"`
|
|
CheckinAt time.Time `json:"checkin_at"`
|
|
}
|
|
|
|
func (self *Checkin) GetCheckin(userId, eventId uuid.UUID) (*Checkin, error) {
|
|
var checkin Checkin
|
|
|
|
err := Database.
|
|
Where("user_id = ? AND event_id = ?", userId, eventId).
|
|
First(&checkin).Error
|
|
|
|
if err != nil {
|
|
if err == gorm.ErrRecordNotFound {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
return &checkin, err
|
|
}
|
|
|
|
type CheckinUsers struct {
|
|
UserId uuid.UUID `json:"user_id"`
|
|
CheckinTime time.Time `json:"checkin_time"`
|
|
}
|
|
|
|
func (self *Checkin) GetUsersByEventID(eventID uuid.UUID) (*[]CheckinUsers, error) {
|
|
var result []CheckinUsers
|
|
|
|
err := Database.
|
|
Model(&Checkin{}).
|
|
Select("user_id, checkin_time").
|
|
Where("event_id = ?", eventID).
|
|
Order("checkin_time ASC").
|
|
Scan(&result).Error
|
|
|
|
return &result, err
|
|
}
|
|
|
|
type CheckinEvent struct {
|
|
EventId uuid.UUID `json:"event_id"`
|
|
CheckinTime time.Time `json:"checkin_time"`
|
|
}
|
|
|
|
func (self *Checkin) GetEventsByUserID(userID uuid.UUID) (*[]CheckinEvent, error) {
|
|
var result []CheckinEvent
|
|
|
|
err := Database.
|
|
Model(&Checkin{}).
|
|
Select("event_id, checkin_time").
|
|
Where("user_id = ?", userID).
|
|
Order("checkin_time ASC").
|
|
Scan(&result).Error
|
|
|
|
return &result, err
|
|
}
|
|
|
|
func (self *Checkin) CreateCheckin() error {
|
|
self.UUID = uuid.New()
|
|
self.CheckinId = uuid.New()
|
|
|
|
// DB transaction for strong consistency
|
|
err := Database.Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(&self).Error; err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.UpdateSearchIndex(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type CheckinUpdateInput struct {
|
|
CheckinTime *time.Time
|
|
}
|
|
|
|
func (self *Checkin) UpdateCheckin(checkinID uuid.UUID, input CheckinUpdateInput) (*Checkin, error) {
|
|
var checkin Checkin
|
|
|
|
err := Database.Transaction(func(tx *gorm.DB) error {
|
|
// Lock the row for update
|
|
if err := tx.
|
|
Where("checkin_id = ?", checkinID).
|
|
First(&checkin).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
updates := map[string]any{}
|
|
|
|
if input.CheckinTime != nil {
|
|
updates["checkin_time"] = *input.CheckinTime
|
|
}
|
|
|
|
if len(updates) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if err := tx.
|
|
Model(&checkin).
|
|
Updates(updates).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Reload to ensure struct is up to date
|
|
return tx.
|
|
Where("checkin_id = ?", checkinID).
|
|
First(&checkin).Error
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Sync to MeiliSearch (eventual consistency)
|
|
if err := checkin.UpdateSearchIndex(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &checkin, nil
|
|
}
|
|
|
|
func (self *Checkin) SearchUsersByEvent(eventID string) (*meilisearch.SearchResponse, error) {
|
|
index := MeiliSearch.Index("checkin")
|
|
|
|
return index.Search("", &meilisearch.SearchRequest{
|
|
Filter: "event_id = \"" + eventID + "\"",
|
|
Sort: []string{"checkin_time:asc"},
|
|
})
|
|
}
|
|
|
|
func (self *Checkin) SearchEventsByUser(userID string) (*meilisearch.SearchResponse, error) {
|
|
index := MeiliSearch.Index("checkin")
|
|
|
|
return index.Search("", &meilisearch.SearchRequest{
|
|
Filter: "user_id = \"" + userID + "\"",
|
|
Sort: []string{"checkin_time:asc"},
|
|
})
|
|
}
|
|
|
|
func (self *Checkin) UpdateSearchIndex() error {
|
|
doc := CheckinSearchDoc{
|
|
CheckinId: self.CheckinId.String(),
|
|
EventId: self.EventId.String(),
|
|
UserId: self.UserId.String(),
|
|
CheckinAt: self.CheckinAt,
|
|
}
|
|
|
|
index := MeiliSearch.Index("checkin")
|
|
|
|
primaryKey := "checkin_id"
|
|
opts := &meilisearch.DocumentOptions{
|
|
PrimaryKey: &primaryKey,
|
|
}
|
|
|
|
if _, err := index.UpdateDocuments([]CheckinSearchDoc{doc}, opts); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *Checkin) DeleteSearchIndex() error {
|
|
index := MeiliSearch.Index("checkin")
|
|
_, err := index.DeleteDocument(self.CheckinId.String(), nil)
|
|
return err
|
|
}
|
|
|
|
func (self *Checkin) GenCheckinCode(eventId uuid.UUID) (*string, error) {
|
|
ctx := context.Background()
|
|
ttl := viper.GetDuration("ttl.checkin_code_ttl")
|
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
|
|
for {
|
|
code := fmt.Sprintf("%06d", rng.Intn(900000)+100000)
|
|
ok, err := Redis.SetNX(
|
|
ctx,
|
|
"checkin_code:"+code,
|
|
"user_id:"+self.UserId.String()+":event_id:"+eventId.String(),
|
|
ttl,
|
|
).Result()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ok {
|
|
return &code, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (self *Checkin) VerifyCheckinCode(checkinCode string) (*uuid.UUID, error) {
|
|
ctx := context.Background()
|
|
|
|
val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result()
|
|
if err != nil {
|
|
return nil, errors.New("invalid or expired checkin code")
|
|
}
|
|
|
|
// Expected format: user_id:<uuid>:event_id:<uuid>
|
|
parts := strings.Split(val, ":")
|
|
if len(parts) != 4 {
|
|
return nil, errors.New("invalid checkin code format")
|
|
}
|
|
|
|
userIdStr := parts[1]
|
|
eventIdStr := parts[3]
|
|
|
|
userId, err := uuid.Parse(userIdStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
eventId, err := uuid.Parse(eventIdStr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// DB transaction: ensure checkin is created atomically
|
|
err = Database.Transaction(func(tx *gorm.DB) error {
|
|
checkin := &Checkin{
|
|
UUID: uuid.New(),
|
|
CheckinId: uuid.New(),
|
|
UserId: userId,
|
|
EventId: eventId,
|
|
CheckinAt: time.Now(),
|
|
}
|
|
|
|
if err := tx.Create(checkin).Error; err != nil {
|
|
return err
|
|
}
|
|
|
|
// Sync search index after commit
|
|
go func(c *Checkin) {
|
|
_ = c.UpdateSearchIndex()
|
|
}(checkin)
|
|
|
|
// Consume the code (one-time use)
|
|
Redis.Del(ctx, "checkin_code:"+checkinCode)
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &userId, nil
|
|
}
|