Files
67/services/internal/service/store_gorm.go
2026-06-22 22:39:08 +03:00

1239 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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{},
&notificationRecord{},
&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(&notificationRecord{}).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
}