281
data/checkin.go
Normal file
281
data/checkin.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
1
data/client.go
Normal file
1
data/client.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package data
|
||||||
172
data/event.go
172
data/event.go
@@ -1,26 +1,21 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"slices"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/meilisearch/meilisearch-go"
|
"github.com/meilisearch/meilisearch-go"
|
||||||
"gorm.io/datatypes"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
||||||
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
|
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueIndex;not null"`
|
||||||
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"`
|
EventId uuid.UUID `json:"event_id" gorm:"type:uuid;uniqueIndex;not null"`
|
||||||
Name string `json:"name" gorm:"type:varchar(255);index;not null"`
|
Name string `json:"name" gorm:"type:varchar(255);index;not null"`
|
||||||
StartTime time.Time `json:"start_time" gorm:"index"`
|
StartTime time.Time `json:"start_time" gorm:"index"`
|
||||||
EndTime time.Time `json:"end_time" gorm:"index"`
|
EndTime time.Time `json:"end_time" gorm:"index"`
|
||||||
JoinedUsers datatypes.JSONSlice[uuid.UUID] `json:"joined_users"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type EventSearchDoc struct {
|
type EventSearchDoc struct {
|
||||||
@@ -30,93 +25,71 @@ type EventSearchDoc struct {
|
|||||||
EndTime time.Time `json:"end_time"`
|
EndTime time.Time `json:"end_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) GetEventById(eventId uuid.UUID) error {
|
func (self *Event) GetEventById(eventId uuid.UUID) (*Event, error) {
|
||||||
return Database.Transaction(func(tx *gorm.DB) error {
|
var event Event
|
||||||
if err := tx.Where("event_id = ?", eventId).First(&self).Error; err != nil {
|
|
||||||
return err
|
err := Database.
|
||||||
|
Where("event_id = ?", eventId).
|
||||||
|
First(&event).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, nil
|
||||||
}
|
}
|
||||||
return nil
|
return nil, err
|
||||||
})
|
}
|
||||||
|
|
||||||
|
return &event, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) UpdateEventById(eventId uuid.UUID) error {
|
func (self *Event) UpdateEventById(eventId uuid.UUID) error {
|
||||||
return Database.Transaction(func(tx *gorm.DB) error {
|
// DB transaction
|
||||||
if err := tx.Model(&Event{}).Where("event_id = ?", eventId).Updates(&self).Error; err != nil {
|
if err := Database.Transaction(func(tx *gorm.DB) error {
|
||||||
|
// Update by business key
|
||||||
|
if err := tx.
|
||||||
|
Model(&Event{}).
|
||||||
|
Where("event_id = ?", eventId).
|
||||||
|
Updates(self).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update event to document index
|
// Reload to ensure struct is fresh
|
||||||
doc := EventSearchDoc{
|
return tx.
|
||||||
EventId: self.EventId.String(),
|
Where("event_id = ?", eventId).
|
||||||
Name: self.Name,
|
First(self).Error
|
||||||
StartTime: self.StartTime,
|
}); err != nil {
|
||||||
EndTime: self.EndTime,
|
return err
|
||||||
}
|
}
|
||||||
index := MeiliSearch.Index("event")
|
|
||||||
docPrimaryKey := "event_id"
|
|
||||||
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
|
|
||||||
if _, err := index.UpdateDocuments([]EventSearchDoc{doc}, meiliOptions); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
// Sync search index
|
||||||
})
|
if err := self.UpdateSearchIndex(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) CreateEvent() error {
|
func (self *Event) CreateEvent() error {
|
||||||
if self.UUID == uuid.Nil {
|
self.UUID = uuid.New()
|
||||||
self.UUID = uuid.New()
|
self.EventId = uuid.New()
|
||||||
}
|
|
||||||
if self.EventId == uuid.Nil {
|
// DB transaction only
|
||||||
self.EventId = uuid.New()
|
if err := Database.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(self).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return Database.Transaction(func(tx *gorm.DB) error {
|
// Search index (eventual consistency)
|
||||||
if err := tx.Create(&self).Error; err != nil {
|
if err := self.UpdateSearchIndex(); err != nil {
|
||||||
return err
|
// TODO: async retry / log
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Add event to document index
|
return nil
|
||||||
doc := EventSearchDoc{
|
|
||||||
EventId: self.EventId.String(),
|
|
||||||
Name: self.Name,
|
|
||||||
StartTime: self.StartTime,
|
|
||||||
EndTime: self.EndTime,
|
|
||||||
}
|
|
||||||
index := MeiliSearch.Index("event")
|
|
||||||
docPrimaryKey := "event_id"
|
|
||||||
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
|
|
||||||
if _, err := index.AddDocuments([]EventSearchDoc{doc}, meiliOptions); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (self *Event) UserJoinEvent(userId, eventId uuid.UUID) error {
|
|
||||||
return Database.Transaction(func(tx *gorm.DB) error {
|
|
||||||
var event Event
|
|
||||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
||||||
Where("event_id = ?", eventId).
|
|
||||||
First(&event).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user already joined
|
|
||||||
if slices.Contains(event.JoinedUsers, userId) {
|
|
||||||
return errors.New("user already joined")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add user to list
|
|
||||||
event.JoinedUsers = append(event.JoinedUsers, userId)
|
|
||||||
if err := tx.Model(&Event{}).Where("event_id = ?", eventId).Update("joined_users", event.JoinedUsers).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
*self = event
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *Event) GetFullTable() (*[]Event, error) {
|
func (self *Event) GetFullTable() (*[]Event, error) {
|
||||||
@@ -130,6 +103,8 @@ func (self *Event) GetFullTable() (*[]Event, error) {
|
|||||||
|
|
||||||
func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error) {
|
func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error) {
|
||||||
index := MeiliSearch.Index("event")
|
index := MeiliSearch.Index("event")
|
||||||
|
|
||||||
|
// Fast read from MeiliSearch (no DB involved)
|
||||||
result, err := index.Search("", &meilisearch.SearchRequest{
|
result, err := index.Search("", &meilisearch.SearchRequest{
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
@@ -137,9 +112,38 @@ func (self *Event) FastListEvents(limit, offset int64) (*[]EventSearchDoc, error
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var list []EventSearchDoc
|
var list []EventSearchDoc
|
||||||
if err := mapstructure.Decode(result.Hits, &list); err != nil {
|
if err := mapstructure.Decode(result.Hits, &list); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &list, nil
|
return &list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (self *Event) UpdateSearchIndex() error {
|
||||||
|
doc := EventSearchDoc{
|
||||||
|
EventId: self.EventId.String(),
|
||||||
|
Name: self.Name,
|
||||||
|
StartTime: self.StartTime,
|
||||||
|
EndTime: self.EndTime,
|
||||||
|
}
|
||||||
|
index := MeiliSearch.Index("event")
|
||||||
|
|
||||||
|
primaryKey := "event_id"
|
||||||
|
opts := &meilisearch.DocumentOptions{
|
||||||
|
PrimaryKey: &primaryKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := index.UpdateDocuments([]EventSearchDoc{doc}, opts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Event) DeleteSearchIndex() error {
|
||||||
|
index := MeiliSearch.Index("event")
|
||||||
|
_, err := index.DeleteDocument(self.EventId.String(), nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
233
data/user.go
233
data/user.go
@@ -1,20 +1,10 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-viper/mapstructure/v2"
|
"github.com/go-viper/mapstructure/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/meilisearch/meilisearch-go"
|
"github.com/meilisearch/meilisearch-go"
|
||||||
"github.com/spf13/viper"
|
|
||||||
"gorm.io/datatypes"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Permission Level
|
// Permission Level
|
||||||
@@ -24,16 +14,15 @@ import (
|
|||||||
// Super User: 30
|
// Super User: 30
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
Id uint `json:"id" gorm:"primarykey;autoincrement"`
|
||||||
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"`
|
UUID uuid.UUID `json:"uuid" gorm:"type:uuid;uniqueindex;not null"`
|
||||||
UserId uuid.UUID `json:"user_id" 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"`
|
Email string `json:"email" gorm:"type:varchar(255);uniqueindex;not null"`
|
||||||
Type string `json:"type" gorm:"type:varchar(32);index;not null"`
|
Type string `json:"type" gorm:"type:varchar(32);index;not null"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Subtitle string `json:"subtitle"`
|
Subtitle string `json:"subtitle"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Checkin datatypes.JSONMap `json:"checkin"`
|
PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
|
||||||
PermissionLevel uint `json:"permission_level" gorm:"default:10;not null"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSearchDoc struct {
|
type UserSearchDoc struct {
|
||||||
@@ -46,88 +35,61 @@ type UserSearchDoc struct {
|
|||||||
PermissionLevel uint `json:"permission_level"`
|
PermissionLevel uint `json:"permission_level"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) GetByEmail(email string) error {
|
func (self *User) GetByEmail(email string) (*User, error) {
|
||||||
if err := Database.Where("email = ?", email).First(&self).Error; err != nil {
|
var user User
|
||||||
return err
|
|
||||||
|
err := Database.
|
||||||
|
Where("email = ?", email).
|
||||||
|
First(&user).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
return &user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) GetByUserId(userId uuid.UUID) error {
|
func (self *User) GetByUserId(userId uuid.UUID) (*User, error) {
|
||||||
if err := Database.Where("user_id = ?", userId).First(&self).Error; err != nil {
|
var user User
|
||||||
return err
|
|
||||||
|
err := Database.
|
||||||
|
Where("user_id = ?", userId).
|
||||||
|
First(&user).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (self *User) UpdateCheckin(userId, eventId uuid.UUID, time time.Time) error {
|
return &user, err
|
||||||
return Database.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.
|
|
||||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
|
||||||
Where("user_id = ?", userId).
|
|
||||||
First(self).Error; err != nil {
|
|
||||||
return err // if error then rollback
|
|
||||||
}
|
|
||||||
|
|
||||||
self.Checkin = datatypes.JSONMap{eventId.String(): time}
|
|
||||||
|
|
||||||
if err := tx.Save(self).Error; err != nil {
|
|
||||||
return err // rollback
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user to document index
|
|
||||||
doc := UserSearchDoc{
|
|
||||||
UserId: self.UserId.String(),
|
|
||||||
Email: self.Email,
|
|
||||||
Type: self.Type,
|
|
||||||
Nickname: self.Nickname,
|
|
||||||
Subtitle: self.Subtitle,
|
|
||||||
Avatar: self.Avatar,
|
|
||||||
PermissionLevel: self.PermissionLevel,
|
|
||||||
}
|
|
||||||
index := MeiliSearch.Index("user")
|
|
||||||
docPrimaryKey := "user_id"
|
|
||||||
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
|
|
||||||
if _, err := index.UpdateDocuments([]UserSearchDoc{doc}, meiliOptions); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil // commit
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) Create() error {
|
func (self *User) Create() error {
|
||||||
return Database.Transaction(func(tx *gorm.DB) error {
|
self.UUID = uuid.New()
|
||||||
if self.UUID == uuid.Nil {
|
self.UserId = uuid.New()
|
||||||
self.UUID = uuid.New()
|
|
||||||
}
|
|
||||||
if self.UserId == uuid.Nil {
|
|
||||||
self.UserId = uuid.New()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// DB transaction only
|
||||||
|
if err := Database.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Create(self).Error; err != nil {
|
if err := tx.Create(self).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user to document index
|
|
||||||
doc := UserSearchDoc{
|
|
||||||
UserId: self.UserId.String(),
|
|
||||||
Email: self.Email,
|
|
||||||
Type: self.Type,
|
|
||||||
Nickname: self.Nickname,
|
|
||||||
Subtitle: self.Subtitle,
|
|
||||||
Avatar: self.Avatar,
|
|
||||||
PermissionLevel: self.PermissionLevel,
|
|
||||||
}
|
|
||||||
index := MeiliSearch.Index("user")
|
|
||||||
docPrimaryKey := "user_id"
|
|
||||||
meiliOptions := &meilisearch.DocumentOptions{PrimaryKey: &docPrimaryKey}
|
|
||||||
if _, err := index.AddDocuments([]UserSearchDoc{doc}, meiliOptions); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search index (eventual consistency)
|
||||||
|
if err := self.UpdateSearchIndex(); err != nil {
|
||||||
|
// TODO: async retry / log
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) UpdateByUserID(userId uuid.UUID) error {
|
func (self *User) UpdateByUserID(userId uuid.UUID) error {
|
||||||
@@ -150,6 +112,8 @@ func (self *User) GetFullTable() (*[]User, error) {
|
|||||||
|
|
||||||
func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
|
func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
|
||||||
index := MeiliSearch.Index("user")
|
index := MeiliSearch.Index("user")
|
||||||
|
|
||||||
|
// Fast read from MeiliSearch, no DB involved
|
||||||
result, err := index.Search("", &meilisearch.SearchRequest{
|
result, err := index.Search("", &meilisearch.SearchRequest{
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
@@ -157,75 +121,44 @@ func (self *User) FastListUsers(limit, offset int64) (*[]UserSearchDoc, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var list []UserSearchDoc
|
var list []UserSearchDoc
|
||||||
if err := mapstructure.Decode(result.Hits, &list); err != nil {
|
if err := mapstructure.Decode(result.Hits, &list); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &list, nil
|
return &list, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) GenCheckinCode(eventId uuid.UUID) (*string, error) {
|
func (self *User) UpdateSearchIndex() error {
|
||||||
ctx := context.Background()
|
doc := UserSearchDoc{
|
||||||
ttl := viper.GetDuration("ttl.checkin_code_ttl")
|
UserId: self.UserId.String(),
|
||||||
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
Email: self.Email,
|
||||||
|
Type: self.Type,
|
||||||
for {
|
Nickname: self.Nickname,
|
||||||
code := fmt.Sprintf("%06d", rng.Intn(900000)+100000)
|
Subtitle: self.Subtitle,
|
||||||
ok, err := Redis.SetNX(
|
Avatar: self.Avatar,
|
||||||
ctx,
|
PermissionLevel: self.PermissionLevel,
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
index := MeiliSearch.Index("user")
|
||||||
|
|
||||||
|
primaryKey := "user_id"
|
||||||
|
opts := &meilisearch.DocumentOptions{
|
||||||
|
PrimaryKey: &primaryKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := index.UpdateDocuments(
|
||||||
|
[]UserSearchDoc{doc},
|
||||||
|
opts,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (self *User) VerifyCheckinCode(checkinCode string) (*uuid.UUID, error) {
|
func (self *User) DeleteSearchIndex() error {
|
||||||
ctx := context.Background()
|
index := MeiliSearch.Index("user")
|
||||||
|
_, err := index.DeleteDocument(self.UserId.String(), nil)
|
||||||
result := Redis.Get(ctx, "checkin_code:"+checkinCode).String()
|
return err
|
||||||
|
|
||||||
if result == "" {
|
|
||||||
return nil, errors.New("invalid or expired checkin code")
|
|
||||||
}
|
|
||||||
|
|
||||||
split := strings.Split(result, ":")
|
|
||||||
if len(split) < 2 {
|
|
||||||
return nil, errors.New("invalid checkin code format")
|
|
||||||
}
|
|
||||||
|
|
||||||
userId := split[0]
|
|
||||||
eventId := split[1]
|
|
||||||
|
|
||||||
var returnedUserId uuid.UUID
|
|
||||||
err := Database.Transaction(func(tx *gorm.DB) error {
|
|
||||||
checkinData := map[string]interface{}{
|
|
||||||
eventId: time.Now(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Model(&User{}).Where("user_id = ?", userId).Updates(map[string]interface{}{
|
|
||||||
"checkin": checkinData,
|
|
||||||
}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedUserId, err := uuid.Parse(userId)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
returnedUserId = parsedUserId
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &returnedUserId, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,5 @@ import "github.com/gin-gonic/gin"
|
|||||||
|
|
||||||
func Handler(r *gin.RouterGroup) {
|
func Handler(r *gin.RouterGroup) {
|
||||||
r.POST("/magic", RequestMagicLink)
|
r.POST("/magic", RequestMagicLink)
|
||||||
r.GET("/magic/verify", VerifyMagicLink)
|
|
||||||
r.POST("/refresh", Refresh)
|
r.POST("/refresh", Refresh)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ func VerifyMagicLink(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify if user exists
|
// Verify if user exists
|
||||||
user := new(data.User)
|
userData := new(data.User)
|
||||||
err := user.GetByEmail(email)
|
user, err := userData.GetByEmail(email)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == gorm.ErrRecordNotFound {
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
|||||||
1
service/auth/redirect.go
Normal file
1
service/auth/redirect.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package auth
|
||||||
1
service/auth/token.go
Normal file
1
service/auth/token.go
Normal file
@@ -0,0 +1 @@
|
|||||||
|
package auth
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func Checkin(c *gin.Context) {
|
func Checkin(c *gin.Context) {
|
||||||
data := new(data.User)
|
data := new(data.Checkin)
|
||||||
userId, ok := c.Get("user_id")
|
userId, ok := c.Get("user_id")
|
||||||
if !ok {
|
if !ok {
|
||||||
c.JSON(401, gin.H{
|
c.JSON(401, gin.H{
|
||||||
@@ -50,13 +50,29 @@ func Checkin(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func CheckinSubmit(c *gin.Context) {
|
func CheckinSubmit(c *gin.Context) {
|
||||||
|
userId, ok := c.Get("user_id")
|
||||||
|
if !ok {
|
||||||
|
c.JSON(403, gin.H{
|
||||||
|
"status": "unauthorized",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
userData := new(data.User)
|
||||||
|
userData.GetByUserId(userId.(uuid.UUID))
|
||||||
|
if userData.PermissionLevel <= 20 {
|
||||||
|
c.JSON(403, gin.H{
|
||||||
|
"status": "access denied",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
ChekinCode string `json:"checkin_code"`
|
ChekinCode string `json:"checkin_code"`
|
||||||
}
|
}
|
||||||
c.ShouldBindJSON(&req)
|
c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
data := new(data.User)
|
checkinData := new(data.Checkin)
|
||||||
userId, err := data.VerifyCheckinCode(req.ChekinCode)
|
userId, err := checkinData.VerifyCheckinCode(req.ChekinCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(400, gin.H{
|
c.JSON(400, gin.H{
|
||||||
"status": "error verify checkin code",
|
"status": "error verify checkin code",
|
||||||
@@ -64,13 +80,6 @@ func CheckinSubmit(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data.GetByUserId(*userId)
|
|
||||||
if data.PermissionLevel <= 20 {
|
|
||||||
c.JSON(403, gin.H{
|
|
||||||
"status": "access denied",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"status": "success",
|
"status": "success",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"nixcn-cms/data"
|
"nixcn-cms/data"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Info(c *gin.Context) {
|
func Info(c *gin.Context) {
|
||||||
data := new(data.User)
|
userData := new(data.User)
|
||||||
userId, ok := c.Get("user_id")
|
userId, ok := c.Get("user_id")
|
||||||
if !ok {
|
if !ok {
|
||||||
c.JSON(404, gin.H{
|
c.JSON(404, gin.H{
|
||||||
@@ -19,7 +18,7 @@ func Info(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user from database
|
// Get user from database
|
||||||
err := data.GetByUserId(userId.(uuid.UUID))
|
user, err := userData.GetByUserId(userId.(uuid.UUID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(404, gin.H{
|
c.JSON(404, gin.H{
|
||||||
"status": "user not found",
|
"status": "user not found",
|
||||||
@@ -27,21 +26,13 @@ func Info(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set time nil if time is zero
|
|
||||||
for k, v := range data.Checkin {
|
|
||||||
if t, ok := v.(time.Time); ok && t.IsZero() {
|
|
||||||
data.Checkin[k] = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"user_id": data.UserId,
|
"user_id": user.UserId,
|
||||||
"email": data.Email,
|
"email": user.Email,
|
||||||
"type": data.Type,
|
"type": user.Type,
|
||||||
"nickname": data.Nickname,
|
"nickname": user.Nickname,
|
||||||
"subtitle": data.Subtitle,
|
"subtitle": user.Subtitle,
|
||||||
"avatar": data.Avatar,
|
"avatar": user.Avatar,
|
||||||
"checkin": data.Checkin,
|
"permission_level": user.PermissionLevel,
|
||||||
"permission_level": data.PermissionLevel,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"nixcn-cms/data"
|
"nixcn-cms/data"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Query(c *gin.Context) {
|
func Query(c *gin.Context) {
|
||||||
@@ -14,29 +16,46 @@ func Query(c *gin.Context) {
|
|||||||
c.JSON(400, gin.H{"status": "could not found user_id"})
|
c.JSON(400, gin.H{"status": "could not found user_id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
eventId, ok := c.GetQuery("event_id")
|
|
||||||
|
eventIdOrig, ok := c.GetQuery("event_id")
|
||||||
if !ok {
|
if !ok {
|
||||||
c.JSON(400, gin.H{"status": "could not found event_id"})
|
c.JSON(400, gin.H{"status": "could not found event_id"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
eventId, err := uuid.Parse(eventIdOrig)
|
||||||
data := new(data.User)
|
|
||||||
err := data.GetByUserId(userId.(uuid.UUID))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(404, gin.H{"status": "cannot found user"})
|
c.JSON(400, gin.H{"status": "event_id is not valid"})
|
||||||
return
|
|
||||||
}
|
|
||||||
if data.Checkin[eventId] == nil {
|
|
||||||
c.JSON(404, gin.H{"status": "cannot found user checked in"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var checkinTime *time.Time
|
checkinData := new(data.Checkin)
|
||||||
if data.Checkin[eventId].(*time.Time).IsZero() {
|
checkin, err := checkinData.GetCheckin(userId.(uuid.UUID), eventId)
|
||||||
checkinTime = nil
|
if err != nil {
|
||||||
} else {
|
c.JSON(500, gin.H{"status": "database error"})
|
||||||
checkinTime = data.Checkin[eventId].(*time.Time)
|
return
|
||||||
|
} else if checkin == 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",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"checkin_time": checkinTime,
|
"checkin_time": checkinTime,
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user