1239 lines
44 KiB
Go
1239 lines
44 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
)
|
||
|
||
type roleRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
Code string `gorm:"column:code;uniqueIndex"`
|
||
Name string `gorm:"column:name"`
|
||
Description string `gorm:"column:description"`
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
func (roleRecord) TableName() string { return "roles" }
|
||
|
||
type userRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
Login string `gorm:"column:login"`
|
||
PasswordHash string `gorm:"column:password_hash"`
|
||
DisplayName string `gorm:"column:display_name"`
|
||
Email *string `gorm:"column:email"`
|
||
IsActive bool `gorm:"column:is_active"`
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
Roles []roleRecord `gorm:"many2many:user_roles;joinForeignKey:UserID;joinReferences:RoleID"`
|
||
Subscriptions []subscriptionRecord `gorm:"foreignKey:UserID"`
|
||
}
|
||
|
||
func (userRecord) TableName() string { return "users" }
|
||
|
||
type categoryRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
Title string `gorm:"column:title"`
|
||
Slug string `gorm:"column:slug"`
|
||
Description string `gorm:"column:description"`
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
func (categoryRecord) TableName() string { return "categories" }
|
||
|
||
type tagRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
Title string `gorm:"column:title"`
|
||
Slug string `gorm:"column:slug"`
|
||
CreatedAt time.Time
|
||
}
|
||
|
||
func (tagRecord) TableName() string { return "tags" }
|
||
|
||
type speakerRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
DisplayName string `gorm:"column:display_name"`
|
||
RoleDescription string `gorm:"column:role_description"`
|
||
Biography string `gorm:"column:biography"`
|
||
TopicsCSV string `gorm:"column:topics_csv"`
|
||
MaterialsCount int `gorm:"column:materials_count"`
|
||
Subscribers int `gorm:"column:subscribers_count"`
|
||
CreatedAt time.Time
|
||
UpdatedAt time.Time
|
||
}
|
||
|
||
func (speakerRecord) TableName() string { return "speakers" }
|
||
|
||
type contentRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
Title string `gorm:"column:title"`
|
||
Lead string `gorm:"column:lead"`
|
||
Body string `gorm:"column:body"`
|
||
ContentType string `gorm:"column:content_type"`
|
||
Status string `gorm:"column:status"`
|
||
Visibility string `gorm:"column:visibility"`
|
||
CategoryID *string `gorm:"column:category_id"`
|
||
Category categoryRecord `gorm:"foreignKey:CategoryID"`
|
||
AuthorUserID *string `gorm:"column:author_user_id"`
|
||
AuthorUser userRecord `gorm:"foreignKey:AuthorUserID"`
|
||
AuthorLabel string `gorm:"column:author_label"`
|
||
SpeakerID *string `gorm:"column:speaker_id"`
|
||
PublishedAt *time.Time `gorm:"column:published_at"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||
ArchivedAt *time.Time `gorm:"column:archived_at"`
|
||
Duration string `gorm:"column:duration"`
|
||
ImageTone string `gorm:"column:image_tone"`
|
||
ModeratorComment string `gorm:"column:moderator_comment"`
|
||
ReviewComment string `gorm:"column:review_comment"`
|
||
RatingAverage float64 `gorm:"column:rating_average"`
|
||
RatingCount int `gorm:"column:rating_count"`
|
||
Tags []tagRecord `gorm:"many2many:content_tags;joinForeignKey:ContentID;joinReferences:TagID"`
|
||
MediaFile *mediaFileRecord `gorm:"foreignKey:ContentID"`
|
||
Views int64 `gorm:"column:views_count;->"`
|
||
}
|
||
|
||
func (contentRecord) TableName() string { return "content_items" }
|
||
|
||
type mediaFileRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
ContentID *string `gorm:"column:content_id"`
|
||
UploadedByUserID *string `gorm:"column:uploaded_by_user_id"`
|
||
OriginalName string `gorm:"column:original_name"`
|
||
MimeType string `gorm:"column:mime_type"`
|
||
SizeBytes int64 `gorm:"column:size_bytes"`
|
||
StorageKey string `gorm:"column:storage_key"`
|
||
PublicURL string `gorm:"column:public_url"`
|
||
Checksum string `gorm:"column:checksum"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
}
|
||
|
||
func (mediaFileRecord) TableName() string { return "media_files" }
|
||
|
||
type fileBlobRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
Name string `gorm:"column:name"`
|
||
MimeType string `gorm:"column:mime_type"`
|
||
SizeBytes int64 `gorm:"column:size_bytes"`
|
||
Data []byte `gorm:"column:data"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
}
|
||
|
||
func (fileBlobRecord) TableName() string { return "stored_file_blobs" }
|
||
|
||
type subscriptionRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
UserID string `gorm:"column:user_id"`
|
||
SubscriptionType string `gorm:"column:subscription_type"`
|
||
CategoryID *string `gorm:"column:category_id"`
|
||
Category categoryRecord `gorm:"foreignKey:CategoryID"`
|
||
TagID *string `gorm:"column:tag_id"`
|
||
Tag tagRecord `gorm:"foreignKey:TagID"`
|
||
SpeakerID *string `gorm:"column:speaker_id"`
|
||
Speaker speakerRecord `gorm:"foreignKey:SpeakerID"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
}
|
||
|
||
func (subscriptionRecord) TableName() string { return "subscriptions" }
|
||
|
||
type commentRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
ContentID string `gorm:"column:content_id"`
|
||
UserID string `gorm:"column:user_id"`
|
||
User userRecord `gorm:"foreignKey:UserID"`
|
||
Body string `gorm:"column:body"`
|
||
Status string `gorm:"column:status"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||
}
|
||
|
||
func (commentRecord) TableName() string { return "comments" }
|
||
|
||
type notificationRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
UserID string `gorm:"column:user_id"`
|
||
Title string `gorm:"column:title"`
|
||
Body string `gorm:"column:body"`
|
||
IsRead bool `gorm:"column:is_read"`
|
||
ContentID *string `gorm:"column:content_id"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
ReadAt *time.Time `gorm:"column:read_at"`
|
||
}
|
||
|
||
func (notificationRecord) TableName() string { return "notifications" }
|
||
|
||
type actionLogRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
ActorUserID *string `gorm:"column:actor_user_id"`
|
||
ActorUser userRecord `gorm:"foreignKey:ActorUserID"`
|
||
Action string `gorm:"column:action"`
|
||
EntityType string `gorm:"column:entity_type"`
|
||
EntityID *string `gorm:"column:entity_id"`
|
||
RequestID string `gorm:"column:request_id"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
}
|
||
|
||
func (actionLogRecord) TableName() string { return "action_logs" }
|
||
|
||
type contentViewRecord struct {
|
||
ID string `gorm:"column:id;primaryKey"`
|
||
ContentID string `gorm:"column:content_id"`
|
||
UserID *string `gorm:"column:user_id"`
|
||
AnonymousKey *string `gorm:"column:anonymous_key"`
|
||
CreatedAt time.Time `gorm:"column:created_at"`
|
||
}
|
||
|
||
func (contentViewRecord) TableName() string { return "content_views" }
|
||
|
||
type gormStore struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
func newGORMStore(db *gorm.DB) (*gormStore, error) {
|
||
if db.Dialector.Name() == "sqlite" {
|
||
if err := db.AutoMigrate(
|
||
&roleRecord{},
|
||
&userRecord{},
|
||
&categoryRecord{},
|
||
&tagRecord{},
|
||
&speakerRecord{},
|
||
&contentRecord{},
|
||
&mediaFileRecord{},
|
||
&fileBlobRecord{},
|
||
&subscriptionRecord{},
|
||
&commentRecord{},
|
||
¬ificationRecord{},
|
||
&actionLogRecord{},
|
||
&contentViewRecord{},
|
||
); err != nil {
|
||
return nil, fmt.Errorf("auto-migrate service tables: %w", err)
|
||
}
|
||
} else if err := ensurePostgresSchema(db); err != nil {
|
||
return nil, err
|
||
}
|
||
store := &gormStore{db: db}
|
||
if err := store.seed(context.Background()); err != nil {
|
||
return nil, err
|
||
}
|
||
return store, nil
|
||
}
|
||
|
||
func ensurePostgresSchema(db *gorm.DB) error {
|
||
statements := []string{
|
||
`ALTER TABLE speakers ADD COLUMN IF NOT EXISTS topics_csv TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE speakers ADD COLUMN IF NOT EXISTS materials_count INTEGER NOT NULL DEFAULT 0`,
|
||
`ALTER TABLE speakers ADD COLUMN IF NOT EXISTS subscribers_count INTEGER NOT NULL DEFAULT 0`,
|
||
`ALTER TABLE content_items ADD COLUMN IF NOT EXISTS duration TEXT`,
|
||
`ALTER TABLE content_items ADD COLUMN IF NOT EXISTS image_tone TEXT NOT NULL DEFAULT ''`,
|
||
`ALTER TABLE content_items ADD COLUMN IF NOT EXISTS moderator_comment TEXT`,
|
||
`ALTER TABLE content_items ADD COLUMN IF NOT EXISTS review_comment TEXT`,
|
||
`ALTER TABLE content_items ADD COLUMN IF NOT EXISTS rating_average DOUBLE PRECISION NOT NULL DEFAULT 0`,
|
||
`ALTER TABLE content_items ADD COLUMN IF NOT EXISTS rating_count INTEGER NOT NULL DEFAULT 0`,
|
||
`CREATE TABLE IF NOT EXISTS stored_file_blobs (
|
||
id UUID PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
mime_type TEXT NOT NULL,
|
||
size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
|
||
data BYTEA NOT NULL,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
)`,
|
||
}
|
||
for _, statement := range statements {
|
||
if err := db.Exec(statement).Error; err != nil {
|
||
return fmt.Errorf("ensure postgres schema: %w", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *gormStore) Ping(ctx context.Context) error {
|
||
sqlDB, err := s.db.DB()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return sqlDB.PingContext(ctx)
|
||
}
|
||
|
||
func (s *gormStore) UserByLogin(ctx context.Context, login string) (UserProfile, bool, error) {
|
||
login = resolveLoginAlias(login)
|
||
var user userRecord
|
||
err := s.db.WithContext(ctx).
|
||
Preload("Roles").
|
||
Preload("Subscriptions.Category").
|
||
Preload("Subscriptions.Tag").
|
||
Preload("Subscriptions.Speaker").
|
||
Where("lower(login) = ?", strings.ToLower(strings.TrimSpace(login))).
|
||
First(&user).Error
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return UserProfile{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return UserProfile{}, false, err
|
||
}
|
||
return mapUserRecord(user), true, nil
|
||
}
|
||
|
||
func (s *gormStore) UserByID(ctx context.Context, id string) (UserProfile, bool, error) {
|
||
var user userRecord
|
||
err := s.db.WithContext(ctx).
|
||
Preload("Roles").
|
||
Preload("Subscriptions.Category").
|
||
Preload("Subscriptions.Tag").
|
||
Preload("Subscriptions.Speaker").
|
||
First(&user, "id = ?", id).Error
|
||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return UserProfile{}, false, nil
|
||
}
|
||
if err != nil {
|
||
return UserProfile{}, false, err
|
||
}
|
||
return mapUserRecord(user), true, nil
|
||
}
|
||
|
||
func (s *gormStore) AddUser(ctx context.Context, login, name, password string) (UserProfile, error) {
|
||
hash, err := hashPassword(password)
|
||
if err != nil {
|
||
return UserProfile{}, err
|
||
}
|
||
user := userRecord{
|
||
ID: uuid.NewString(),
|
||
Login: strings.ToLower(strings.TrimSpace(login)),
|
||
PasswordHash: hash,
|
||
DisplayName: strings.TrimSpace(name),
|
||
IsActive: true,
|
||
}
|
||
var role roleRecord
|
||
if err := s.db.WithContext(ctx).Where("code = ?", roleCodeToDB(RoleUser)).First(&role).Error; err != nil {
|
||
return UserProfile{}, err
|
||
}
|
||
if err := s.db.WithContext(ctx).Create(&user).Error; err != nil {
|
||
return UserProfile{}, err
|
||
}
|
||
if err := s.db.WithContext(ctx).Model(&user).Association("Roles").Append(&role); err != nil {
|
||
return UserProfile{}, err
|
||
}
|
||
profile, _, err := s.UserByID(ctx, user.ID)
|
||
if err != nil {
|
||
return UserProfile{}, err
|
||
}
|
||
return profile, nil
|
||
}
|
||
|
||
func (s *gormStore) UpdatePassword(ctx context.Context, userID, password string) error {
|
||
hash, err := hashPassword(password)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return s.db.WithContext(ctx).Model(&userRecord{}).Where("id = ?", userID).Updates(map[string]any{
|
||
"password_hash": hash,
|
||
"updated_at": time.Now().UTC(),
|
||
}).Error
|
||
}
|
||
|
||
func (s *gormStore) ListUsers(ctx context.Context) ([]UserProfile, error) {
|
||
var users []userRecord
|
||
if err := s.db.WithContext(ctx).
|
||
Preload("Roles").
|
||
Preload("Subscriptions.Category").
|
||
Preload("Subscriptions.Tag").
|
||
Preload("Subscriptions.Speaker").
|
||
Order("created_at asc").
|
||
Find(&users).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]UserProfile, 0, len(users))
|
||
for _, user := range users {
|
||
items = append(items, mapUserRecord(user))
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *gormStore) ListContent(ctx context.Context, filter ContentFilter) ([]ContentItem, error) {
|
||
query := s.db.WithContext(ctx).
|
||
Model(&contentRecord{}).
|
||
Select("content_items.*, COALESCE(view_counts.views_count, 0) AS views_count").
|
||
Joins("LEFT JOIN (SELECT content_id, COUNT(*) AS views_count FROM content_views GROUP BY content_id) AS view_counts ON view_counts.content_id = content_items.id").
|
||
Preload("Category").
|
||
Preload("AuthorUser").
|
||
Preload("Tags").
|
||
Preload("MediaFile")
|
||
|
||
if term := strings.TrimSpace(filter.Term); term != "" {
|
||
like := "%" + strings.ToLower(term) + "%"
|
||
query = query.Joins("LEFT JOIN categories search_categories ON search_categories.id = content_items.category_id").
|
||
Where("lower(content_items.title) LIKE ? OR lower(coalesce(content_items.lead, '')) LIKE ? OR lower(coalesce(content_items.body, '')) LIKE ? OR lower(coalesce(content_items.author_label, '')) LIKE ? OR lower(coalesce(search_categories.title, '')) LIKE ?", like, like, like, like, like)
|
||
}
|
||
if filter.Category != "" && filter.Category != "Все" {
|
||
query = query.Joins("LEFT JOIN categories filter_categories ON filter_categories.id = content_items.category_id").Where("filter_categories.title = ?", filter.Category)
|
||
}
|
||
if filter.ContentType != "" {
|
||
query = query.Where("content_items.content_type = ?", contentTypeToDB(ContentType(filter.ContentType)))
|
||
}
|
||
if filter.Status != "" {
|
||
query = query.Where("content_items.status = ?", contentStatusToDB(ContentStatus(filter.Status)))
|
||
}
|
||
if filter.AuthorID != "" {
|
||
query = query.Where("content_items.author_user_id = ?", filter.AuthorID)
|
||
}
|
||
if filter.Mine {
|
||
query = query.Where("content_items.author_user_id = ?", filter.User.ID)
|
||
}
|
||
if filter.Exclude != "" {
|
||
query = query.Where("content_items.id <> ?", filter.Exclude)
|
||
}
|
||
if filter.OnlyEvents {
|
||
query = query.Where("content_items.content_type = ?", contentTypeToDB(ContentTypeEvent))
|
||
}
|
||
if filter.OnlyMedia {
|
||
query = query.Where("content_items.content_type IN ?", []string{contentTypeToDB(ContentTypeVideo), contentTypeToDB(ContentTypeAudio), contentTypeToDB(ContentTypeGraphic)})
|
||
}
|
||
if filter.SortMode == "newest" {
|
||
query = query.Order("content_items.published_at desc NULLS LAST").Order("content_items.created_at desc")
|
||
} else {
|
||
query = query.Order("views_count desc").Order("content_items.published_at desc NULLS LAST")
|
||
}
|
||
if filter.Limit > 0 {
|
||
query = query.Limit(filter.Limit)
|
||
}
|
||
var records []contentRecord
|
||
if err := query.Find(&records).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]ContentItem, 0, len(records))
|
||
for _, record := range records {
|
||
items = append(items, mapContentRecord(record))
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *gormStore) ContentByID(ctx context.Context, id string) (ContentItem, bool, error) {
|
||
var exists int64
|
||
if err := s.db.WithContext(ctx).Model(&contentRecord{}).Where("id = ?", id).Count(&exists).Error; err != nil {
|
||
return ContentItem{}, false, err
|
||
}
|
||
if exists == 0 {
|
||
return ContentItem{}, false, nil
|
||
}
|
||
if err := s.db.WithContext(ctx).Create(&contentViewRecord{ID: uuid.NewString(), ContentID: id}).Error; err != nil {
|
||
return ContentItem{}, false, err
|
||
}
|
||
items, err := s.ListContent(ctx, ContentFilter{})
|
||
if err != nil {
|
||
return ContentItem{}, false, err
|
||
}
|
||
for _, item := range items {
|
||
if item.ID == id {
|
||
return item, true, nil
|
||
}
|
||
}
|
||
return ContentItem{}, false, nil
|
||
}
|
||
|
||
func (s *gormStore) AddContent(ctx context.Context, item ContentItem) (ContentItem, error) {
|
||
record, err := s.buildContentRecord(ctx, item, false)
|
||
if err != nil {
|
||
return ContentItem{}, err
|
||
}
|
||
if record.ID == "" {
|
||
record.ID = uuid.NewString()
|
||
}
|
||
if err := s.db.WithContext(ctx).Create(&record).Error; err != nil {
|
||
return ContentItem{}, err
|
||
}
|
||
if err := s.replaceContentTags(ctx, record.ID, item.Tags); err != nil {
|
||
return ContentItem{}, err
|
||
}
|
||
if item.MediaURL != "" || item.FileName != "" || item.MimeType != "" || item.FileSize > 0 {
|
||
if err := s.upsertMediaFile(ctx, record.ID, item); err != nil {
|
||
return ContentItem{}, err
|
||
}
|
||
}
|
||
_ = s.appendAuditLog(ctx, item.Author, "создал черновик", item.Title, record.ID)
|
||
reloaded, _, err := s.reloadContent(ctx, record.ID)
|
||
if err != nil {
|
||
return ContentItem{}, err
|
||
}
|
||
return reloaded, nil
|
||
}
|
||
|
||
func (s *gormStore) PatchContent(ctx context.Context, id string, patch ContentItem) (ContentItem, bool, error) {
|
||
var record contentRecord
|
||
if err := s.db.WithContext(ctx).First(&record, "id = ?", id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return ContentItem{}, false, nil
|
||
} else if err != nil {
|
||
return ContentItem{}, false, err
|
||
}
|
||
updates := map[string]any{}
|
||
if patch.Title != "" {
|
||
updates["title"] = patch.Title
|
||
}
|
||
if patch.Lead != "" {
|
||
updates["lead"] = patch.Lead
|
||
}
|
||
if patch.Body != "" {
|
||
updates["body"] = patch.Body
|
||
}
|
||
if patch.Type != "" {
|
||
updates["content_type"] = contentTypeToDB(patch.Type)
|
||
}
|
||
if patch.Category != "" {
|
||
categoryID, err := s.lookupCategoryID(ctx, patch.Category)
|
||
if err != nil {
|
||
return ContentItem{}, false, err
|
||
}
|
||
updates["category_id"] = categoryID
|
||
}
|
||
if patch.Visibility != "" {
|
||
updates["visibility"] = visibilityToDB(patch.Visibility)
|
||
}
|
||
if patch.Status != "" {
|
||
updates["status"] = contentStatusToDB(patch.Status)
|
||
}
|
||
if patch.ModeratorComment != "" {
|
||
updates["moderator_comment"] = patch.ModeratorComment
|
||
updates["review_comment"] = patch.ModeratorComment
|
||
}
|
||
if patch.ReviewComment != "" {
|
||
updates["review_comment"] = patch.ReviewComment
|
||
}
|
||
if patch.Rating > 0 {
|
||
updates["rating_average"] = float64(patch.Rating)
|
||
updates["rating_count"] = gorm.Expr("rating_count + 1")
|
||
}
|
||
if patch.MediaURL != "" || patch.MediaKind != "" || patch.MimeType != "" || patch.FileName != "" || patch.FileSize != 0 {
|
||
if err := s.upsertMediaFile(ctx, id, patch); err != nil {
|
||
return ContentItem{}, false, err
|
||
}
|
||
}
|
||
if len(updates) > 0 {
|
||
updates["updated_at"] = time.Now().UTC()
|
||
if err := s.db.WithContext(ctx).Model(&contentRecord{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||
return ContentItem{}, false, err
|
||
}
|
||
}
|
||
if patch.Tags != nil {
|
||
if err := s.replaceContentTags(ctx, id, patch.Tags); err != nil {
|
||
return ContentItem{}, false, err
|
||
}
|
||
}
|
||
item, ok, err := s.reloadContent(ctx, id)
|
||
if err != nil || !ok {
|
||
return item, ok, err
|
||
}
|
||
if patch.Status != "" || patch.ModeratorComment != "" || patch.ReviewComment != "" {
|
||
_ = s.appendAuditLog(ctx, item.Author, "изменил статус", item.Title, id)
|
||
}
|
||
if patch.Rating > 0 {
|
||
item.MyRating = patch.Rating
|
||
}
|
||
return item, true, nil
|
||
}
|
||
|
||
func (s *gormStore) DeleteContent(ctx context.Context, id string) (bool, error) {
|
||
result := s.db.WithContext(ctx).Delete(&contentRecord{}, "id = ?", id)
|
||
if result.Error != nil {
|
||
return false, result.Error
|
||
}
|
||
return result.RowsAffected > 0, nil
|
||
}
|
||
|
||
func (s *gormStore) ListCategories(ctx context.Context) ([]string, error) {
|
||
var categories []categoryRecord
|
||
if err := s.db.WithContext(ctx).Order("title asc").Find(&categories).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]string, 0, len(categories))
|
||
for _, category := range categories {
|
||
items = append(items, category.Title)
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *gormStore) ListTags(ctx context.Context) ([]string, error) {
|
||
var tags []tagRecord
|
||
if err := s.db.WithContext(ctx).Order("title asc").Find(&tags).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]string, 0, len(tags))
|
||
for _, tag := range tags {
|
||
items = append(items, tag.Title)
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *gormStore) ListSpeakers(ctx context.Context) ([]Speaker, error) {
|
||
var speakers []speakerRecord
|
||
if err := s.db.WithContext(ctx).Order("display_name asc").Find(&speakers).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]Speaker, 0, len(speakers))
|
||
for _, speaker := range speakers {
|
||
items = append(items, Speaker{ID: speaker.ID, Name: speaker.DisplayName, Role: speaker.RoleDescription, Topics: splitCSV(speaker.TopicsCSV), Materials: speaker.MaterialsCount, Subscribers: speaker.Subscribers})
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *gormStore) AddFile(ctx context.Context, file StoredFile) (StoredFile, error) {
|
||
if file.ID == "" {
|
||
file.ID = uuid.NewString()
|
||
}
|
||
record := fileBlobRecord{ID: file.ID, Name: file.Name, MimeType: file.MimeType, SizeBytes: file.Size, Data: file.Data}
|
||
if err := s.db.WithContext(ctx).Create(&record).Error; err != nil {
|
||
return StoredFile{}, err
|
||
}
|
||
return file, nil
|
||
}
|
||
|
||
func (s *gormStore) FileByID(ctx context.Context, id string) (StoredFile, bool, error) {
|
||
var record fileBlobRecord
|
||
if err := s.db.WithContext(ctx).First(&record, "id = ?", id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return StoredFile{}, false, nil
|
||
} else if err != nil {
|
||
return StoredFile{}, false, err
|
||
}
|
||
return StoredFile{ID: record.ID, Name: record.Name, MimeType: record.MimeType, Size: record.SizeBytes, Data: record.Data}, true, nil
|
||
}
|
||
|
||
func (s *gormStore) UpsertSubscription(ctx context.Context, userID, target string) ([]string, error) {
|
||
target = strings.TrimSpace(target)
|
||
if target == "" {
|
||
user, ok, err := s.UserByID(ctx, userID)
|
||
return user.Subscriptions, okToErr(ok, err)
|
||
}
|
||
var record subscriptionRecord
|
||
var existing subscriptionRecord
|
||
lookup := strings.ToLower(target)
|
||
if categoryID, err := s.lookupCategoryIDInsensitive(ctx, lookup); err != nil {
|
||
return nil, err
|
||
} else if categoryID != nil {
|
||
record = subscriptionRecord{ID: uuid.NewString(), UserID: userID, SubscriptionType: "category", CategoryID: categoryID}
|
||
existing = subscriptionRecord{UserID: userID, SubscriptionType: "category", CategoryID: categoryID}
|
||
} else if tagID, err := s.lookupTagIDInsensitive(ctx, lookup); err != nil {
|
||
return nil, err
|
||
} else if tagID != nil {
|
||
record = subscriptionRecord{ID: uuid.NewString(), UserID: userID, SubscriptionType: "tag", TagID: tagID}
|
||
existing = subscriptionRecord{UserID: userID, SubscriptionType: "tag", TagID: tagID}
|
||
} else if speakerID, err := s.lookupSpeakerIDInsensitive(ctx, lookup); err != nil {
|
||
return nil, err
|
||
} else if speakerID != nil {
|
||
record = subscriptionRecord{ID: uuid.NewString(), UserID: userID, SubscriptionType: "speaker", SpeakerID: speakerID}
|
||
existing = subscriptionRecord{UserID: userID, SubscriptionType: "speaker", SpeakerID: speakerID}
|
||
}
|
||
if record.ID != "" {
|
||
var count int64
|
||
if err := s.db.WithContext(ctx).Model(&subscriptionRecord{}).Where(&existing).Count(&count).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
if count == 0 {
|
||
if err := s.db.WithContext(ctx).Create(&record).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
}
|
||
user, ok, err := s.UserByID(ctx, userID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !ok {
|
||
return nil, nil
|
||
}
|
||
return user.Subscriptions, nil
|
||
}
|
||
|
||
func (s *gormStore) ListNotifications(ctx context.Context) ([]NotificationItem, error) {
|
||
var records []notificationRecord
|
||
if err := s.db.WithContext(ctx).Order("created_at desc").Find(&records).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]NotificationItem, 0, len(records))
|
||
for _, record := range records {
|
||
items = append(items, NotificationItem{ID: record.ID, Title: record.Title, Description: record.Body, Read: record.IsRead, CreatedAt: record.CreatedAt.UTC().Format("2006-01-02 15:04")})
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *gormStore) MarkNotificationRead(ctx context.Context, id string) (*NotificationItem, error) {
|
||
now := time.Now().UTC()
|
||
if err := s.db.WithContext(ctx).Model(¬ificationRecord{}).Where("id = ?", id).Updates(map[string]any{"is_read": true, "read_at": &now}).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
var record notificationRecord
|
||
if err := s.db.WithContext(ctx).First(&record, "id = ?", id).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, nil
|
||
} else if err != nil {
|
||
return nil, err
|
||
}
|
||
item := NotificationItem{ID: record.ID, Title: record.Title, Description: record.Body, Read: record.IsRead, CreatedAt: record.CreatedAt.UTC().Format("2006-01-02 15:04")}
|
||
return &item, nil
|
||
}
|
||
|
||
func (s *gormStore) ListComments(ctx context.Context, contentID string) ([]CommentItem, error) {
|
||
var records []commentRecord
|
||
if err := s.db.WithContext(ctx).Preload("User").Where("content_id = ?", contentID).Order("created_at desc").Find(&records).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]CommentItem, 0, len(records))
|
||
for _, record := range records {
|
||
items = append(items, CommentItem{ID: record.ID, ContentID: record.ContentID, Author: record.User.DisplayName, Text: record.Body, CreatedAt: record.CreatedAt.UTC().Format("2006-01-02 15:04")})
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *gormStore) AddComment(ctx context.Context, contentID string, user UserProfile, text string) (CommentItem, error) {
|
||
record := commentRecord{ID: uuid.NewString(), ContentID: contentID, UserID: user.ID, Body: text, Status: "visible"}
|
||
if err := s.db.WithContext(ctx).Create(&record).Error; err != nil {
|
||
return CommentItem{}, err
|
||
}
|
||
return CommentItem{ID: record.ID, ContentID: contentID, Author: user.Name, Text: text, CreatedAt: time.Now().UTC().Format("2006-01-02 15:04")}, nil
|
||
}
|
||
|
||
func (s *gormStore) CountComments(ctx context.Context) (int, error) {
|
||
var count int64
|
||
if err := s.db.WithContext(ctx).Model(&commentRecord{}).Count(&count).Error; err != nil {
|
||
return 0, err
|
||
}
|
||
return int(count), nil
|
||
}
|
||
|
||
func (s *gormStore) ListAudit(ctx context.Context) ([]AuditItem, error) {
|
||
var records []actionLogRecord
|
||
if err := s.db.WithContext(ctx).Preload("ActorUser").Order("created_at desc").Find(&records).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
items := make([]AuditItem, 0, len(records))
|
||
for _, record := range records {
|
||
target := ""
|
||
if record.EntityID != nil {
|
||
if item, ok, err := s.reloadContent(ctx, *record.EntityID); err == nil && ok {
|
||
target = item.Title
|
||
}
|
||
}
|
||
if target == "" {
|
||
target = record.EntityType
|
||
}
|
||
actor := record.ActorUser.DisplayName
|
||
if actor == "" {
|
||
actor = "Система"
|
||
}
|
||
items = append(items, AuditItem{ID: record.ID, Actor: actor, Action: record.Action, Target: target, CreatedAt: record.CreatedAt.UTC().Format("2006-01-02 15:04")})
|
||
}
|
||
return items, nil
|
||
}
|
||
|
||
func (s *gormStore) reloadContent(ctx context.Context, id string) (ContentItem, bool, error) {
|
||
items, err := s.ListContent(ctx, ContentFilter{})
|
||
if err != nil {
|
||
return ContentItem{}, false, err
|
||
}
|
||
for _, item := range items {
|
||
if item.ID == id {
|
||
return item, true, nil
|
||
}
|
||
}
|
||
return ContentItem{}, false, nil
|
||
}
|
||
|
||
func (s *gormStore) buildContentRecord(ctx context.Context, item ContentItem, preserveID bool) (contentRecord, error) {
|
||
categoryID, err := s.lookupCategoryID(ctx, item.Category)
|
||
if err != nil {
|
||
return contentRecord{}, err
|
||
}
|
||
authorID, err := s.lookupUserIDByName(ctx, item.Author)
|
||
if err != nil {
|
||
return contentRecord{}, err
|
||
}
|
||
record := contentRecord{
|
||
Title: item.Title,
|
||
Lead: item.Lead,
|
||
Body: item.Body,
|
||
ContentType: contentTypeToDB(item.Type),
|
||
Status: contentStatusToDB(item.Status),
|
||
Visibility: visibilityToDB(item.Visibility),
|
||
CategoryID: categoryID,
|
||
AuthorUserID: authorID,
|
||
AuthorLabel: item.Author,
|
||
Duration: item.Duration,
|
||
ImageTone: item.ImageTone,
|
||
ModeratorComment: item.ModeratorComment,
|
||
ReviewComment: item.ReviewComment,
|
||
}
|
||
if preserveID {
|
||
record.ID = item.ID
|
||
}
|
||
if item.PublishedAt != "" {
|
||
ts := mustParseDate(item.PublishedAt)
|
||
record.PublishedAt = &ts
|
||
}
|
||
if record.Status == "" {
|
||
record.Status = contentStatusToDB(ContentStatusDraft)
|
||
}
|
||
if record.Visibility == "" {
|
||
record.Visibility = visibilityToDB(VisibilityPublic)
|
||
}
|
||
return record, nil
|
||
}
|
||
|
||
func (s *gormStore) replaceContentTags(ctx context.Context, contentID string, tags []string) error {
|
||
if err := s.db.WithContext(ctx).Where("content_id = ?", contentID).Delete(&struct {
|
||
ContentID string `gorm:"column:content_id"`
|
||
TagID string `gorm:"column:tag_id"`
|
||
}{}).Error; err != nil {
|
||
return err
|
||
}
|
||
for _, title := range tags {
|
||
tagID, err := s.lookupTagID(ctx, title)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if tagID == nil {
|
||
continue
|
||
}
|
||
if err := s.db.WithContext(ctx).Table("content_tags").Create(map[string]any{"content_id": contentID, "tag_id": *tagID}).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *gormStore) upsertMediaFile(ctx context.Context, contentID string, item ContentItem) error {
|
||
if item.FileName == "" && item.MediaURL == "" && item.MimeType == "" && item.FileSize == 0 {
|
||
return nil
|
||
}
|
||
var existing mediaFileRecord
|
||
err := s.db.WithContext(ctx).Where("content_id = ?", contentID).First(&existing).Error
|
||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return err
|
||
}
|
||
record := mediaFileRecord{
|
||
ID: existing.ID,
|
||
ContentID: &contentID,
|
||
OriginalName: firstNonEmpty(item.FileName, existing.OriginalName),
|
||
MimeType: firstNonEmpty(item.MimeType, existing.MimeType),
|
||
SizeBytes: firstNonZeroInt64(item.FileSize, existing.SizeBytes),
|
||
StorageKey: firstNonEmpty(item.MediaURL, existing.StorageKey),
|
||
PublicURL: firstNonEmpty(item.MediaURL, existing.PublicURL),
|
||
}
|
||
if record.ID == "" {
|
||
record.ID = uuid.NewString()
|
||
return s.db.WithContext(ctx).Create(&record).Error
|
||
}
|
||
return s.db.WithContext(ctx).Model(&mediaFileRecord{}).Where("id = ?", record.ID).Updates(record).Error
|
||
}
|
||
|
||
func (s *gormStore) appendAuditLog(ctx context.Context, actorName, action, title, contentID string) error {
|
||
actorID, err := s.lookupUserIDByName(ctx, actorName)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
entityID := contentID
|
||
return s.db.WithContext(ctx).Create(&actionLogRecord{ID: uuid.NewString(), ActorUserID: actorID, Action: action, EntityType: title, EntityID: &entityID}).Error
|
||
}
|
||
|
||
func (s *gormStore) lookupCategoryID(ctx context.Context, title string) (*string, error) {
|
||
var record categoryRecord
|
||
if err := s.db.WithContext(ctx).Where("title = ?", title).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, nil
|
||
} else if err != nil {
|
||
return nil, err
|
||
}
|
||
return &record.ID, nil
|
||
}
|
||
|
||
func (s *gormStore) lookupCategoryIDInsensitive(ctx context.Context, title string) (*string, error) {
|
||
var record categoryRecord
|
||
if err := s.db.WithContext(ctx).Where("lower(title) = ?", title).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, nil
|
||
} else if err != nil {
|
||
return nil, err
|
||
}
|
||
return &record.ID, nil
|
||
}
|
||
|
||
func (s *gormStore) lookupTagID(ctx context.Context, title string) (*string, error) {
|
||
var record tagRecord
|
||
if err := s.db.WithContext(ctx).Where("title = ?", title).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, nil
|
||
} else if err != nil {
|
||
return nil, err
|
||
}
|
||
return &record.ID, nil
|
||
}
|
||
|
||
func (s *gormStore) lookupTagIDInsensitive(ctx context.Context, title string) (*string, error) {
|
||
var record tagRecord
|
||
if err := s.db.WithContext(ctx).Where("lower(title) = ?", title).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, nil
|
||
} else if err != nil {
|
||
return nil, err
|
||
}
|
||
return &record.ID, nil
|
||
}
|
||
|
||
func (s *gormStore) lookupSpeakerIDInsensitive(ctx context.Context, title string) (*string, error) {
|
||
var record speakerRecord
|
||
if err := s.db.WithContext(ctx).Where("lower(display_name) = ?", title).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, nil
|
||
} else if err != nil {
|
||
return nil, err
|
||
}
|
||
return &record.ID, nil
|
||
}
|
||
|
||
func (s *gormStore) lookupUserIDByName(ctx context.Context, name string) (*string, error) {
|
||
var record userRecord
|
||
if err := s.db.WithContext(ctx).Where("display_name = ?", name).First(&record).Error; errors.Is(err, gorm.ErrRecordNotFound) {
|
||
return nil, nil
|
||
} else if err != nil {
|
||
return nil, err
|
||
}
|
||
return &record.ID, nil
|
||
}
|
||
|
||
func (s *gormStore) seed(ctx context.Context) error {
|
||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
for _, role := range []roleRecord{
|
||
{ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1", Code: roleCodeToDB(RoleAdministrator), Name: "Администратор", Description: "Расширенное управление системой, пользователями, ролями и настройками"},
|
||
{ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2", Code: roleCodeToDB(RoleEditor), Name: "Редактор", Description: "Создание и редактирование материалов, участие в модерации"},
|
||
{ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3", Code: roleCodeToDB(RoleManager), Name: "Менеджер", Description: "Публикация, управление контентом и подписками"},
|
||
{ID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4", Code: roleCodeToDB(RoleUser), Name: "Пользователь", Description: "Просмотр материалов, комментарии и подписки"},
|
||
} {
|
||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "code"}}, DoUpdates: clause.AssignmentColumns([]string{"name", "description"})}).Create(&role).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, category := range seedCategories {
|
||
record := categoryRecord{ID: uuid.NewSHA1(uuid.Nil, []byte("category:"+category)).String(), Title: category, Slug: slugify(category)}
|
||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"title", "slug"})}).Create(&record).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, tag := range seedTags {
|
||
record := tagRecord{ID: uuid.NewSHA1(uuid.Nil, []byte("tag:"+tag)).String(), Title: tag, Slug: slugify(tag)}
|
||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"title", "slug"})}).Create(&record).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, speaker := range seedSpeakers {
|
||
record := speakerRecord{ID: speaker.ID, DisplayName: speaker.Name, RoleDescription: speaker.Role, TopicsCSV: strings.Join(speaker.Topics, ","), MaterialsCount: speaker.Materials, Subscribers: speaker.Subscribers}
|
||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"display_name", "role_description", "topics_csv", "materials_count", "subscribers_count"})}).Create(&record).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
var storedRoles []roleRecord
|
||
if err := tx.Where("code IN ?", []string{roleCodeToDB(RoleAdministrator), roleCodeToDB(RoleEditor), roleCodeToDB(RoleManager), roleCodeToDB(RoleUser)}).Find(&storedRoles).Error; err != nil {
|
||
return err
|
||
}
|
||
roleIDs := map[RoleCode]string{}
|
||
for _, role := range storedRoles {
|
||
roleIDs[roleCodeFromDB(role.Code)] = role.ID
|
||
}
|
||
for _, user := range seedUsers {
|
||
email := user.Email
|
||
hash, err := hashPassword(user.Password)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
record := userRecord{ID: user.ID, Login: user.Login, PasswordHash: hash, DisplayName: user.Name, Email: &email, IsActive: true}
|
||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"login", "password_hash", "display_name", "email", "is_active"})}).Create(&record).Error; err != nil {
|
||
return err
|
||
}
|
||
for _, role := range user.Roles {
|
||
join := map[string]any{"user_id": user.ID, "role_id": roleIDs[role]}
|
||
if err := tx.Table("user_roles").Clauses(clause.OnConflict{DoNothing: true}).Create(join).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
for _, item := range seedContentItems {
|
||
categoryID := uuid.NewSHA1(uuid.Nil, []byte("category:"+item.Category)).String()
|
||
authorID, _ := userIDByLogin(item.AuthorLogin)
|
||
publishedAt := mustParseDate(item.PublishedAt)
|
||
record := contentRecord{ID: item.ID, Title: item.Title, Lead: item.Lead, Body: item.Body, ContentType: contentTypeToDB(item.Type), Status: contentStatusToDB(item.Status), Visibility: visibilityToDB(item.Visibility), CategoryID: &categoryID, AuthorUserID: authorID, AuthorLabel: seedUserName(item.AuthorLogin), PublishedAt: &publishedAt, Duration: item.Duration, ImageTone: item.ImageTone, ModeratorComment: item.ModeratorComment, ReviewComment: item.ReviewComment}
|
||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"title", "lead", "body", "content_type", "status", "visibility", "category_id", "author_user_id", "author_label", "published_at", "duration", "image_tone", "moderator_comment", "review_comment"})}).Create(&record).Error; err != nil {
|
||
return err
|
||
}
|
||
for _, tag := range item.Tags {
|
||
join := map[string]any{"content_id": item.ID, "tag_id": uuid.NewSHA1(uuid.Nil, []byte("tag:"+tag)).String()}
|
||
if err := tx.Table("content_tags").Clauses(clause.OnConflict{DoNothing: true}).Create(join).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
var existingViews int64
|
||
if err := tx.Model(&contentViewRecord{}).Where("content_id = ?", item.ID).Count(&existingViews).Error; err != nil {
|
||
return err
|
||
}
|
||
for idx := existingViews; idx < int64(item.Views); idx++ {
|
||
if err := tx.Create(&contentViewRecord{ID: uuid.NewString(), ContentID: item.ID, CreatedAt: publishedAt.Add(time.Duration(idx) * time.Second)}).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
for _, notification := range seedNotifications {
|
||
userID, _ := userIDByLogin(notification.UserLogin)
|
||
record := notificationRecord{ID: notification.ID, UserID: derefString(userID), Title: notification.Title, Body: notification.Description, IsRead: notification.Read, CreatedAt: mustParseMinute(notification.CreatedAt)}
|
||
if notification.Read {
|
||
readAt := record.CreatedAt
|
||
record.ReadAt = &readAt
|
||
}
|
||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"user_id", "title", "body", "is_read", "created_at", "read_at"})}).Create(&record).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, comment := range seedComments {
|
||
userID, _ := userIDByLogin(comment.AuthorLogin)
|
||
record := commentRecord{ID: comment.ID, ContentID: comment.ContentID, UserID: derefString(userID), Body: comment.Text, Status: "visible", CreatedAt: mustParseMinute(comment.CreatedAt), UpdatedAt: mustParseMinute(comment.CreatedAt)}
|
||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"content_id", "user_id", "body", "status", "created_at", "updated_at"})}).Create(&record).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
for _, audit := range seedAuditTrail {
|
||
userID, _ := userIDByLogin(audit.ActorLogin)
|
||
entityID := lookupSeedContentIDByTitle(audit.Target)
|
||
record := actionLogRecord{ID: audit.ID, ActorUserID: userID, Action: audit.Action, EntityType: audit.Target, EntityID: entityID, CreatedAt: mustParseMinute(audit.CreatedAt)}
|
||
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoUpdates: clause.AssignmentColumns([]string{"actor_user_id", "action", "entity_type", "entity_id", "created_at"})}).Create(&record).Error; err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
for _, user := range seedUsers {
|
||
for _, target := range user.Subscriptions {
|
||
if _, err := s.UpsertSubscription(ctx, user.ID, target); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func mapUserRecord(user userRecord) UserProfile {
|
||
roles := make([]RoleCode, 0, len(user.Roles))
|
||
for _, role := range user.Roles {
|
||
roles = append(roles, roleCodeFromDB(role.Code))
|
||
}
|
||
subscriptions := make([]string, 0, len(user.Subscriptions))
|
||
for _, subscription := range user.Subscriptions {
|
||
switch subscription.SubscriptionType {
|
||
case "category":
|
||
subscriptions = append(subscriptions, subscription.Category.Title)
|
||
case "tag":
|
||
subscriptions = append(subscriptions, subscription.Tag.Title)
|
||
case "speaker":
|
||
subscriptions = append(subscriptions, subscription.Speaker.DisplayName)
|
||
}
|
||
}
|
||
sort.Strings(subscriptions)
|
||
return UserProfile{ID: user.ID, Name: user.DisplayName, Login: user.Login, PasswordHash: user.PasswordHash, Roles: roles, Subscriptions: subscriptions}
|
||
}
|
||
|
||
func mapContentRecord(record contentRecord) ContentItem {
|
||
tags := make([]string, 0, len(record.Tags))
|
||
for _, tag := range record.Tags {
|
||
tags = append(tags, tag.Title)
|
||
}
|
||
sort.Strings(tags)
|
||
item := ContentItem{
|
||
ID: record.ID,
|
||
Title: record.Title,
|
||
Lead: record.Lead,
|
||
Body: record.Body,
|
||
Type: contentTypeFromDB(record.ContentType),
|
||
Category: record.Category.Title,
|
||
Tags: tags,
|
||
Author: firstNonEmpty(record.AuthorUser.DisplayName, record.AuthorLabel),
|
||
Duration: record.Duration,
|
||
Visibility: visibilityFromDB(record.Visibility),
|
||
Status: contentStatusFromDB(record.Status),
|
||
Views: int(record.Views),
|
||
ImageTone: record.ImageTone,
|
||
ModeratorComment: record.ModeratorComment,
|
||
ReviewComment: record.ReviewComment,
|
||
RatingAverage: record.RatingAverage,
|
||
RatingCount: record.RatingCount,
|
||
}
|
||
if record.PublishedAt != nil {
|
||
item.PublishedAt = record.PublishedAt.UTC().Format("2006-01-02")
|
||
}
|
||
if record.MediaFile != nil {
|
||
item.MediaURL = firstNonEmpty(record.MediaFile.PublicURL, record.MediaFile.StorageKey)
|
||
item.MediaKind = inferMediaKind(record.MediaFile.MimeType, record.MediaFile.OriginalName)
|
||
item.MimeType = record.MediaFile.MimeType
|
||
item.FileName = record.MediaFile.OriginalName
|
||
item.FileSize = record.MediaFile.SizeBytes
|
||
}
|
||
return item
|
||
}
|
||
|
||
func resolveLoginAlias(login string) string {
|
||
login = strings.ToLower(strings.TrimSpace(login))
|
||
aliases := map[string]string{"admin@dstu.ru": "demo_admin", "editor@dstu.ru": "demo_editor", "moderator@dstu.ru": "demo_moderator", "manager@dstu.ru": "demo_moderator", "user@dstu.ru": "demo_user"}
|
||
if alias, ok := aliases[login]; ok {
|
||
return alias
|
||
}
|
||
return login
|
||
}
|
||
|
||
func roleCodeToDB(role RoleCode) string {
|
||
switch role {
|
||
case RoleAdministrator:
|
||
return "administrator"
|
||
case RoleEditor:
|
||
return "editor"
|
||
case RoleManager:
|
||
return "manager"
|
||
default:
|
||
return "user"
|
||
}
|
||
}
|
||
|
||
func roleCodeFromDB(role string) RoleCode {
|
||
switch role {
|
||
case "administrator":
|
||
return RoleAdministrator
|
||
case "editor":
|
||
return RoleEditor
|
||
case "manager":
|
||
return RoleManager
|
||
default:
|
||
return RoleUser
|
||
}
|
||
}
|
||
|
||
func contentTypeToDB(contentType ContentType) string {
|
||
if contentType == ContentTypeEvent {
|
||
return "event"
|
||
}
|
||
return string(contentType)
|
||
}
|
||
|
||
func contentTypeFromDB(contentType string) ContentType { return ContentType(contentType) }
|
||
|
||
func contentStatusToDB(status ContentStatus) string {
|
||
switch status {
|
||
case ContentStatusDraft:
|
||
return "draft"
|
||
case ContentStatusModeration:
|
||
return "moderation"
|
||
case ContentStatusReview:
|
||
return "review"
|
||
case ContentStatusPublished:
|
||
return "published"
|
||
case ContentStatusReturned:
|
||
return "returned"
|
||
case ContentStatusArchived:
|
||
return "archived"
|
||
default:
|
||
return strings.ToLower(strings.TrimSpace(string(status)))
|
||
}
|
||
}
|
||
|
||
func contentStatusFromDB(status string) ContentStatus {
|
||
return normalizeStatus(status)
|
||
}
|
||
|
||
func visibilityToDB(visibility Visibility) string {
|
||
switch visibility {
|
||
case VisibilityPublic:
|
||
return "public"
|
||
case VisibilityAuthenticated:
|
||
return "authenticated"
|
||
case VisibilityRole:
|
||
return "role_restricted"
|
||
default:
|
||
return "public"
|
||
}
|
||
}
|
||
|
||
func visibilityFromDB(value string) Visibility {
|
||
switch value {
|
||
case "public":
|
||
return VisibilityPublic
|
||
case "authenticated":
|
||
return VisibilityAuthenticated
|
||
case "role_restricted":
|
||
return VisibilityRole
|
||
default:
|
||
return VisibilityPublic
|
||
}
|
||
}
|
||
|
||
func splitCSV(value string) []string {
|
||
if strings.TrimSpace(value) == "" {
|
||
return nil
|
||
}
|
||
parts := strings.Split(value, ",")
|
||
items := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||
items = append(items, trimmed)
|
||
}
|
||
}
|
||
return items
|
||
}
|
||
|
||
func slugify(value string) string {
|
||
value = strings.ToLower(strings.TrimSpace(value))
|
||
replacer := strings.NewReplacer(" ", "-", ",", "", ".", "", "«", "", "»", "")
|
||
return replacer.Replace(value)
|
||
}
|
||
|
||
func firstNonZeroInt64(value, fallback int64) int64 {
|
||
if value == 0 {
|
||
return fallback
|
||
}
|
||
return value
|
||
}
|
||
|
||
func userIDByLogin(login string) (*string, bool) {
|
||
for _, user := range seedUsers {
|
||
if user.Login == login {
|
||
return &user.ID, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func seedUserName(login string) string {
|
||
for _, user := range seedUsers {
|
||
if user.Login == login {
|
||
return user.Name
|
||
}
|
||
}
|
||
return login
|
||
}
|
||
|
||
func lookupSeedContentIDByTitle(title string) *string {
|
||
for _, item := range seedContentItems {
|
||
if item.Title == title {
|
||
return &item.ID
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func derefString(value *string) string {
|
||
if value == nil {
|
||
return ""
|
||
}
|
||
return *value
|
||
}
|
||
|
||
func okToErr(ok bool, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !ok {
|
||
return nil
|
||
}
|
||
return nil
|
||
}
|