diff --git a/data/attendance.go b/data/attendance.go new file mode 100644 index 0000000..db92551 --- /dev/null +++ b/data/attendance.go @@ -0,0 +1,263 @@ +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 Attendance struct { + Id uint `json:"id" gorm:"primarykey;autoIncrement"` + UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"` + AttendanceId uuid.UUID `json:"attendance_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"` + Role string `json"role" gorm:"not null"` + CheckinAt time.Time `json:"checkin_at"` +} + +type AttendanceSearchDoc struct { + AttendanceId string `json:"attendance_id"` + EventId string `json:"event_id"` + UserId string `json:"user_id"` + Role string `json:"role"` + CheckinAt time.Time `json:"checkin_at"` +} + +func (self *Attendance) GetAttendance(userId, eventId uuid.UUID) (*Attendance, error) { + var checkin Attendance + + 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 AttendanceUsers struct { + UserId uuid.UUID `json:"user_id"` + Role string `json:"role"` + CheckinAt time.Time `json:"checkin_at"` +} + +func (self *Attendance) GetUsersByEventID(eventID uuid.UUID) (*[]AttendanceUsers, error) { + var result []AttendanceUsers + + err := Database. + Model(&Attendance{}). + Select("user_id, checkin_at"). + Where("event_id = ?", eventID). + Order("checkin_at ASC"). + Scan(&result).Error + + return &result, err +} + +type AttendanceEvent struct { + EventId uuid.UUID `json:"event_id"` + CheckinAt time.Time `json:"checkin_at"` +} + +func (self *Attendance) GetEventsByUserID(userID uuid.UUID) (*[]AttendanceEvent, error) { + var result []AttendanceEvent + + err := Database. + Model(&Attendance{}). + Select("event_id, checkin_at"). + Where("user_id = ?", userID). + Order("checkin_at ASC"). + Scan(&result).Error + + return &result, err +} + +func (self *Attendance) Create() error { + self.UUID = uuid.New() + self.AttendanceId = 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 +} + +func (self *Attendance) Update(attendanceId uuid.UUID, checkinTime *time.Time) (*Attendance, error) { + var attendance Attendance + + err := Database.Transaction(func(tx *gorm.DB) error { + // Lock the row for update + if err := tx. + Where("attendance_id = ?", attendanceId). + First(&attendance).Error; err != nil { + return err + } + + updates := map[string]any{} + + if checkinTime != nil { + updates["checkin_at"] = *checkinTime + } + + if len(updates) == 0 { + return nil + } + + if err := tx. + Model(&attendance). + Updates(updates).Error; err != nil { + return err + } + + // Reload to ensure struct is up to date + return tx. + Where("attendance_id = ?", attendanceId). + First(&attendance).Error + }) + + if err != nil { + return nil, err + } + + // Sync to MeiliSearch (eventual consistency) + if err := attendance.UpdateSearchIndex(); err != nil { + return nil, err + } + + return &attendance, nil +} + +func (self *Attendance) SearchUsersByEvent(eventID string) (*meilisearch.SearchResponse, error) { + index := MeiliSearch.Index("attendance") + + return index.Search("", &meilisearch.SearchRequest{ + Filter: "event_id = \"" + eventID + "\"", + Sort: []string{"checkin_at:asc"}, + }) +} + +func (self *Attendance) SearchEventsByUser(userID string) (*meilisearch.SearchResponse, error) { + index := MeiliSearch.Index("attendance") + + return index.Search("", &meilisearch.SearchRequest{ + Filter: "user_id = \"" + userID + "\"", + Sort: []string{"checkin_at:asc"}, + }) +} + +func (self *Attendance) UpdateSearchIndex() error { + doc := AttendanceSearchDoc{ + AttendanceId: self.AttendanceId.String(), + EventId: self.EventId.String(), + UserId: self.UserId.String(), + CheckinAt: self.CheckinAt, + } + + index := MeiliSearch.Index("attendance") + + primaryKey := "attendance_id" + opts := &meilisearch.DocumentOptions{ + PrimaryKey: &primaryKey, + } + + if _, err := index.UpdateDocuments([]AttendanceSearchDoc{doc}, opts); err != nil { + return err + } + + return nil +} + +func (self *Attendance) DeleteSearchIndex() error { + index := MeiliSearch.Index("attendance") + _, err := index.DeleteDocument(self.AttendanceId.String(), nil) + return err +} + +func (self *Attendance) 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 *Attendance) VerifyCheckinCode(checkinCode string) error { + ctx := context.Background() + + val, err := Redis.Get(ctx, "checkin_code:"+checkinCode).Result() + if err != nil { + return errors.New("invalid or expired checkin code") + } + + // Expected format: user_id::event_id: + parts := strings.Split(val, ":") + if len(parts) != 4 { + return errors.New("invalid checkin code format") + } + + userIdStr := parts[1] + eventIdStr := parts[3] + + userId, err := uuid.Parse(userIdStr) + if err != nil { + return err + } + + eventId, err := uuid.Parse(eventIdStr) + if err != nil { + return err + } + + attendanceData, err := self.GetAttendance(userId, eventId) + if err != nil { + return err + } + + time := time.Now() + _, err = self.Update(attendanceData.AttendanceId, &time) + if err != nil { + return err + } + + return nil +} diff --git a/data/checkin.go b/data/checkin.go deleted file mode 100644 index 0f9e3a7..0000000 --- a/data/checkin.go +++ /dev/null @@ -1,281 +0,0 @@ -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::event_id: - 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 -} diff --git a/data/data.go b/data/data.go index 58037bf..d4a6ee9 100644 --- a/data/data.go +++ b/data/data.go @@ -35,7 +35,7 @@ func Init() { } // Auto migrate - err = db.AutoMigrate(&User{}, &Event{}) + err = db.AutoMigrate(&User{}, &Event{}, &Attendance{}) if err != nil { log.Error("[Database] Error migrating database: ", err) } diff --git a/data/event.go b/data/event.go index 74a0126..22ed025 100644 --- a/data/event.go +++ b/data/event.go @@ -69,7 +69,7 @@ func (self *Event) UpdateEventById(eventId uuid.UUID) error { return nil } -func (self *Event) CreateEvent() error { +func (self *Event) Create() error { self.UUID = uuid.New() self.EventId = uuid.New() diff --git a/data/user.go b/data/user.go index 02a0ee3..25dc819 100644 --- a/data/user.go +++ b/data/user.go @@ -18,7 +18,6 @@ type User struct { UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"` UserId uuid.UUID `json:"user_id" gorm:"type:uuid;uniqueindex;not null"` Email string `json:"email" gorm:"type:varchar(255);uniqueindex;not null"` - Type string `json:"type" gorm:"type:varchar(32);index;not null"` Nickname string `json:"nickname"` Subtitle string `json:"subtitle"` Avatar string `json:"avatar"` @@ -134,7 +133,6 @@ func (self *User) UpdateSearchIndex() error { doc := UserSearchDoc{ UserId: self.UserId.String(), Email: self.Email, - Type: self.Type, Nickname: self.Nickname, Subtitle: self.Subtitle, Avatar: self.Avatar, diff --git a/service/event/info.go b/service/event/info.go index 723d14e..3ee8d23 100644 --- a/service/event/info.go +++ b/service/event/info.go @@ -8,7 +8,7 @@ import ( ) func Info(c *gin.Context) { - event := new(data.Event) + eventData := new(data.Event) eventIdOrig, ok := c.GetQuery("event_id") if !ok { c.JSON(400, gin.H{ @@ -26,7 +26,7 @@ func Info(c *gin.Context) { return } - err = event.GetEventById(eventId) + event, err := eventData.GetEventById(eventId) if err != nil { c.JSON(404, gin.H{ "status": "event id not found", @@ -35,9 +35,8 @@ func Info(c *gin.Context) { } c.JSON(200, gin.H{ - "name": event.Name, - "start_time": event.StartTime, - "end_time": event.EndTime, - "joined_users": event.JoinedUsers, + "name": event.Name, + "start_time": event.StartTime, + "end_time": event.EndTime, }) } diff --git a/service/user/checkin.go b/service/user/checkin.go index 23910e7..eff88b0 100644 --- a/service/user/checkin.go +++ b/service/user/checkin.go @@ -8,7 +8,7 @@ import ( ) func Checkin(c *gin.Context) { - data := new(data.Checkin) + data := new(data.Attendance) userId, ok := c.Get("user_id") if !ok { c.JSON(401, gin.H{ @@ -71,8 +71,8 @@ func CheckinSubmit(c *gin.Context) { } c.ShouldBindJSON(&req) - checkinData := new(data.Checkin) - userId, err := checkinData.VerifyCheckinCode(req.ChekinCode) + attendanceData := new(data.Attendance) + err := attendanceData.VerifyCheckinCode(req.ChekinCode) if err != nil { c.JSON(400, gin.H{ "status": "error verify checkin code", diff --git a/service/user/info.go b/service/user/info.go index c6fca78..0ba1ca5 100644 --- a/service/user/info.go +++ b/service/user/info.go @@ -29,7 +29,6 @@ func Info(c *gin.Context) { c.JSON(200, gin.H{ "user_id": user.UserId, "email": user.Email, - "type": user.Type, "nickname": user.Nickname, "subtitle": user.Subtitle, "avatar": user.Avatar, diff --git a/service/user/query.go b/service/user/query.go index ed2224f..71036d9 100644 --- a/service/user/query.go +++ b/service/user/query.go @@ -1,13 +1,10 @@ package user import ( - "errors" "nixcn-cms/data" - "time" "github.com/gin-gonic/gin" "github.com/google/uuid" - "gorm.io/gorm" ) func Query(c *gin.Context) { @@ -28,35 +25,20 @@ func Query(c *gin.Context) { return } - checkinData := new(data.Checkin) - checkin, err := checkinData.GetCheckin(userId.(uuid.UUID), eventId) + attendanceData := new(data.Attendance) + attendance, err := attendanceData.GetAttendance(userId.(uuid.UUID), eventId) if err != nil { c.JSON(500, gin.H{"status": "database error"}) return - } else if checkin == nil { + } else if attendance == nil { c.JSON(404, gin.H{"status": "event checkin record not found"}) return - } - - checkinTime := time.Now() - checkinData.EventId = eventId - checkinData.UserId = userId.(uuid.UUID) - checkinData.CheckinAt = checkinTime - err = checkinData.CreateCheckin() - if err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - c.JSON(409, gin.H{ - "status": "already checked in", - }) - return - } - c.JSON(500, gin.H{ - "status": "database error", - }) + } else if attendance.CheckinAt.IsZero() { + c.JSON(200, gin.H{"checkin_at": nil}) return } c.JSON(200, gin.H{ - "checkin_time": checkinTime, + "checkin_at": attendance.CheckinAt, }) } diff --git a/service/user/update.go b/service/user/update.go index b64c876..e7136b3 100644 --- a/service/user/update.go +++ b/service/user/update.go @@ -36,10 +36,6 @@ func Update(c *gin.Context) { user.Email = ReqInfo.Email user.Nickname = ReqInfo.Nickname user.Subtitle = ReqInfo.Subtitle - // Cant change user type under permission 2 - if user.PermissionLevel >= 2 { - user.Type = ReqInfo.Type - } // Update user info user.UpdateByUserID(userId.(uuid.UUID))