feat: Implement video workflow repository and related services

- Added videoWorkflowRepository with methods to manage video and user interactions.
- Introduced catalog_mapper for converting database models to protobuf representations.
- Created domain_helpers for normalizing domain and ad format values.
- Defined service interfaces for payment, account, notification, domain, ad template, player config, video, and user management.
- Implemented OAuth helpers for generating state and caching keys.
- Developed payment_proto_helpers for mapping payment-related models to protobuf.
- Added service policy helpers to enforce plan requirements and user permissions.
- Created user_mapper for converting user payloads to protobuf format.
- Implemented value_helpers for handling various value conversions and nil checks.
- Developed video_helpers for normalizing video statuses and managing storage types.
- Created video_mapper for mapping video models to protobuf format.
- Implemented render workflow for managing video creation and job processing.
This commit is contained in:
2026-03-26 18:38:47 +07:00
parent fbbecd7674
commit a0ae2b681a
55 changed files with 3464 additions and 13091 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"gopls-lsp@claude-plugins-official": true
}
}

82
CLAUDE.md Normal file
View File

@@ -0,0 +1,82 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Quick Commands
```bash
# Run server
go run ./cmd/server
# Build server
go build ./cmd/server
# Run all tests
go test ./...
# Run service/middleware tests only
go test ./internal/service/... ./internal/middleware/...
# Regenerate GORM models and queries (after DB schema changes)
go run ./cmd/gendb
# Lint protobufs
buf lint
# Generate protobuf code (Go + TypeScript)
buf generate
# Run DB migrations
./run_migration.sh
```
## Architecture Overview
**Module**: `stream.api` (Go 1.25.6)
This is a **gRPC-first backend** for a video streaming platform with agent/render orchestration.
### Layer Structure
```
cmd/ → Entrypoints (server, gendb, worker stub)
internal/
transport/grpc → gRPC server, agent runtime, streaming, auth
service → Business logic, composed via appServices struct
repository → Database access layer (transactions, GORM abstraction)
database/
model/ → Generated GORM models (*.gen.go)
query/ → Generated GORM query builders (*.gen.go)
adapters/redis → Redis queue, pub/sub for jobs/agents
middleware → gRPC metadata-based internal auth
dto → Data transfer objects
api/proto → Generated protobuf Go code
proto/ → Source .proto files (app/v1, agent/v1)
pkg/ → Shared utilities (database, logger, storage, auth)
```
### Key Patterns
- **Service composition**: `internal/service/service_core.go` defines `appServices` struct holding all dependencies; typed services (`authAppService`, `paymentsAppService`, etc.) embed it
- **Repository abstraction**: Services depend on repository interfaces, not raw GORM
- **Generated DB layer**: `cmd/gendb/main.go` reads schema and generates models/queries
- **Internal auth**: gRPC metadata (`x-stream-internal-auth`, `x-stream-actor-*`) for service-to-service auth
- **Transaction boundaries**: Repositories handle transactions for multi-entity operations (payments, account deletion, subscription updates)
- **Job/render subsystem**: Jobs in DB + Redis sorted set queue + pub/sub for logs/cancels/updates + MQTT for agent events
### Test Patterns
- In-memory SQLite for service tests (`internal/service/__test__/testdb_setup_test.go`)
- bufconn for gRPC test servers
- Tests isolated and fast, no external Postgres required
## Important Caveats
- **Stale paths in docs/scripts**: `MIGRATION_GUIDE.md` and some scripts reference `cmd/api` or `cmd/grpc` — the actual entrypoint is `cmd/server`
- **Dockerfile**: References `./cmd/grpc` which doesn't exist; likely needs updating to `./cmd/server`
- **Proto path mismatch**: `proto/app/v1/admin.proto` declares `go_package` as `internal/gen/proto/...` but generated code lands in `internal/api/proto/...`
## Notification Settings
- Email: disabled
- Notifications: disabled

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1
go.mod
View File

@@ -63,6 +63,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sony/gobreaker v1.0.0
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cast v1.10.0 // indirect

2
go.sum
View File

@@ -126,6 +126,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sony/gobreaker v1.0.0 h1:feX5fGGXSl3dYd4aHZItw+FpHLvvoaqkawKjVNiFMNQ=
github.com/sony/gobreaker v1.0.0/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=

View File

@@ -0,0 +1,66 @@
package circuitbreaker
import (
"context"
"errors"
"time"
"github.com/sony/gobreaker"
)
var (
ErrCircuitOpen = errors.New("circuit breaker is open")
)
type CircuitBreaker struct {
cb *gobreaker.CircuitBreaker
}
// New creates a new circuit breaker with default settings
func New(name string) *CircuitBreaker {
settings := gobreaker.Settings{
Name: name,
MaxRequests: 3,
Interval: time.Second * 60,
Timeout: time.Second * 30,
ReadyToTrip: func(counts gobreaker.Counts) bool {
failureRatio := float64(counts.TotalFailures) / float64(counts.Requests)
return counts.Requests >= 3 && failureRatio >= 0.6
},
OnStateChange: func(name string, from gobreaker.State, to gobreaker.State) {
// Log state changes
// logger.Info("Circuit breaker state changed", "name", name, "from", from, "to", to)
},
}
return &CircuitBreaker{
cb: gobreaker.NewCircuitBreaker(settings),
}
}
// Execute runs the function with circuit breaker protection
func (cb *CircuitBreaker) Execute(ctx context.Context, fn func() error) error {
_, err := cb.cb.Execute(func() (interface{}, error) {
return nil, fn()
})
if err == gobreaker.ErrOpenState {
return ErrCircuitOpen
}
return err
}
// ExecuteWithFallback runs the function with circuit breaker and fallback
func (cb *CircuitBreaker) ExecuteWithFallback(ctx context.Context, fn func() error, fallback func() error) error {
err := cb.Execute(ctx, fn)
if err == ErrCircuitOpen && fallback != nil {
return fallback()
}
return err
}
// State returns the current state of the circuit breaker
func (cb *CircuitBreaker) State() gobreaker.State {
return cb.cb.State()
}

View File

@@ -1,5 +1,6 @@
package redis package redis
// DeadLetterQueue provides a simple implementation of a dead letter queue using Redis sorted sets and hashes. Each failed job is stored as a JSON-encoded entry with metadata such as failure time, reason, and retry count. The sorted set allows for efficient retrieval of jobs in order of failure time, while the hash stores the detailed metadata for each job.
import ( import (
"context" "context"
"encoding/json" "encoding/json"

View File

@@ -0,0 +1,73 @@
package repository
import (
"context"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
)
type accountRepository struct {
db *gorm.DB
}
func NewAccountRepository(db *gorm.DB) *accountRepository {
return &accountRepository{db: db}
}
func (r *accountRepository) DeleteUserAccount(ctx context.Context, userID string) error {
userID = strings.TrimSpace(userID)
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.WalletTransaction{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.PlanSubscription{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.UserPreference{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Payment{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
return err
}
if err := tx.Where("id = ?", userID).Delete(&model.User{}).Error; err != nil {
return err
}
return nil
})
}
func (r *accountRepository) ClearUserData(ctx context.Context, userID string) error {
userID = strings.TrimSpace(userID)
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{"storage_used": 0}).Error; err != nil {
return err
}
return nil
})
}

View File

@@ -0,0 +1,152 @@
package repository
import (
"context"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
)
type adTemplateRepository struct {
db *gorm.DB
}
func NewAdTemplateRepository(db *gorm.DB) *adTemplateRepository {
return &adTemplateRepository{db: db}
}
func (r *adTemplateRepository) ListByUser(ctx context.Context, userID string) ([]model.AdTemplate, error) {
var items []model.AdTemplate
err := r.db.WithContext(ctx).
Where("user_id = ?", strings.TrimSpace(userID)).
Order("is_default DESC").
Order("created_at DESC").
Find(&items).Error
return items, err
}
func (r *adTemplateRepository) ListForAdmin(ctx context.Context, search string, userID string, limit int32, offset int) ([]model.AdTemplate, int64, error) {
db := r.db.WithContext(ctx).Model(&model.AdTemplate{})
if trimmedSearch := strings.TrimSpace(search); trimmedSearch != "" {
like := "%" + trimmedSearch + "%"
db = db.Where("name ILIKE ?", like)
}
if trimmedUserID := strings.TrimSpace(userID); trimmedUserID != "" {
db = db.Where("user_id = ?", trimmedUserID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var templates []model.AdTemplate
if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&templates).Error; err != nil {
return nil, 0, err
}
return templates, total, nil
}
func (r *adTemplateRepository) CountAll(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.AdTemplate{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *adTemplateRepository) GetByID(ctx context.Context, id string) (*model.AdTemplate, error) {
var item model.AdTemplate
if err := r.db.WithContext(ctx).
Where("id = ?", strings.TrimSpace(id)).
First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *adTemplateRepository) GetByIDAndUser(ctx context.Context, id string, userID string) (*model.AdTemplate, error) {
var item model.AdTemplate
if err := r.db.WithContext(ctx).
Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).
First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *adTemplateRepository) ExistsByIDAndUser(ctx context.Context, id string, userID string) (bool, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&model.AdTemplate{}).
Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).
Count(&count).Error
return count > 0, err
}
func (r *adTemplateRepository) CreateWithDefault(ctx context.Context, userID string, item *model.AdTemplate) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := tx.Model(&model.AdTemplate{}).
Where("user_id = ?", strings.TrimSpace(userID)).
Update("is_default", false).Error; err != nil {
return err
}
}
return tx.Create(item).Error
})
}
func (r *adTemplateRepository) SaveWithDefault(ctx context.Context, userID string, item *model.AdTemplate) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := tx.Model(&model.AdTemplate{}).
Where("user_id = ? AND id <> ?", strings.TrimSpace(userID), item.ID).
Update("is_default", false).Error; err != nil {
return err
}
}
return tx.Save(item).Error
})
}
func (r *adTemplateRepository) DeleteByIDAndUserAndClearVideos(ctx context.Context, id string, userID string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Video{}).
Where("user_id = ? AND ad_id = ?", strings.TrimSpace(userID), strings.TrimSpace(id)).
Update("ad_id", nil).Error; err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).
Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
}
func (r *adTemplateRepository) DeleteByIDAndClearVideos(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Video{}).
Where("ad_id = ?", strings.TrimSpace(id)).
Update("ad_id", nil).Error; err != nil {
return err
}
res := tx.Where("id = ?", strings.TrimSpace(id)).
Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
}

View File

@@ -0,0 +1,41 @@
package repository
import (
"context"
"time"
"gorm.io/gorm"
"stream.api/internal/database/model"
)
type billingRepository struct {
db *gorm.DB
}
func NewBillingRepository(db *gorm.DB) *billingRepository {
return &billingRepository{db: db}
}
func (r *billingRepository) GetWalletBalance(ctx context.Context, userID string) (float64, error) {
return model.GetWalletBalance(ctx, r.db, userID)
}
func (r *billingRepository) GetWalletBalanceTx(tx *gorm.DB, ctx context.Context, userID string) (float64, error) {
return model.GetWalletBalance(ctx, tx, userID)
}
func (r *billingRepository) GetLatestPlanSubscription(ctx context.Context, userID string) (*model.PlanSubscription, error) {
return model.GetLatestPlanSubscription(ctx, r.db, userID)
}
func (r *billingRepository) GetLatestPlanSubscriptionTx(tx *gorm.DB, ctx context.Context, userID string) (*model.PlanSubscription, error) {
return model.GetLatestPlanSubscription(ctx, tx, userID)
}
func (r *billingRepository) CountActiveSubscriptions(ctx context.Context, now time.Time) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("expires_at > ?", now).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -0,0 +1,46 @@
package repository
import (
"context"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
)
type domainRepository struct {
db *gorm.DB
}
func NewDomainRepository(db *gorm.DB) *domainRepository {
return &domainRepository{db: db}
}
func (r *domainRepository) ListByUser(ctx context.Context, userID string) ([]model.Domain, error) {
var rows []model.Domain
err := r.db.WithContext(ctx).
Where("user_id = ?", strings.TrimSpace(userID)).
Order("created_at DESC").
Find(&rows).Error
return rows, err
}
func (r *domainRepository) CountByUserAndName(ctx context.Context, userID string, name string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).
Model(&model.Domain{}).
Where("user_id = ? AND name = ?", strings.TrimSpace(userID), strings.TrimSpace(name)).
Count(&count).Error
return count, err
}
func (r *domainRepository) Create(ctx context.Context, item *model.Domain) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *domainRepository) DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error) {
res := r.db.WithContext(ctx).
Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).
Delete(&model.Domain{})
return res.RowsAffected, res.Error
}

View File

@@ -0,0 +1,102 @@
package repository
import (
"context"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
)
type jobRepository struct {
db *gorm.DB
}
func NewJobRepository(db *gorm.DB) *jobRepository {
return &jobRepository{db: db}
}
func (r *jobRepository) ListByOffset(ctx context.Context, agentID string, offset int, limit int) ([]*model.Job, int64, error) {
q := query.Job.WithContext(ctx).Order(query.Job.CreatedAt.Desc(), query.Job.ID.Desc())
if trimmedAgentID := strings.TrimSpace(agentID); trimmedAgentID != "" {
agentNumeric, err := strconv.ParseInt(trimmedAgentID, 10, 64)
if err != nil {
return []*model.Job{}, 0, nil
}
q = q.Where(query.Job.AgentID.Eq(agentNumeric))
}
jobs, total, err := q.FindByPage(offset, limit)
if err != nil {
return nil, 0, err
}
items := make([]*model.Job, 0, len(jobs))
for _, job := range jobs {
items = append(items, job)
}
return items, total, nil
}
func (r *jobRepository) ListByCursor(ctx context.Context, agentID string, cursorTime time.Time, cursorID string, limit int) ([]*model.Job, bool, error) {
q := query.Job.WithContext(ctx).Order(query.Job.CreatedAt.Desc(), query.Job.ID.Desc())
if trimmedAgentID := strings.TrimSpace(agentID); trimmedAgentID != "" {
agentNumeric, err := strconv.ParseInt(trimmedAgentID, 10, 64)
if err != nil {
return []*model.Job{}, false, nil
}
q = q.Where(query.Job.AgentID.Eq(agentNumeric))
}
queryDB := q.UnderlyingDB()
if !cursorTime.IsZero() && strings.TrimSpace(cursorID) != "" {
queryDB = queryDB.Where("(created_at < ?) OR (created_at = ? AND id < ?)", cursorTime, cursorTime, strings.TrimSpace(cursorID))
}
var jobs []*model.Job
if err := queryDB.Limit(limit + 1).Find(&jobs).Error; err != nil {
return nil, false, err
}
hasMore := len(jobs) > limit
if hasMore {
jobs = jobs[:limit]
}
return jobs, hasMore, nil
}
func (r *jobRepository) Create(ctx context.Context, job *model.Job) error {
return query.Job.WithContext(ctx).Create(job)
}
func (r *jobRepository) GetByID(ctx context.Context, id string) (*model.Job, error) {
return query.Job.WithContext(ctx).Where(query.Job.ID.Eq(strings.TrimSpace(id))).First()
}
func (r *jobRepository) Save(ctx context.Context, job *model.Job) error {
return query.Job.WithContext(ctx).Save(job)
}
func (r *jobRepository) UpdateVideoStatus(ctx context.Context, videoID string, statusValue string, processingStatus string) error {
videoID = strings.TrimSpace(videoID)
if videoID == "" {
return nil
}
_, err := query.Video.WithContext(ctx).
Where(query.Video.ID.Eq(videoID)).
Updates(map[string]any{"status": statusValue, "processing_status": processingStatus})
return err
}
func (r *jobRepository) GetLatestByVideoID(ctx context.Context, videoID string) (*model.Job, error) {
var job model.Job
if err := r.db.WithContext(ctx).
Where("config::jsonb ->> 'video_id' = ?", strings.TrimSpace(videoID)).
Order("created_at DESC").
First(&job).Error; err != nil {
return nil, err
}
return &job, nil
}

View File

@@ -0,0 +1,54 @@
package repository
import (
"context"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
)
type notificationRepository struct {
db *gorm.DB
}
func NewNotificationRepository(db *gorm.DB) *notificationRepository {
return &notificationRepository{db: db}
}
func (r *notificationRepository) ListByUser(ctx context.Context, userID string) ([]model.Notification, error) {
var rows []model.Notification
err := r.db.WithContext(ctx).
Where("user_id = ?", strings.TrimSpace(userID)).
Order("created_at DESC").
Find(&rows).Error
return rows, err
}
func (r *notificationRepository) MarkReadByIDAndUser(ctx context.Context, id string, userID string) (int64, error) {
res := r.db.WithContext(ctx).
Model(&model.Notification{}).
Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).
Update("is_read", true)
return res.RowsAffected, res.Error
}
func (r *notificationRepository) MarkAllReadByUser(ctx context.Context, userID string) error {
return r.db.WithContext(ctx).
Model(&model.Notification{}).
Where("user_id = ? AND is_read = ?", strings.TrimSpace(userID), false).
Update("is_read", true).Error
}
func (r *notificationRepository) DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error) {
res := r.db.WithContext(ctx).
Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).
Delete(&model.Notification{})
return res.RowsAffected, res.Error
}
func (r *notificationRepository) DeleteAllByUser(ctx context.Context, userID string) error {
return r.db.WithContext(ctx).
Where("user_id = ?", strings.TrimSpace(userID)).
Delete(&model.Notification{}).Error
}

View File

@@ -0,0 +1,469 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
)
type PaymentHistoryRow struct {
ID string `gorm:"column:id"`
Amount float64 `gorm:"column:amount"`
Currency *string `gorm:"column:currency"`
Status *string `gorm:"column:status"`
PlanID *string `gorm:"column:plan_id"`
PlanName *string `gorm:"column:plan_name"`
InvoiceID string `gorm:"column:invoice_id"`
Kind string `gorm:"column:kind"`
TermMonths *int32 `gorm:"column:term_months"`
PaymentMethod *string `gorm:"column:payment_method"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
CreatedAt *time.Time `gorm:"column:created_at"`
}
type paymentRepository struct {
db *gorm.DB
}
func NewPaymentRepository(db *gorm.DB) *paymentRepository {
return &paymentRepository{db: db}
}
func (r *paymentRepository) ListHistoryByUser(ctx context.Context, userID string, subscriptionKind string, walletTopupKind string, topupType string, limit int32, offset int) ([]PaymentHistoryRow, int64, error) {
baseQuery := `
WITH history AS (
SELECT
p.id AS id,
p.amount AS amount,
p.currency AS currency,
p.status AS status,
p.plan_id AS plan_id,
pl.name AS plan_name,
p.id AS invoice_id,
? AS kind,
ps.term_months AS term_months,
ps.payment_method AS payment_method,
ps.expires_at AS expires_at,
p.created_at AS created_at
FROM payment AS p
LEFT JOIN plan AS pl ON pl.id = p.plan_id
LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id
WHERE p.user_id = ?
UNION ALL
SELECT
wt.id AS id,
wt.amount AS amount,
wt.currency AS currency,
'SUCCESS' AS status,
NULL AS plan_id,
NULL AS plan_name,
wt.id AS invoice_id,
? AS kind,
NULL AS term_months,
NULL AS payment_method,
NULL AS expires_at,
wt.created_at AS created_at
FROM wallet_transactions AS wt
WHERE wt.user_id = ? AND wt.type = ? AND wt.payment_id IS NULL
)
`
var total int64
if err := r.db.WithContext(ctx).
Raw(baseQuery+`SELECT COUNT(*) FROM history`, subscriptionKind, userID, walletTopupKind, userID, topupType).
Scan(&total).Error; err != nil {
return nil, 0, err
}
var rows []PaymentHistoryRow
if err := r.db.WithContext(ctx).
Raw(baseQuery+`SELECT * FROM history ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, subscriptionKind, userID, walletTopupKind, userID, topupType, limit, offset).
Scan(&rows).Error; err != nil {
return nil, 0, err
}
return rows, total, nil
}
func (r *paymentRepository) ListForAdmin(ctx context.Context, userID string, status string, limit int32, offset int) ([]model.Payment, int64, error) {
db := r.db.WithContext(ctx).Model(&model.Payment{})
if trimmedUserID := strings.TrimSpace(userID); trimmedUserID != "" {
db = db.Where("user_id = ?", trimmedUserID)
}
if trimmedStatus := strings.TrimSpace(status); trimmedStatus != "" {
db = db.Where("UPPER(status) = ?", strings.ToUpper(trimmedStatus))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var payments []model.Payment
if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&payments).Error; err != nil {
return nil, 0, err
}
return payments, total, nil
}
func (r *paymentRepository) CountAll(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.Payment{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *paymentRepository) SumSuccessfulAmount(ctx context.Context) (float64, error) {
var total float64
if err := r.db.WithContext(ctx).Model(&model.Payment{}).Where("status = ?", "SUCCESS").Select("COALESCE(SUM(amount), 0)").Scan(&total).Error; err != nil {
return 0, err
}
return total, nil
}
func (r *paymentRepository) GetByID(ctx context.Context, paymentID string) (*model.Payment, error) {
return query.Payment.WithContext(ctx).
Where(query.Payment.ID.Eq(strings.TrimSpace(paymentID))).
First()
}
func (r *paymentRepository) GetByIDAndUser(ctx context.Context, paymentID string, userID string) (*model.Payment, error) {
return query.Payment.WithContext(ctx).
Where(query.Payment.ID.Eq(strings.TrimSpace(paymentID)), query.Payment.UserID.Eq(strings.TrimSpace(userID))).
First()
}
func (r *paymentRepository) GetStandaloneTopupByIDAndUser(ctx context.Context, id string, userID string, topupType string) (*model.WalletTransaction, error) {
var topup model.WalletTransaction
if err := r.db.WithContext(ctx).
Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", strings.TrimSpace(id), strings.TrimSpace(userID), topupType).
First(&topup).Error; err != nil {
return nil, err
}
return &topup, nil
}
func (r *paymentRepository) GetSubscriptionByPaymentID(ctx context.Context, paymentID string) (*model.PlanSubscription, error) {
var subscription model.PlanSubscription
if err := r.db.WithContext(ctx).
Where("payment_id = ?", strings.TrimSpace(paymentID)).
Order("created_at DESC").
First(&subscription).Error; err != nil {
return nil, err
}
return &subscription, nil
}
func (r *paymentRepository) CountByPlanID(ctx context.Context, planID string) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", strings.TrimSpace(planID)).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *paymentRepository) CreatePayment(ctx context.Context, payment *model.Payment) error {
return r.db.WithContext(ctx).Create(payment).Error
}
func (r *paymentRepository) Save(ctx context.Context, payment *model.Payment) error {
return r.db.WithContext(ctx).Save(payment).Error
}
func (r *paymentRepository) CreatePaymentTx(tx *gorm.DB, ctx context.Context, payment *model.Payment) error {
return tx.Create(payment).Error
}
func (r *paymentRepository) CreateWalletTransactionTx(tx *gorm.DB, ctx context.Context, txRecord *model.WalletTransaction) error {
return tx.Create(txRecord).Error
}
func (r *paymentRepository) CreatePlanSubscriptionTx(tx *gorm.DB, ctx context.Context, subscription *model.PlanSubscription) error {
return tx.Create(subscription).Error
}
func (r *paymentRepository) UpdateUserPlanID(ctx context.Context, userID string, planID string) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Update("plan_id", planID).Error
}
func (r *paymentRepository) UpdateUserPlanIDTx(tx *gorm.DB, ctx context.Context, userID string, planID string) error {
return tx.WithContext(ctx).Model(&model.User{}).Where("id = ?", strings.TrimSpace(userID)).Update("plan_id", strings.TrimSpace(planID)).Error
}
func (r *paymentRepository) CreateNotificationTx(tx *gorm.DB, ctx context.Context, notification *model.Notification) error {
return tx.Create(notification).Error
}
func (r *paymentRepository) CreateWalletTopupAndNotification(ctx context.Context, userID string, transaction *model.WalletTransaction, notification *model.Notification) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if _, err := r.lockUserForUpdateTx(tx, ctx, userID); err != nil {
return err
}
if err := r.CreateWalletTransactionTx(tx, ctx, transaction); err != nil {
return err
}
return r.CreateNotificationTx(tx, ctx, notification)
})
}
func (r *paymentRepository) ExecuteSubscriptionPayment(ctx context.Context, userID string, plan *model.Plan, termMonths int32, paymentMethod string, paymentRecord *model.Payment, invoiceID string, now time.Time, validateFunding func(currentWalletBalance float64) (float64, error)) (*model.PlanSubscription, float64, error) {
var (
subscription *model.PlanSubscription
walletBalance float64
)
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
referee, err := r.lockUserForUpdateTx(tx, ctx, userID)
if err != nil {
return err
}
newExpiry, err := r.loadPaymentExpiryTx(tx, ctx, userID, termMonths, now)
if err != nil {
return err
}
currentWalletBalance, err := model.GetWalletBalance(ctx, tx, userID)
if err != nil {
return err
}
validatedTopupAmount, err := validateFunding(currentWalletBalance)
if err != nil {
return err
}
if err := r.CreatePaymentTx(tx, ctx, paymentRecord); err != nil {
return err
}
if err := r.createPaymentWalletTransactionsTx(tx, ctx, userID, plan, termMonths, paymentMethod, paymentRecord, paymentRecord.Amount, validatedTopupAmount, model.StringValue(paymentRecord.Currency)); err != nil {
return err
}
subscription = &model.PlanSubscription{
ID: uuid.New().String(),
UserID: userID,
PaymentID: paymentRecord.ID,
PlanID: plan.ID,
TermMonths: termMonths,
PaymentMethod: paymentMethod,
WalletAmount: paymentRecord.Amount,
TopupAmount: validatedTopupAmount,
StartedAt: now,
ExpiresAt: newExpiry,
}
if err := r.CreatePlanSubscriptionTx(tx, ctx, subscription); err != nil {
return err
}
if err := r.UpdateUserPlanIDTx(tx, ctx, userID, plan.ID); err != nil {
return err
}
notification := &model.Notification{
ID: uuid.New().String(),
UserID: userID,
Type: "billing.subscription",
Title: "Subscription activated",
Message: fmt.Sprintf("Your subscription to %s is active until %s.", plan.Name, subscription.ExpiresAt.UTC().Format("2006-01-02")),
Metadata: model.StringPtr(mustMarshalJSON(map[string]any{
"payment_id": paymentRecord.ID,
"invoice_id": invoiceID,
"plan_id": plan.ID,
"term_months": subscription.TermMonths,
"payment_method": subscription.PaymentMethod,
"wallet_amount": subscription.WalletAmount,
"topup_amount": subscription.TopupAmount,
"plan_expires_at": subscription.ExpiresAt.UTC().Format(time.RFC3339),
})),
}
if err := r.CreateNotificationTx(tx, ctx, notification); err != nil {
return err
}
if err := r.maybeGrantReferralRewardTx(tx, ctx, referee, plan, paymentRecord, subscription); err != nil {
return err
}
walletBalance, err = model.GetWalletBalance(ctx, tx, userID)
return err
})
return subscription, walletBalance, err
}
func (r *paymentRepository) lockUserForUpdateTx(tx *gorm.DB, ctx context.Context, userID string) (*model.User, error) {
trimmedUserID := strings.TrimSpace(userID)
if tx.Dialector.Name() == "sqlite" {
res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", trimmedUserID)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, gorm.ErrRecordNotFound
}
}
var user model.User
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", trimmedUserID).
First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *paymentRepository) loadPaymentExpiryTx(tx *gorm.DB, ctx context.Context, userID string, termMonths int32, now time.Time) (time.Time, error) {
currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, userID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return time.Time{}, err
}
baseExpiry := now
if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) {
baseExpiry = currentSubscription.ExpiresAt.UTC()
}
return baseExpiry.AddDate(0, int(termMonths), 0), nil
}
func (r *paymentRepository) createPaymentWalletTransactionsTx(tx *gorm.DB, ctx context.Context, userID string, plan *model.Plan, termMonths int32, paymentMethod string, paymentRecord *model.Payment, totalAmount, topupAmount float64, currency string) error {
if paymentMethod == "topup" {
topupTransaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: userID,
Type: "topup",
Amount: topupAmount,
Currency: model.StringPtr(currency),
Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", plan.Name, termMonths)),
PaymentID: &paymentRecord.ID,
PlanID: &plan.ID,
TermMonths: &termMonths,
}
if err := r.CreateWalletTransactionTx(tx, ctx, topupTransaction); err != nil {
return err
}
}
debitTransaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: userID,
Type: "subscription_debit",
Amount: -totalAmount,
Currency: model.StringPtr(currency),
Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", plan.Name, termMonths)),
PaymentID: &paymentRecord.ID,
PlanID: &plan.ID,
TermMonths: &termMonths,
}
return r.CreateWalletTransactionTx(tx, ctx, debitTransaction)
}
func (r *paymentRepository) maybeGrantReferralRewardTx(tx *gorm.DB, ctx context.Context, referee *model.User, plan *model.Plan, paymentRecord *model.Payment, subscription *model.PlanSubscription) error {
if paymentRecord == nil || subscription == nil || plan == nil || referee == nil {
return nil
}
if subscription.PaymentMethod != "wallet" && subscription.PaymentMethod != "topup" {
return nil
}
if referee.ReferredByUserID == nil || strings.TrimSpace(*referee.ReferredByUserID) == "" {
return nil
}
if referee.ReferralRewardGrantedAt != nil || (referee.ReferralRewardPaymentID != nil && strings.TrimSpace(*referee.ReferralRewardPaymentID) != "") {
return nil
}
var subscriptionCount int64
if err := tx.WithContext(ctx).Model(&model.PlanSubscription{}).Where("user_id = ?", referee.ID).Count(&subscriptionCount).Error; err != nil {
return err
}
if subscriptionCount != 1 {
return nil
}
referrer, err := r.lockUserForUpdateTx(tx, ctx, strings.TrimSpace(*referee.ReferredByUserID))
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
if referrer.ID == referee.ID || (referrer.ReferralEligible != nil && !*referrer.ReferralEligible) {
return nil
}
bps := int32(500)
if referrer.ReferralRewardBps != nil {
bps = *referrer.ReferralRewardBps
if bps < 0 {
bps = 0
}
if bps > 10000 {
bps = 10000
}
}
if bps <= 0 {
return nil
}
baseAmount := plan.Price * float64(subscription.TermMonths)
if baseAmount <= 0 {
return nil
}
rewardAmount := baseAmount * float64(bps) / 10000
if rewardAmount <= 0 {
return nil
}
refereeLabel := strings.TrimSpace(referee.Email)
if username := strings.TrimSpace(model.StringValue(referee.Username)); username != "" {
refereeLabel = "@" + username
}
rewardTransaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: referrer.ID,
Type: "referral_reward",
Amount: rewardAmount,
Currency: paymentRecord.Currency,
Note: model.StringPtr(fmt.Sprintf("Referral reward for %s first subscription", referee.Email)),
PaymentID: &paymentRecord.ID,
PlanID: &plan.ID,
}
if err := tx.Create(rewardTransaction).Error; err != nil {
return err
}
notification := &model.Notification{
ID: uuid.New().String(),
UserID: referrer.ID,
Type: "billing.referral_reward",
Title: "Referral reward granted",
Message: fmt.Sprintf("You received %.2f USD from %s's first subscription.", rewardAmount, refereeLabel),
Metadata: model.StringPtr(mustMarshalJSON(map[string]any{
"payment_id": paymentRecord.ID,
"referee_id": referee.ID,
"amount": rewardAmount,
})),
}
if err := tx.Create(notification).Error; err != nil {
return err
}
now := time.Now().UTC()
return tx.WithContext(ctx).Model(&model.User{}).Where("id = ?", referee.ID).Updates(map[string]any{
"referral_reward_granted_at": now,
"referral_reward_payment_id": paymentRecord.ID,
"referral_reward_amount": rewardAmount,
}).Error
}
func mustMarshalJSON(value any) string {
encoded, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(encoded)
}

View File

@@ -0,0 +1,69 @@
package repository
import (
"context"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
)
type planRepository struct {
db *gorm.DB
}
func NewPlanRepository(db *gorm.DB) *planRepository {
return &planRepository{db: db}
}
func (r *planRepository) GetByID(ctx context.Context, planID string) (*model.Plan, error) {
var plan model.Plan
if err := r.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(planID)).First(&plan).Error; err != nil {
return nil, err
}
return &plan, nil
}
func (r *planRepository) ListActive(ctx context.Context) ([]model.Plan, error) {
var plans []model.Plan
if err := r.db.WithContext(ctx).Where("is_active = ?", true).Find(&plans).Error; err != nil {
return nil, err
}
return plans, nil
}
func (r *planRepository) ListAll(ctx context.Context) ([]model.Plan, error) {
var plans []model.Plan
if err := r.db.WithContext(ctx).Order("price ASC").Find(&plans).Error; err != nil {
return nil, err
}
return plans, nil
}
func (r *planRepository) Create(ctx context.Context, plan *model.Plan) error {
return r.db.WithContext(ctx).Create(plan).Error
}
func (r *planRepository) Save(ctx context.Context, plan *model.Plan) error {
return r.db.WithContext(ctx).Save(plan).Error
}
func (r *planRepository) CountPaymentsByPlan(ctx context.Context, planID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", strings.TrimSpace(planID)).Count(&count).Error
return count, err
}
func (r *planRepository) CountSubscriptionsByPlan(ctx context.Context, planID string) (int64, error) {
var count int64
err := r.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", strings.TrimSpace(planID)).Count(&count).Error
return count, err
}
func (r *planRepository) SetActive(ctx context.Context, planID string, isActive bool) error {
return r.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", strings.TrimSpace(planID)).Update("is_active", isActive).Error
}
func (r *planRepository) DeleteByID(ctx context.Context, planID string) error {
return r.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(planID)).Delete(&model.Plan{}).Error
}

View File

@@ -0,0 +1,277 @@
package repository
import (
"context"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"stream.api/internal/database/model"
)
type playerConfigRepository struct {
db *gorm.DB
}
func NewPlayerConfigRepository(db *gorm.DB) *playerConfigRepository {
return &playerConfigRepository{db: db}
}
func (r *playerConfigRepository) ListByUser(ctx context.Context, userID string) ([]model.PlayerConfig, error) {
var items []model.PlayerConfig
if err := r.db.WithContext(ctx).
Where("user_id = ?", strings.TrimSpace(userID)).
Order("is_default DESC").
Order("created_at DESC").
Find(&items).Error; err != nil {
return nil, err
}
return items, nil
}
func (r *playerConfigRepository) ListForAdmin(ctx context.Context, search string, userID string, limit int32, offset int) ([]model.PlayerConfig, int64, error) {
db := r.db.WithContext(ctx).Model(&model.PlayerConfig{})
if trimmedSearch := strings.TrimSpace(search); trimmedSearch != "" {
like := "%" + trimmedSearch + "%"
db = db.Where("name ILIKE ?", like)
}
if trimmedUserID := strings.TrimSpace(userID); trimmedUserID != "" {
db = db.Where("user_id = ?", trimmedUserID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var items []model.PlayerConfig
if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&items).Error; err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *playerConfigRepository) CountByUser(ctx context.Context, userID string) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", strings.TrimSpace(userID)).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *playerConfigRepository) CountByUserTx(tx *gorm.DB, ctx context.Context, userID string) (int64, error) {
var count int64
if err := tx.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", strings.TrimSpace(userID)).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *playerConfigRepository) Create(ctx context.Context, item *model.PlayerConfig) error {
return r.db.WithContext(ctx).Create(item).Error
}
func (r *playerConfigRepository) CreateWithDefault(ctx context.Context, userID string, item *model.PlayerConfig) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := r.UnsetDefaultForUserTx(tx, userID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
})
}
func (r *playerConfigRepository) CreateTx(tx *gorm.DB, ctx context.Context, item *model.PlayerConfig) error {
return tx.Create(item).Error
}
func (r *playerConfigRepository) GetByIDAndUser(ctx context.Context, id string, userID string) (*model.PlayerConfig, error) {
var item model.PlayerConfig
if err := r.db.WithContext(ctx).
Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).
First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *playerConfigRepository) GetByID(ctx context.Context, id string) (*model.PlayerConfig, error) {
var item model.PlayerConfig
if err := r.db.WithContext(ctx).
Where("id = ?", strings.TrimSpace(id)).
First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *playerConfigRepository) GetByIDAndUserTx(tx *gorm.DB, ctx context.Context, id string, userID string) (*model.PlayerConfig, error) {
var item model.PlayerConfig
if err := tx.WithContext(ctx).
Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).
First(&item).Error; err != nil {
return nil, err
}
return &item, nil
}
func (r *playerConfigRepository) Save(ctx context.Context, item *model.PlayerConfig) error {
return r.db.WithContext(ctx).Save(item).Error
}
func (r *playerConfigRepository) SaveWithDefault(ctx context.Context, userID string, item *model.PlayerConfig) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if item.IsDefault {
if err := r.UnsetDefaultForUserTx(tx, userID, item.ID); err != nil {
return err
}
}
return tx.Save(item).Error
})
}
func (r *playerConfigRepository) SaveTx(tx *gorm.DB, ctx context.Context, item *model.PlayerConfig) error {
return tx.Save(item).Error
}
func (r *playerConfigRepository) DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error) {
res := r.db.WithContext(ctx).
Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).
Delete(&model.PlayerConfig{})
return res.RowsAffected, res.Error
}
func (r *playerConfigRepository) DeleteByID(ctx context.Context, id string) (int64, error) {
res := r.db.WithContext(ctx).
Where("id = ?", strings.TrimSpace(id)).
Delete(&model.PlayerConfig{})
return res.RowsAffected, res.Error
}
func (r *playerConfigRepository) DeleteByIDAndUserTx(tx *gorm.DB, ctx context.Context, id string, userID string) (int64, error) {
res := tx.WithContext(ctx).
Where("id = ? AND user_id = ?", strings.TrimSpace(id), strings.TrimSpace(userID)).
Delete(&model.PlayerConfig{})
return res.RowsAffected, res.Error
}
func (r *playerConfigRepository) UnsetDefaultForUser(ctx context.Context, userID string, excludeID string) error {
updates := map[string]any{"is_default": false}
db := r.db.WithContext(ctx).Model(&model.PlayerConfig{}).Where("user_id = ?", strings.TrimSpace(userID))
if strings.TrimSpace(excludeID) != "" {
db = db.Where("id != ?", strings.TrimSpace(excludeID))
}
return db.Updates(updates).Error
}
func (r *playerConfigRepository) UnsetDefaultForUserTx(tx *gorm.DB, userID string, excludeID string) error {
updates := map[string]any{"is_default": false}
db := tx.Model(&model.PlayerConfig{}).Where("user_id = ?", strings.TrimSpace(userID))
if strings.TrimSpace(excludeID) != "" {
db = db.Where("id != ?", strings.TrimSpace(excludeID))
}
return db.Updates(updates).Error
}
func (r *playerConfigRepository) CreateManaged(ctx context.Context, userID string, item *model.PlayerConfig, validate func(*model.User, int64) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := r.lockUserForUpdateTx(tx, ctx, userID)
if err != nil {
return err
}
configCount, err := r.CountByUserTx(tx, ctx, userID)
if err != nil {
return err
}
if err := validate(lockedUser, configCount); err != nil {
return err
}
if item.IsDefault {
if err := r.UnsetDefaultForUserTx(tx, userID, ""); err != nil {
return err
}
}
return r.CreateTx(tx, ctx, item)
})
}
func (r *playerConfigRepository) UpdateManaged(ctx context.Context, userID string, id string, mutateAndValidate func(*model.PlayerConfig, *model.User, int64) error) (*model.PlayerConfig, error) {
var item *model.PlayerConfig
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := r.lockUserForUpdateTx(tx, ctx, userID)
if err != nil {
return err
}
configCount, err := r.CountByUserTx(tx, ctx, userID)
if err != nil {
return err
}
item, err = r.GetByIDAndUserTx(tx, ctx, id, userID)
if err != nil {
return err
}
if err := mutateAndValidate(item, lockedUser, configCount); err != nil {
return err
}
if item.IsDefault {
if err := r.UnsetDefaultForUserTx(tx, userID, item.ID); err != nil {
return err
}
}
return r.SaveTx(tx, ctx, item)
})
return item, err
}
func (r *playerConfigRepository) DeleteManaged(ctx context.Context, userID string, id string, validate func(*model.User, int64) error) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := r.lockUserForUpdateTx(tx, ctx, userID)
if err != nil {
return err
}
configCount, err := r.CountByUserTx(tx, ctx, userID)
if err != nil {
return err
}
if err := validate(lockedUser, configCount); err != nil {
return err
}
rowsAffected, err := r.DeleteByIDAndUserTx(tx, ctx, id, userID)
if err != nil {
return err
}
if rowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
}
func (r *playerConfigRepository) lockUserForUpdateTx(tx *gorm.DB, ctx context.Context, userID string) (*model.User, error) {
trimmedUserID := strings.TrimSpace(userID)
if tx.Dialector.Name() == "sqlite" {
res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", trimmedUserID)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, gorm.ErrRecordNotFound
}
}
var user model.User
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", trimmedUserID).
First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}

View File

@@ -0,0 +1,24 @@
package repository
import (
"context"
"gorm.io/gorm"
"stream.api/internal/database/model"
)
type userPreferenceRepository struct {
db *gorm.DB
}
func NewUserPreferenceRepository(db *gorm.DB) *userPreferenceRepository {
return &userPreferenceRepository{db: db}
}
func (r *userPreferenceRepository) FindOrCreateByUserID(ctx context.Context, userID string) (*model.UserPreference, error) {
return model.FindOrCreateUserPreference(ctx, r.db, userID)
}
func (r *userPreferenceRepository) Save(ctx context.Context, pref *model.UserPreference) error {
return r.db.WithContext(ctx).Save(pref).Error
}

View File

@@ -0,0 +1,173 @@
package repository
import (
"context"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
)
type userRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *userRepository {
return &userRepository{db: db}
}
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*model.User, error) {
u := query.User
return u.WithContext(ctx).Where(u.Email.Eq(strings.TrimSpace(email))).First()
}
func (r *userRepository) CountByEmail(ctx context.Context, email string) (int64, error) {
u := query.User
return u.WithContext(ctx).Where(u.Email.Eq(strings.TrimSpace(email))).Count()
}
func (r *userRepository) GetByID(ctx context.Context, userID string) (*model.User, error) {
u := query.User
return u.WithContext(ctx).Where(u.ID.Eq(strings.TrimSpace(userID))).First()
}
func (r *userRepository) ListForAdmin(ctx context.Context, search string, role string, limit int32, offset int) ([]model.User, int64, error) {
db := r.db.WithContext(ctx).Model(&model.User{})
if trimmedSearch := strings.TrimSpace(search); trimmedSearch != "" {
like := "%" + trimmedSearch + "%"
db = db.Where("email ILIKE ? OR username ILIKE ?", like, like)
}
if trimmedRole := strings.TrimSpace(role); trimmedRole != "" {
db = db.Where("UPPER(role) = ?", strings.ToUpper(trimmedRole))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var users []model.User
if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
func (r *userRepository) CountAll(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *userRepository) CountCreatedSince(ctx context.Context, since time.Time) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("created_at >= ?", since).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *userRepository) SumStorageUsed(ctx context.Context) (int64, error) {
var total int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Select("COALESCE(SUM(storage_used), 0)").Scan(&total).Error; err != nil {
return 0, err
}
return total, nil
}
func (r *userRepository) GetEmailByID(ctx context.Context, userID string) (*string, error) {
var user model.User
if err := r.db.WithContext(ctx).Select("id, email").Where("id = ?", strings.TrimSpace(userID)).First(&user).Error; err != nil {
return nil, err
}
return &user.Email, nil
}
func (r *userRepository) GetReferralSummaryByID(ctx context.Context, userID string) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).Select("id, email, username").Where("id = ?", strings.TrimSpace(userID)).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) CountByPlanID(ctx context.Context, planID string) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.User{}).Where("plan_id = ?", strings.TrimSpace(planID)).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *userRepository) LockByIDTx(tx *gorm.DB, ctx context.Context, userID string) (*model.User, error) {
trimmedUserID := strings.TrimSpace(userID)
if tx.Dialector.Name() == "sqlite" {
res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", trimmedUserID)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, gorm.ErrRecordNotFound
}
}
var user model.User
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", trimmedUserID).
First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *userRepository) Create(ctx context.Context, user *model.User) error {
return query.User.WithContext(ctx).Create(user)
}
func (r *userRepository) UpdateFieldsByID(ctx context.Context, userID string, updates map[string]any) error {
return r.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", strings.TrimSpace(userID)).Updates(updates).Error
}
func (r *userRepository) UpdateFieldsByIDTx(tx *gorm.DB, ctx context.Context, userID string, updates map[string]any) error {
return tx.WithContext(ctx).Model(&model.User{}).Where("id = ?", strings.TrimSpace(userID)).Updates(updates).Error
}
func (r *userRepository) UpdatePassword(ctx context.Context, userID string, passwordHash string) error {
_, err := query.User.WithContext(ctx).
Where(query.User.ID.Eq(strings.TrimSpace(userID))).
Update(query.User.Password, passwordHash)
return err
}
func (r *userRepository) FindByReferralUsername(ctx context.Context, username string, limit int) ([]model.User, error) {
trimmed := strings.TrimSpace(username)
if trimmed == "" {
return nil, nil
}
var users []model.User
if err := r.db.WithContext(ctx).
Where("LOWER(username) = LOWER(?)", trimmed).
Order("created_at ASC, id ASC").
Limit(limit).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *userRepository) CountSubscriptionsByUser(ctx context.Context, userID string) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).
Model(&model.PlanSubscription{}).
Where("user_id = ?", strings.TrimSpace(userID)).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -0,0 +1,210 @@
package repository
import (
"context"
"errors"
"strings"
"time"
"gorm.io/gorm"
"stream.api/internal/database/model"
)
type videoRepository struct {
db *gorm.DB
}
func NewVideoRepository(db *gorm.DB) *videoRepository {
return &videoRepository{db: db}
}
func (r *videoRepository) ListByUser(ctx context.Context, userID string, search string, status string, offset int, limit int) ([]model.Video, int64, error) {
db := r.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", strings.TrimSpace(userID))
if trimmedSearch := strings.TrimSpace(search); trimmedSearch != "" {
like := "%" + trimmedSearch + "%"
db = db.Where("title ILIKE ? OR description ILIKE ?", like, like)
}
if trimmedStatus := strings.TrimSpace(status); trimmedStatus != "" && !strings.EqualFold(trimmedStatus, "all") {
db = db.Where("status = ?", trimmedStatus)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&videos).Error; err != nil {
return nil, 0, err
}
return videos, total, nil
}
func (r *videoRepository) ListForAdmin(ctx context.Context, search string, userID string, status string, offset int, limit int) ([]model.Video, int64, error) {
db := r.db.WithContext(ctx).Model(&model.Video{})
if trimmedSearch := strings.TrimSpace(search); trimmedSearch != "" {
like := "%" + trimmedSearch + "%"
db = db.Where("title ILIKE ?", like)
}
if trimmedUserID := strings.TrimSpace(userID); trimmedUserID != "" {
db = db.Where("user_id = ?", trimmedUserID)
}
if trimmedStatus := strings.TrimSpace(status); trimmedStatus != "" && !strings.EqualFold(trimmedStatus, "all") {
db = db.Where("status = ?", trimmedStatus)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&videos).Error; err != nil {
return nil, 0, err
}
return videos, total, nil
}
func (r *videoRepository) CountAll(ctx context.Context) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.Video{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *videoRepository) CountCreatedSince(ctx context.Context, since time.Time) (int64, error) {
var count int64
if err := r.db.WithContext(ctx).Model(&model.Video{}).Where("created_at >= ?", since).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *videoRepository) IncrementViews(ctx context.Context, videoID string, userID string) error {
return r.db.WithContext(ctx).
Model(&model.Video{}).
Where("id = ? AND user_id = ?", strings.TrimSpace(videoID), strings.TrimSpace(userID)).
UpdateColumn("views", gorm.Expr("views + ?", 1)).Error
}
func (r *videoRepository) GetByIDAndUser(ctx context.Context, videoID string, userID string) (*model.Video, error) {
var video model.Video
if err := r.db.WithContext(ctx).
Where("id = ? AND user_id = ?", strings.TrimSpace(videoID), strings.TrimSpace(userID)).
First(&video).Error; err != nil {
return nil, err
}
return &video, nil
}
func (r *videoRepository) GetByID(ctx context.Context, videoID string) (*model.Video, error) {
var video model.Video
if err := r.db.WithContext(ctx).
Where("id = ?", strings.TrimSpace(videoID)).
First(&video).Error; err != nil {
return nil, err
}
return &video, nil
}
func (r *videoRepository) UpdateByIDAndUser(ctx context.Context, videoID string, userID string, updates map[string]any) (int64, error) {
res := r.db.WithContext(ctx).
Model(&model.Video{}).
Where("id = ? AND user_id = ?", strings.TrimSpace(videoID), strings.TrimSpace(userID)).
Updates(updates)
return res.RowsAffected, res.Error
}
func (r *videoRepository) CountByUser(ctx context.Context, userID string) (int64, error) {
var total int64
err := r.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", strings.TrimSpace(userID)).Count(&total).Error
return total, err
}
func (r *videoRepository) DeleteByIDAndUserWithStorageUpdate(ctx context.Context, videoID string, userID string, videoSize int64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return r.deleteByIDAndUserWithStorageUpdateTx(tx, ctx, videoID, userID, videoSize)
})
}
func (r *videoRepository) deleteByIDAndUserWithStorageUpdateTx(tx *gorm.DB, ctx context.Context, videoID string, userID string, videoSize int64) error {
if err := tx.WithContext(ctx).Where("id = ? AND user_id = ?", strings.TrimSpace(videoID), strings.TrimSpace(userID)).Delete(&model.Video{}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).
Where("id = ?", strings.TrimSpace(userID)).
UpdateColumn("storage_used", gorm.Expr("storage_used - ?", videoSize)).Error
}
func (r *videoRepository) DeleteByIDWithStorageUpdate(ctx context.Context, videoID string, userID string, videoSize int64) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Where("id = ?", strings.TrimSpace(videoID)).Delete(&model.Video{}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).
Where("id = ?", strings.TrimSpace(userID)).
UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", videoSize)).Error
})
}
func (r *videoRepository) UpdateAdminVideo(ctx context.Context, video *model.Video, oldUserID string, oldSize int64, adTemplateID *string) error {
if video == nil {
return gorm.ErrInvalidData
}
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Save(video).Error; err != nil {
return err
}
if strings.TrimSpace(oldUserID) == strings.TrimSpace(video.UserID) {
delta := video.Size - oldSize
if delta != 0 {
if err := tx.Model(&model.User{}).
Where("id = ?", strings.TrimSpace(video.UserID)).
UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used + ?, 0)", delta)).Error; err != nil {
return err
}
}
} else {
if err := tx.Model(&model.User{}).
Where("id = ?", strings.TrimSpace(oldUserID)).
UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", oldSize)).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", strings.TrimSpace(video.UserID)).
UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
return err
}
}
if adTemplateID == nil {
return nil
}
trimmedAdID := strings.TrimSpace(*adTemplateID)
if trimmedAdID == "" {
if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", nil).Error; err != nil {
return err
}
video.AdID = nil
return nil
}
var template model.AdTemplate
if err := tx.WithContext(ctx).Select("id").Where("id = ? AND user_id = ?", trimmedAdID, strings.TrimSpace(video.UserID)).First(&template).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return gorm.ErrRecordNotFound
}
return err
}
if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", template.ID).Error; err != nil {
return err
}
video.AdID = &template.ID
return nil
})
}

View File

@@ -0,0 +1,67 @@
package repository
import (
"context"
"strings"
"gorm.io/gorm"
"stream.api/internal/database/model"
)
type videoWorkflowRepository struct {
db *gorm.DB
}
func NewVideoWorkflowRepository(db *gorm.DB) *videoWorkflowRepository {
return &videoWorkflowRepository{db: db}
}
func (r *videoWorkflowRepository) GetUserByID(ctx context.Context, userID string) (*model.User, error) {
var user model.User
if err := r.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(userID)).First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func (r *videoWorkflowRepository) CreateVideoWithStorageAndAd(ctx context.Context, video *model.Video, userID string, adTemplateID *string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(video).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).
Where("id = ?", strings.TrimSpace(userID)).
UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
return err
}
if video == nil || adTemplateID == nil {
return nil
}
trimmed := strings.TrimSpace(*adTemplateID)
if trimmed == "" {
if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", nil).Error; err != nil {
return err
}
video.AdID = nil
return nil
}
var template model.AdTemplate
if err := tx.WithContext(ctx).Select("id").Where("id = ? AND user_id = ?", trimmed, strings.TrimSpace(userID)).First(&template).Error; err != nil {
return err
}
if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", template.ID).Error; err != nil {
return err
}
video.AdID = &template.ID
return nil
})
}
func (r *videoWorkflowRepository) MarkVideoJobFailed(ctx context.Context, videoID string) error {
return r.db.WithContext(ctx).
Model(&model.Video{}).
Where("id = ?", strings.TrimSpace(videoID)).
Updates(map[string]any{"status": "failed", "processing_status": "FAILED"}).Error
}

View File

@@ -10,7 +10,7 @@ import (
appv1 "stream.api/internal/api/proto/app/v1" appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model" "stream.api/internal/database/model"
runtimeservices "stream.api/internal/service/runtime/services" runtimeservices "stream.api/internal/service/runtime/services"
"stream.api/internal/service/video" renderworkflow "stream.api/internal/workflow/render"
) )
func TestListAdminJobsCursorPagination(t *testing.T) { func TestListAdminJobsCursorPagination(t *testing.T) {
@@ -18,7 +18,7 @@ func TestListAdminJobsCursorPagination(t *testing.T) {
ensureTestJobsTable(t, db) ensureTestJobsTable(t, db)
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil)) services.videoWorkflowService = renderworkflow.New(db, runtimeservices.NewJobService(db, nil, nil))
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
baseTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC) baseTime := time.Date(2026, 3, 22, 10, 0, 0, 0, time.UTC)
@@ -67,7 +67,7 @@ func TestListAdminJobsInvalidCursor(t *testing.T) {
ensureTestJobsTable(t, db) ensureTestJobsTable(t, db)
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil)) services.videoWorkflowService = renderworkflow.New(db, runtimeservices.NewJobService(db, nil, nil))
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
conn, cleanup := newTestGRPCServer(t, services) conn, cleanup := newTestGRPCServer(t, services)
@@ -86,7 +86,7 @@ func TestListAdminJobsCursorRejectsAgentMismatch(t *testing.T) {
ensureTestJobsTable(t, db) ensureTestJobsTable(t, db)
services := newTestAppServices(t, db) services := newTestAppServices(t, db)
services.videoService = video.NewService(db, runtimeservices.NewJobService(nil, nil)) services.videoWorkflowService = renderworkflow.New(db, runtimeservices.NewJobService(db, nil, nil))
admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")}) admin := seedTestUser(t, db, model.User{ID: uuid.NewString(), Email: "admin@example.com", Role: ptrString("ADMIN")})
baseTime := time.Date(2026, 3, 22, 11, 0, 0, 0, time.UTC) baseTime := time.Date(2026, 3, 22, 11, 0, 0, 0, time.UTC)

View File

@@ -36,42 +36,12 @@ func (s *appServices) ensurePlanExists(ctx context.Context, planID *string) erro
if trimmed == "" { if trimmed == "" {
return nil return nil
} }
var count int64 if _, err := s.planRepository.GetByID(ctx, trimmed); err != nil {
if err := s.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", trimmed).Count(&count).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) {
return status.Error(codes.InvalidArgument, "Plan not found")
}
return status.Error(codes.Internal, "Failed to validate plan") return status.Error(codes.Internal, "Failed to validate plan")
} }
if count == 0 {
return status.Error(codes.InvalidArgument, "Plan not found")
}
return nil
}
func (s *appServices) saveAdminVideoAdConfig(ctx context.Context, tx *gorm.DB, video *model.Video, userID string, adTemplateID *string) error {
if video == nil || adTemplateID == nil {
return nil
}
trimmed := strings.TrimSpace(*adTemplateID)
if trimmed == "" {
if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", nil).Error; err != nil {
return err
}
video.AdID = nil
return nil
}
var template model.AdTemplate
if err := tx.WithContext(ctx).Select("id").Where("id = ? AND user_id = ?", trimmed, userID).First(&template).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("Ad template not found")
}
return err
}
if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", template.ID).Error; err != nil {
return err
}
video.AdID = &template.ID
return nil return nil
} }
@@ -181,7 +151,7 @@ func (s *appServices) buildAdminUser(ctx context.Context, user *model.User) (*ap
} }
payload.VideoCount = videoCount payload.VideoCount = videoCount
walletBalance, err := model.GetWalletBalance(ctx, s.db, user.ID) walletBalance, err := s.billingRepository.GetWalletBalance(ctx, user.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -338,30 +308,26 @@ func (s *appServices) buildAdminPayment(ctx context.Context, payment *model.Paym
} }
func (s *appServices) loadAdminUserVideoCount(ctx context.Context, userID string) (int64, error) { func (s *appServices) loadAdminUserVideoCount(ctx context.Context, userID string) (int64, error) {
var videoCount int64 return s.videoRepository.CountByUser(ctx, userID)
if err := s.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", userID).Count(&videoCount).Error; err != nil {
return 0, err
}
return videoCount, nil
} }
func (s *appServices) loadAdminUserEmail(ctx context.Context, userID string) (*string, error) { func (s *appServices) loadAdminUserEmail(ctx context.Context, userID string) (*string, error) {
var user model.User email, err := s.userRepository.GetEmailByID(ctx, userID)
if err := s.db.WithContext(ctx).Select("id, email").Where("id = ?", userID).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil return nil, nil
} }
return nil, err return nil, err
} }
return nullableTrimmedString(&user.Email), nil return nullableTrimmedString(email), nil
} }
func (s *appServices) loadReferralUserSummary(ctx context.Context, userID string) (*appv1.ReferralUserSummary, error) { func (s *appServices) loadReferralUserSummary(ctx context.Context, userID string) (*appv1.ReferralUserSummary, error) {
if strings.TrimSpace(userID) == "" { if strings.TrimSpace(userID) == "" {
return nil, nil return nil, nil
} }
var user model.User user, err := s.userRepository.GetReferralSummaryByID(ctx, userID)
if err := s.db.WithContext(ctx).Select("id, email, username").Where("id = ?", userID).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil return nil, nil
} }
@@ -378,8 +344,8 @@ func (s *appServices) loadAdminPlanName(ctx context.Context, planID *string) (*s
if planID == nil || strings.TrimSpace(*planID) == "" { if planID == nil || strings.TrimSpace(*planID) == "" {
return nil, nil return nil, nil
} }
var plan model.Plan plan, err := s.planRepository.GetByID(ctx, *planID)
if err := s.db.WithContext(ctx).Select("id, name").Where("id = ?", *planID).First(&plan).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil return nil, nil
} }
@@ -404,8 +370,8 @@ func (s *appServices) loadAdminVideoAdTemplateDetails(ctx context.Context, video
} }
func (s *appServices) loadAdminAdTemplateName(ctx context.Context, adTemplateID string) (*string, error) { func (s *appServices) loadAdminAdTemplateName(ctx context.Context, adTemplateID string) (*string, error) {
var template model.AdTemplate template, err := s.adTemplateRepository.GetByID(ctx, adTemplateID)
if err := s.db.WithContext(ctx).Select("id, name").Where("id = ?", adTemplateID).First(&template).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil return nil, nil
} }
@@ -420,11 +386,8 @@ func (s *appServices) loadLatestVideoJobID(ctx context.Context, videoID string)
return nil, nil return nil, nil
} }
var job model.Job job, err := s.jobRepository.GetLatestByVideoID(ctx, videoID)
if err := s.db.WithContext(ctx). if err != nil {
Where("config::jsonb ->> 'video_id' = ?", videoID).
Order("created_at DESC").
First(&job).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil return nil, nil
} }
@@ -434,8 +397,8 @@ func (s *appServices) loadLatestVideoJobID(ctx context.Context, videoID string)
} }
func (s *appServices) loadAdminPaymentSubscriptionDetails(ctx context.Context, paymentID string) (*int32, *string, *string, *float64, *float64, error) { func (s *appServices) loadAdminPaymentSubscriptionDetails(ctx context.Context, paymentID string) (*int32, *string, *string, *float64, *float64, error) {
var subscription model.PlanSubscription subscription, err := s.paymentRepository.GetSubscriptionByPaymentID(ctx, paymentID)
if err := s.db.WithContext(ctx).Where("payment_id = ?", paymentID).Order("created_at DESC").First(&subscription).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil, nil, nil, nil, nil return nil, nil, nil, nil, nil, nil
} }
@@ -450,18 +413,18 @@ func (s *appServices) loadAdminPaymentSubscriptionDetails(ctx context.Context, p
} }
func (s *appServices) loadAdminPlanUsageCounts(ctx context.Context, planID string) (int64, int64, int64, error) { func (s *appServices) loadAdminPlanUsageCounts(ctx context.Context, planID string) (int64, int64, int64, error) {
var userCount int64 userCount, err := s.userRepository.CountByPlanID(ctx, planID)
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("plan_id = ?", planID).Count(&userCount).Error; err != nil { if err != nil {
return 0, 0, 0, err return 0, 0, 0, err
} }
var paymentCount int64 paymentCount, err := s.paymentRepository.CountByPlanID(ctx, planID)
if err := s.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", planID).Count(&paymentCount).Error; err != nil { if err != nil {
return 0, 0, 0, err return 0, 0, 0, err
} }
var subscriptionCount int64 subscriptionCount, err := s.planRepository.CountSubscriptionsByPlan(ctx, planID)
if err := s.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", planID).Count(&subscriptionCount).Error; err != nil { if err != nil {
return 0, 0, 0, err return 0, 0, 0, err
} }
@@ -511,22 +474,6 @@ func validateAdminPlayerConfigInput(userID, name string) string {
return "" return ""
} }
func (s *appServices) unsetAdminDefaultTemplates(ctx context.Context, tx *gorm.DB, userID, excludeID string) error {
query := tx.WithContext(ctx).Model(&model.AdTemplate{}).Where("user_id = ?", userID)
if excludeID != "" {
query = query.Where("id <> ?", excludeID)
}
return query.Update("is_default", false).Error
}
func (s *appServices) unsetAdminDefaultPlayerConfigs(ctx context.Context, tx *gorm.DB, userID, excludeID string) error {
query := tx.WithContext(ctx).Model(&model.PlayerConfig{}).Where("user_id = ?", userID)
if excludeID != "" {
query = query.Where("id <> ?", excludeID)
}
return query.Update("is_default", false).Error
}
func (s *appServices) buildAdminPlan(ctx context.Context, plan *model.Plan) (*appv1.AdminPlan, error) { func (s *appServices) buildAdminPlan(ctx context.Context, plan *model.Plan) (*appv1.AdminPlan, error) {
if plan == nil { if plan == nil {
return nil, nil return nil, nil

View File

@@ -0,0 +1,103 @@
package service
import appv1 "stream.api/internal/api/proto/app/v1"
import "stream.api/internal/database/model"
func toProtoDomain(item *model.Domain) *appv1.Domain {
if item == nil {
return nil
}
return &appv1.Domain{
Id: item.ID,
Name: item.Name,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(item.UpdatedAt),
}
}
func toProtoAdTemplate(item *model.AdTemplate) *appv1.AdTemplate {
if item == nil {
return nil
}
return &appv1.AdTemplate{
Id: item.ID,
Name: item.Name,
Description: item.Description,
VastTagUrl: item.VastTagURL,
AdFormat: model.StringValue(item.AdFormat),
Duration: int64PtrToInt32Ptr(item.Duration),
IsActive: boolValue(item.IsActive),
IsDefault: item.IsDefault,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(item.UpdatedAt),
}
}
func toProtoPlayerConfig(item *model.PlayerConfig) *appv1.PlayerConfig {
if item == nil {
return nil
}
return &appv1.PlayerConfig{
Id: item.ID,
Name: item.Name,
Description: item.Description,
Autoplay: item.Autoplay,
Loop: item.Loop,
Muted: item.Muted,
ShowControls: boolValue(item.ShowControls),
Pip: boolValue(item.Pip),
Airplay: boolValue(item.Airplay),
Chromecast: boolValue(item.Chromecast),
IsActive: boolValue(item.IsActive),
IsDefault: item.IsDefault,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(&item.UpdatedAt),
EncrytionM3U8: boolValue(item.EncrytionM3u8),
LogoUrl: nullableTrimmedString(item.LogoURL),
}
}
func toProtoAdminPlayerConfig(item *model.PlayerConfig, ownerEmail *string) *appv1.AdminPlayerConfig {
if item == nil {
return nil
}
return &appv1.AdminPlayerConfig{
Id: item.ID,
UserId: item.UserID,
Name: item.Name,
Description: item.Description,
Autoplay: item.Autoplay,
Loop: item.Loop,
Muted: item.Muted,
ShowControls: boolValue(item.ShowControls),
Pip: boolValue(item.Pip),
Airplay: boolValue(item.Airplay),
Chromecast: boolValue(item.Chromecast),
IsActive: boolValue(item.IsActive),
IsDefault: item.IsDefault,
OwnerEmail: ownerEmail,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(&item.UpdatedAt),
EncrytionM3U8: boolValue(item.EncrytionM3u8),
LogoUrl: nullableTrimmedString(item.LogoURL),
}
}
func toProtoPlan(item *model.Plan) *appv1.Plan {
if item == nil {
return nil
}
return &appv1.Plan{
Id: item.ID,
Name: item.Name,
Description: item.Description,
Price: item.Price,
Cycle: item.Cycle,
StorageLimit: item.StorageLimit,
UploadLimit: item.UploadLimit,
DurationLimit: item.DurationLimit,
QualityLimit: item.QualityLimit,
Features: item.Features,
IsActive: boolValue(item.IsActive),
}
}

View File

@@ -0,0 +1,29 @@
package service
import "strings"
func normalizeDomain(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
normalized = strings.TrimPrefix(normalized, "https://")
normalized = strings.TrimPrefix(normalized, "http://")
normalized = strings.TrimPrefix(normalized, "www.")
normalized = strings.TrimSuffix(normalized, "/")
return normalized
}
func normalizeAdFormat(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "mid-roll", "post-roll":
return strings.TrimSpace(strings.ToLower(value))
default:
return "pre-roll"
}
}
func adTemplateIsActive(value *bool) bool {
return value == nil || *value
}
func playerConfigIsActive(value *bool) bool {
return value == nil || *value
}

View File

@@ -0,0 +1,191 @@
package service
import (
"context"
"time"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/dto"
"stream.api/internal/repository"
renderworkflow "stream.api/internal/workflow/render"
)
type PaymentHistoryRow = repository.PaymentHistoryRow
type CreateVideoInput = renderworkflow.CreateVideoInput
type CreateVideoResult = renderworkflow.CreateVideoResult
var (
ErrUserNotFound = renderworkflow.ErrUserNotFound
ErrAdTemplateNotFound = renderworkflow.ErrAdTemplateNotFound
ErrJobServiceUnavailable = renderworkflow.ErrJobServiceUnavailable
)
type PaymentRepository interface {
ListHistoryByUser(ctx context.Context, userID string, subscriptionKind string, walletTopupKind string, topupType string, limit int32, offset int) ([]PaymentHistoryRow, int64, error)
ListForAdmin(ctx context.Context, userID string, status string, limit int32, offset int) ([]model.Payment, int64, error)
CountAll(ctx context.Context) (int64, error)
SumSuccessfulAmount(ctx context.Context) (float64, error)
GetByIDAndUser(ctx context.Context, paymentID string, userID string) (*model.Payment, error)
GetByID(ctx context.Context, paymentID string) (*model.Payment, error)
GetStandaloneTopupByIDAndUser(ctx context.Context, id string, userID string, topupType string) (*model.WalletTransaction, error)
GetSubscriptionByPaymentID(ctx context.Context, paymentID string) (*model.PlanSubscription, error)
CountByPlanID(ctx context.Context, planID string) (int64, error)
CreatePayment(ctx context.Context, payment *model.Payment) error
Save(ctx context.Context, payment *model.Payment) error
CreatePaymentTx(tx *gorm.DB, ctx context.Context, payment *model.Payment) error
CreateWalletTransactionTx(tx *gorm.DB, ctx context.Context, txRecord *model.WalletTransaction) error
CreatePlanSubscriptionTx(tx *gorm.DB, ctx context.Context, subscription *model.PlanSubscription) error
UpdateUserPlanID(ctx context.Context, userID string, planID string) error
UpdateUserPlanIDTx(tx *gorm.DB, ctx context.Context, userID string, planID string) error
CreateNotificationTx(tx *gorm.DB, ctx context.Context, notification *model.Notification) error
CreateWalletTopupAndNotification(ctx context.Context, userID string, transaction *model.WalletTransaction, notification *model.Notification) error
ExecuteSubscriptionPayment(ctx context.Context, userID string, plan *model.Plan, termMonths int32, paymentMethod string, paymentRecord *model.Payment, invoiceID string, now time.Time, validateFunding func(currentWalletBalance float64) (float64, error)) (*model.PlanSubscription, float64, error)
}
type AccountRepository interface {
DeleteUserAccount(ctx context.Context, userID string) error
ClearUserData(ctx context.Context, userID string) error
}
type NotificationRepository interface {
ListByUser(ctx context.Context, userID string) ([]model.Notification, error)
MarkReadByIDAndUser(ctx context.Context, id string, userID string) (int64, error)
MarkAllReadByUser(ctx context.Context, userID string) error
DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error)
DeleteAllByUser(ctx context.Context, userID string) error
}
type DomainRepository interface {
ListByUser(ctx context.Context, userID string) ([]model.Domain, error)
CountByUserAndName(ctx context.Context, userID string, name string) (int64, error)
Create(ctx context.Context, item *model.Domain) error
DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error)
}
type AdTemplateRepository interface {
ListByUser(ctx context.Context, userID string) ([]model.AdTemplate, error)
ListForAdmin(ctx context.Context, search string, userID string, limit int32, offset int) ([]model.AdTemplate, int64, error)
CountAll(ctx context.Context) (int64, error)
GetByID(ctx context.Context, id string) (*model.AdTemplate, error)
GetByIDAndUser(ctx context.Context, id string, userID string) (*model.AdTemplate, error)
ExistsByIDAndUser(ctx context.Context, id string, userID string) (bool, error)
CreateWithDefault(ctx context.Context, userID string, item *model.AdTemplate) error
SaveWithDefault(ctx context.Context, userID string, item *model.AdTemplate) error
DeleteByIDAndUserAndClearVideos(ctx context.Context, id string, userID string) error
DeleteByIDAndClearVideos(ctx context.Context, id string) error
}
type PlayerConfigRepository interface {
ListByUser(ctx context.Context, userID string) ([]model.PlayerConfig, error)
ListForAdmin(ctx context.Context, search string, userID string, limit int32, offset int) ([]model.PlayerConfig, int64, error)
CountByUser(ctx context.Context, userID string) (int64, error)
CountByUserTx(tx *gorm.DB, ctx context.Context, userID string) (int64, error)
Create(ctx context.Context, item *model.PlayerConfig) error
CreateWithDefault(ctx context.Context, userID string, item *model.PlayerConfig) error
CreateTx(tx *gorm.DB, ctx context.Context, item *model.PlayerConfig) error
GetByID(ctx context.Context, id string) (*model.PlayerConfig, error)
GetByIDAndUser(ctx context.Context, id string, userID string) (*model.PlayerConfig, error)
GetByIDAndUserTx(tx *gorm.DB, ctx context.Context, id string, userID string) (*model.PlayerConfig, error)
Save(ctx context.Context, item *model.PlayerConfig) error
SaveWithDefault(ctx context.Context, userID string, item *model.PlayerConfig) error
SaveTx(tx *gorm.DB, ctx context.Context, item *model.PlayerConfig) error
DeleteByID(ctx context.Context, id string) (int64, error)
DeleteByIDAndUser(ctx context.Context, id string, userID string) (int64, error)
DeleteByIDAndUserTx(tx *gorm.DB, ctx context.Context, id string, userID string) (int64, error)
UnsetDefaultForUser(ctx context.Context, userID string, excludeID string) error
UnsetDefaultForUserTx(tx *gorm.DB, userID string, excludeID string) error
CreateManaged(ctx context.Context, userID string, item *model.PlayerConfig, validate func(*model.User, int64) error) error
DeleteManaged(ctx context.Context, userID string, id string, validate func(*model.User, int64) error) error
UpdateManaged(ctx context.Context, userID string, id string, mutateAndValidate func(*model.PlayerConfig, *model.User, int64) error) (*model.PlayerConfig, error)
}
type VideoWorkflow interface {
CreateVideo(ctx context.Context, input CreateVideoInput) (*CreateVideoResult, error)
ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error)
ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error)
ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error)
GetJob(ctx context.Context, id string) (*model.Job, error)
CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error)
CancelJob(ctx context.Context, id string) error
RetryJob(ctx context.Context, id string) (*model.Job, error)
}
type VideoRepository interface {
ListByUser(ctx context.Context, userID string, search string, status string, offset int, limit int) ([]model.Video, int64, error)
ListForAdmin(ctx context.Context, search string, userID string, status string, offset int, limit int) ([]model.Video, int64, error)
CountAll(ctx context.Context) (int64, error)
CountCreatedSince(ctx context.Context, since time.Time) (int64, error)
IncrementViews(ctx context.Context, videoID string, userID string) error
GetByID(ctx context.Context, videoID string) (*model.Video, error)
GetByIDAndUser(ctx context.Context, videoID string, userID string) (*model.Video, error)
UpdateByIDAndUser(ctx context.Context, videoID string, userID string, updates map[string]any) (int64, error)
CountByUser(ctx context.Context, userID string) (int64, error)
DeleteByIDAndUserWithStorageUpdate(ctx context.Context, videoID string, userID string, videoSize int64) error
DeleteByIDWithStorageUpdate(ctx context.Context, videoID string, userID string, videoSize int64) error
UpdateAdminVideo(ctx context.Context, video *model.Video, oldUserID string, oldSize int64, adTemplateID *string) error
}
type VideoWorkflowRepository interface {
CreateVideoWithStorageAndAd(ctx context.Context, video *model.Video, userID string, adTemplateID *string) error
GetUserByID(ctx context.Context, userID string) (*model.User, error)
MarkVideoJobFailed(ctx context.Context, videoID string) error
}
type UserRepository interface {
GetByEmail(ctx context.Context, email string) (*model.User, error)
CountByEmail(ctx context.Context, email string) (int64, error)
GetByID(ctx context.Context, userID string) (*model.User, error)
LockByIDTx(tx *gorm.DB, ctx context.Context, userID string) (*model.User, error)
ListForAdmin(ctx context.Context, search string, role string, limit int32, offset int) ([]model.User, int64, error)
CountAll(ctx context.Context) (int64, error)
CountCreatedSince(ctx context.Context, since time.Time) (int64, error)
SumStorageUsed(ctx context.Context) (int64, error)
GetEmailByID(ctx context.Context, userID string) (*string, error)
GetReferralSummaryByID(ctx context.Context, userID string) (*model.User, error)
CountByPlanID(ctx context.Context, planID string) (int64, error)
Create(ctx context.Context, user *model.User) error
UpdateFieldsByID(ctx context.Context, userID string, updates map[string]any) error
UpdateFieldsByIDTx(tx *gorm.DB, ctx context.Context, userID string, updates map[string]any) error
UpdatePassword(ctx context.Context, userID string, passwordHash string) error
FindByReferralUsername(ctx context.Context, username string, limit int) ([]model.User, error)
CountSubscriptionsByUser(ctx context.Context, userID string) (int64, error)
}
type UserPreferenceRepository interface {
FindOrCreateByUserID(ctx context.Context, userID string) (*model.UserPreference, error)
Save(ctx context.Context, pref *model.UserPreference) error
}
type BillingRepository interface {
GetWalletBalance(ctx context.Context, userID string) (float64, error)
GetWalletBalanceTx(tx *gorm.DB, ctx context.Context, userID string) (float64, error)
GetLatestPlanSubscription(ctx context.Context, userID string) (*model.PlanSubscription, error)
GetLatestPlanSubscriptionTx(tx *gorm.DB, ctx context.Context, userID string) (*model.PlanSubscription, error)
CountActiveSubscriptions(ctx context.Context, now time.Time) (int64, error)
}
type PlanRepository interface {
GetByID(ctx context.Context, planID string) (*model.Plan, error)
ListActive(ctx context.Context) ([]model.Plan, error)
ListAll(ctx context.Context) ([]model.Plan, error)
Create(ctx context.Context, plan *model.Plan) error
Save(ctx context.Context, plan *model.Plan) error
CountPaymentsByPlan(ctx context.Context, planID string) (int64, error)
CountSubscriptionsByPlan(ctx context.Context, planID string) (int64, error)
SetActive(ctx context.Context, planID string, isActive bool) error
DeleteByID(ctx context.Context, planID string) error
}
type JobRepository interface {
Create(ctx context.Context, job *model.Job) error
GetByID(ctx context.Context, id string) (*model.Job, error)
GetLatestByVideoID(ctx context.Context, videoID string) (*model.Job, error)
ListByCursor(ctx context.Context, agentID string, cursorTime time.Time, cursorID string, limit int) ([]*model.Job, bool, error)
ListByOffset(ctx context.Context, agentID string, offset int, limit int) ([]*model.Job, int64, error)
Save(ctx context.Context, job *model.Job) error
UpdateVideoStatus(ctx context.Context, videoID string, statusValue string, processingStatus string) error
}
type AgentRuntime interface {
ListAgentsWithStats() []*dto.AgentWithStats
SendCommand(agentID string, cmd string) bool
}

View File

@@ -0,0 +1,18 @@
package service
import (
"crypto/rand"
"encoding/base64"
)
func generateOAuthState() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func googleOAuthStateCacheKey(state string) string {
return "google_oauth_state:" + state
}

View File

@@ -15,7 +15,6 @@ import (
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
appv1 "stream.api/internal/api/proto/app/v1" appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model" "stream.api/internal/database/model"
) )
@@ -33,9 +32,9 @@ func statusErrorWithBody(ctx context.Context, grpcCode codes.Code, httpCode int,
return status.Error(grpcCode, message) return status.Error(grpcCode, message)
} }
func (s *appServices) loadPaymentPlanForUser(ctx context.Context, planID string) (*model.Plan, error) { func (s *paymentsAppService) loadPaymentPlanForUser(ctx context.Context, planID string) (*model.Plan, error) {
var planRecord model.Plan planRecord, err := s.planRepository.GetByID(ctx, planID)
if err := s.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Plan not found") return nil, status.Error(codes.NotFound, "Plan not found")
} }
@@ -45,12 +44,12 @@ func (s *appServices) loadPaymentPlanForUser(ctx context.Context, planID string)
if planRecord.IsActive == nil || !*planRecord.IsActive { if planRecord.IsActive == nil || !*planRecord.IsActive {
return nil, status.Error(codes.InvalidArgument, "Plan is not active") return nil, status.Error(codes.InvalidArgument, "Plan is not active")
} }
return &planRecord, nil return planRecord, nil
} }
func (s *appServices) loadPaymentPlanForAdmin(ctx context.Context, planID string) (*model.Plan, error) { func (s *appServices) loadPaymentPlanForAdmin(ctx context.Context, planID string) (*model.Plan, error) {
var planRecord model.Plan planRecord, err := s.planRepository.GetByID(ctx, planID)
if err := s.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "Plan not found") return nil, status.Error(codes.InvalidArgument, "Plan not found")
} }
@@ -59,18 +58,18 @@ func (s *appServices) loadPaymentPlanForAdmin(ctx context.Context, planID string
if planRecord.IsActive == nil || !*planRecord.IsActive { if planRecord.IsActive == nil || !*planRecord.IsActive {
return nil, status.Error(codes.InvalidArgument, "Plan is not active") return nil, status.Error(codes.InvalidArgument, "Plan is not active")
} }
return &planRecord, nil return planRecord, nil
} }
func (s *appServices) loadPaymentUserForAdmin(ctx context.Context, userID string) (*model.User, error) { func (s *appServices) loadPaymentUserForAdmin(ctx context.Context, userID string) (*model.User, error) {
var user model.User user, err := s.userRepository.GetByID(ctx, userID)
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found") return nil, status.Error(codes.InvalidArgument, "User not found")
} }
return nil, status.Error(codes.Internal, "Failed to create payment") return nil, status.Error(codes.Internal, "Failed to create payment")
} }
return &user, nil return user, nil
} }
func (s *appServices) executePaymentFlow(ctx context.Context, input paymentExecutionInput) (*paymentExecutionResult, error) { func (s *appServices) executePaymentFlow(ctx context.Context, input paymentExecutionInput) (*paymentExecutionResult, error) {
@@ -101,69 +100,27 @@ func (s *appServices) executePaymentFlow(ctx context.Context, input paymentExecu
InvoiceID: invoiceID, InvoiceID: invoiceID,
} }
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { subscription, walletBalance, err := s.paymentRepository.ExecuteSubscriptionPayment(
if _, err := lockUserForUpdate(ctx, tx, input.UserID); err != nil { ctx,
return err input.UserID,
} input.Plan,
input.TermMonths,
newExpiry, err := loadPaymentExpiry(ctx, tx, input.UserID, input.TermMonths, now) input.PaymentMethod,
if err != nil { paymentRecord,
return err invoiceID,
} now,
currentWalletBalance, err := model.GetWalletBalance(ctx, tx, input.UserID) func(currentWalletBalance float64) (float64, error) {
if err != nil { return validatePaymentFunding(ctx, input, totalAmount, currentWalletBalance)
return err },
} )
validatedTopupAmount, err := validatePaymentFunding(ctx, input, totalAmount, currentWalletBalance)
if err != nil {
return err
}
if err := tx.Create(paymentRecord).Error; err != nil {
return err
}
if err := createPaymentWalletTransactions(tx, input, paymentRecord, totalAmount, validatedTopupAmount, currency); err != nil {
return err
}
subscription := buildPaymentSubscription(input, paymentRecord, totalAmount, validatedTopupAmount, now, newExpiry)
if err := tx.Create(subscription).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).Where("id = ?", input.UserID).Update("plan_id", input.Plan.ID).Error; err != nil {
return err
}
notification := buildSubscriptionNotification(input.UserID, paymentRecord.ID, invoiceID, input.Plan, subscription)
if err := tx.Create(notification).Error; err != nil {
return err
}
if _, err := s.maybeGrantReferralReward(ctx, tx, input, paymentRecord, subscription); err != nil {
return err
}
walletBalance, err := model.GetWalletBalance(ctx, tx, input.UserID)
if err != nil {
return err
}
result.Subscription = subscription
result.WalletBalance = walletBalance
return nil
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
result.Subscription = subscription
result.WalletBalance = walletBalance
return result, nil return result, nil
} }
func loadPaymentExpiry(ctx context.Context, tx *gorm.DB, userID string, termMonths int32, now time.Time) (time.Time, error) {
currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, userID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return time.Time{}, err
}
baseExpiry := now
if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) {
baseExpiry = currentSubscription.ExpiresAt.UTC()
}
return baseExpiry.AddDate(0, int(termMonths), 0), nil
}
func validatePaymentFunding(ctx context.Context, input paymentExecutionInput, totalAmount, currentWalletBalance float64) (float64, error) { func validatePaymentFunding(ctx context.Context, input paymentExecutionInput, totalAmount, currentWalletBalance float64) (float64, error) {
shortfall := maxFloat(totalAmount-currentWalletBalance, 0) shortfall := maxFloat(totalAmount-currentWalletBalance, 0)
if input.PaymentMethod == paymentMethodWallet && shortfall > 0 { if input.PaymentMethod == paymentMethodWallet && shortfall > 0 {
@@ -206,73 +163,7 @@ func validatePaymentFunding(ctx context.Context, input paymentExecutionInput, to
return topupAmount, nil return topupAmount, nil
} }
func createPaymentWalletTransactions(tx *gorm.DB, input paymentExecutionInput, paymentRecord *model.Payment, totalAmount, topupAmount float64, currency string) error { func (s *paymentsAppService) buildPaymentInvoice(ctx context.Context, paymentRecord *model.Payment) (string, string, error) {
if input.PaymentMethod == paymentMethodTopup {
topupTransaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: input.UserID,
Type: walletTransactionTypeTopup,
Amount: topupAmount,
Currency: model.StringPtr(currency),
Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", input.Plan.Name, input.TermMonths)),
PaymentID: &paymentRecord.ID,
PlanID: &input.Plan.ID,
TermMonths: int32Ptr(input.TermMonths),
}
if err := tx.Create(topupTransaction).Error; err != nil {
return err
}
}
debitTransaction := &model.WalletTransaction{
ID: uuid.New().String(),
UserID: input.UserID,
Type: walletTransactionTypeSubscriptionDebit,
Amount: -totalAmount,
Currency: model.StringPtr(currency),
Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", input.Plan.Name, input.TermMonths)),
PaymentID: &paymentRecord.ID,
PlanID: &input.Plan.ID,
TermMonths: int32Ptr(input.TermMonths),
}
return tx.Create(debitTransaction).Error
}
func buildPaymentSubscription(input paymentExecutionInput, paymentRecord *model.Payment, totalAmount, topupAmount float64, now, newExpiry time.Time) *model.PlanSubscription {
return &model.PlanSubscription{
ID: uuid.New().String(),
UserID: input.UserID,
PaymentID: paymentRecord.ID,
PlanID: input.Plan.ID,
TermMonths: input.TermMonths,
PaymentMethod: input.PaymentMethod,
WalletAmount: totalAmount,
TopupAmount: topupAmount,
StartedAt: now,
ExpiresAt: newExpiry,
}
}
func buildSubscriptionNotification(userID, paymentID, invoiceID string, planRecord *model.Plan, subscription *model.PlanSubscription) *model.Notification {
return &model.Notification{
ID: uuid.New().String(),
UserID: userID,
Type: "billing.subscription",
Title: "Subscription activated",
Message: fmt.Sprintf("Your subscription to %s is active until %s.", planRecord.Name, subscription.ExpiresAt.UTC().Format("2006-01-02")),
Metadata: model.StringPtr(mustMarshalJSON(map[string]any{
"payment_id": paymentID,
"invoice_id": invoiceID,
"plan_id": planRecord.ID,
"term_months": subscription.TermMonths,
"payment_method": subscription.PaymentMethod,
"wallet_amount": subscription.WalletAmount,
"topup_amount": subscription.TopupAmount,
"plan_expires_at": subscription.ExpiresAt.UTC().Format(time.RFC3339),
})),
}
}
func (s *appServices) buildPaymentInvoice(ctx context.Context, paymentRecord *model.Payment) (string, string, error) {
details, err := s.loadPaymentInvoiceDetails(ctx, paymentRecord) details, err := s.loadPaymentInvoiceDetails(ctx, paymentRecord)
if err != nil { if err != nil {
return "", "", err return "", "", err
@@ -324,15 +215,15 @@ func buildTopupInvoice(transaction *model.WalletTransaction) string {
}, "\n") }, "\n")
} }
func (s *appServices) loadPaymentInvoiceDetails(ctx context.Context, paymentRecord *model.Payment) (*paymentInvoiceDetails, error) { func (s *paymentsAppService) loadPaymentInvoiceDetails(ctx context.Context, paymentRecord *model.Payment) (*paymentInvoiceDetails, error) {
details := &paymentInvoiceDetails{ details := &paymentInvoiceDetails{
PlanName: "Unknown plan", PlanName: "Unknown plan",
PaymentMethod: paymentMethodWallet, PaymentMethod: paymentMethodWallet,
} }
if paymentRecord.PlanID != nil && strings.TrimSpace(*paymentRecord.PlanID) != "" { if paymentRecord.PlanID != nil && strings.TrimSpace(*paymentRecord.PlanID) != "" {
var planRecord model.Plan planRecord, err := s.planRepository.GetByID(ctx, *paymentRecord.PlanID)
if err := s.db.WithContext(ctx).Where("id = ?", *paymentRecord.PlanID).First(&planRecord).Error; err != nil { if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err return nil, err
} }
@@ -341,11 +232,8 @@ func (s *appServices) loadPaymentInvoiceDetails(ctx context.Context, paymentReco
} }
} }
var subscription model.PlanSubscription subscription, err := s.paymentRepository.GetSubscriptionByPaymentID(ctx, paymentRecord.ID)
if err := s.db.WithContext(ctx). if err != nil {
Where("payment_id = ?", paymentRecord.ID).
Order("created_at DESC").
First(&subscription).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err return nil, err
} }
@@ -369,27 +257,6 @@ func isAllowedTermMonths(value int32) bool {
return ok return ok
} }
func lockUserForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.User, error) {
if tx.Dialector.Name() == "sqlite" {
res := tx.WithContext(ctx).Exec("UPDATE user SET id = id WHERE id = ?", userID)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, gorm.ErrRecordNotFound
}
}
var user model.User
if err := tx.WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", userID).
First(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
func maxFloat(left, right float64) float64 { func maxFloat(left, right float64) float64 {
if left > right { if left > right {
return left return left

View File

@@ -0,0 +1,128 @@
package service
import (
"fmt"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
)
func toProtoPayment(item *model.Payment) *appv1.Payment {
if item == nil {
return nil
}
return &appv1.Payment{
Id: item.ID,
UserId: item.UserID,
PlanId: item.PlanID,
Amount: item.Amount,
Currency: normalizeCurrency(item.Currency),
Status: normalizePaymentStatus(item.Status),
Provider: strings.ToUpper(stringValue(item.Provider)),
TransactionId: item.TransactionID,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()),
}
}
func toProtoPlanSubscription(item *model.PlanSubscription) *appv1.PlanSubscription {
if item == nil {
return nil
}
return &appv1.PlanSubscription{
Id: item.ID,
UserId: item.UserID,
PaymentId: item.PaymentID,
PlanId: item.PlanID,
TermMonths: item.TermMonths,
PaymentMethod: item.PaymentMethod,
WalletAmount: item.WalletAmount,
TopupAmount: item.TopupAmount,
StartedAt: timestamppb.New(item.StartedAt.UTC()),
ExpiresAt: timestamppb.New(item.ExpiresAt.UTC()),
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(item.UpdatedAt),
}
}
func toProtoWalletTransaction(item *model.WalletTransaction) *appv1.WalletTransaction {
if item == nil {
return nil
}
return &appv1.WalletTransaction{
Id: item.ID,
UserId: item.UserID,
Type: item.Type,
Amount: item.Amount,
Currency: normalizeCurrency(item.Currency),
Note: item.Note,
PaymentId: item.PaymentID,
PlanId: item.PlanID,
TermMonths: item.TermMonths,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(item.UpdatedAt),
}
}
func normalizePaymentStatus(status *string) string {
value := strings.ToLower(strings.TrimSpace(stringValue(status)))
switch value {
case "success", "succeeded", "paid":
return "success"
case "failed", "error", "canceled", "cancelled":
return "failed"
case "pending", "processing":
return "pending"
default:
if value == "" {
return "success"
}
return value
}
}
func normalizeCurrency(currency *string) string {
value := strings.ToUpper(strings.TrimSpace(stringValue(currency)))
if value == "" {
return "USD"
}
return value
}
func normalizePaymentMethod(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case paymentMethodWallet:
return paymentMethodWallet
case paymentMethodTopup:
return paymentMethodTopup
default:
return ""
}
}
func normalizeOptionalPaymentMethod(value *string) *string {
normalized := normalizePaymentMethod(stringValue(value))
if normalized == "" {
return nil
}
return &normalized
}
func buildInvoiceID(id string) string {
trimmed := strings.ReplaceAll(strings.TrimSpace(id), "-", "")
if len(trimmed) > 12 {
trimmed = trimmed[:12]
}
return "INV-" + strings.ToUpper(trimmed)
}
func buildTransactionID(prefix string) string {
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
}
func buildInvoiceFilename(id string) string {
return fmt.Sprintf("invoice-%s.txt", id)
}

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"strings" "strings"
"gorm.io/gorm"
"stream.api/internal/database/model" "stream.api/internal/database/model"
"stream.api/pkg/logger" "stream.api/pkg/logger"
) )
@@ -18,12 +17,12 @@ type updatePreferencesInput struct {
Locale *string Locale *string
} }
func loadUserPreferences(ctx context.Context, db *gorm.DB, userID string) (*model.UserPreference, error) { func loadUserPreferences(ctx context.Context, prefRepo UserPreferenceRepository, userID string) (*model.UserPreference, error) {
return model.FindOrCreateUserPreference(ctx, db, userID) return prefRepo.FindOrCreateByUserID(ctx, userID)
} }
func updateUserPreferences(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req updatePreferencesInput) (*model.UserPreference, error) { func updateUserPreferences(ctx context.Context, prefRepo UserPreferenceRepository, l logger.Logger, userID string, req updatePreferencesInput) (*model.UserPreference, error) {
pref, err := model.FindOrCreateUserPreference(ctx, db, userID) pref, err := prefRepo.FindOrCreateByUserID(ctx, userID)
if err != nil { if err != nil {
l.Error("Failed to load preferences", "error", err) l.Error("Failed to load preferences", "error", err)
return nil, err return nil, err
@@ -54,7 +53,7 @@ func updateUserPreferences(ctx context.Context, db *gorm.DB, l logger.Logger, us
pref.Locale = model.StringPtr(model.StringValue(pref.Language)) pref.Locale = model.StringPtr(model.StringValue(pref.Language))
} }
if err := db.WithContext(ctx).Save(pref).Error; err != nil { if err := prefRepo.Save(ctx, pref); err != nil {
l.Error("Failed to save preferences", "error", err) l.Error("Failed to save preferences", "error", err)
return nil, err return nil, err
} }

View File

@@ -7,7 +7,6 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"stream.api/internal/database/model" "stream.api/internal/database/model"
"stream.api/internal/database/query"
"stream.api/pkg/logger" "stream.api/pkg/logger"
) )
@@ -23,7 +22,7 @@ type updateProfileInput struct {
Locale *string Locale *string
} }
func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID string, req updateProfileInput) (*model.User, error) { func updateUserProfile(ctx context.Context, userRepo UserRepository, prefRepo UserPreferenceRepository, l logger.Logger, userID string, req updateProfileInput) (*model.User, error) {
updates := map[string]any{} updates := map[string]any{}
if req.Username != nil { if req.Username != nil {
username := strings.TrimSpace(*req.Username) username := strings.TrimSpace(*req.Username)
@@ -38,7 +37,7 @@ func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID
} }
if len(updates) > 0 { if len(updates) > 0 {
if err := db.WithContext(ctx).Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil { if err := userRepo.UpdateFieldsByID(ctx, userID, updates); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, errEmailAlreadyRegistered return nil, errEmailAlreadyRegistered
} }
@@ -47,7 +46,7 @@ func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID
} }
} }
pref, err := model.FindOrCreateUserPreference(ctx, db, userID) pref, err := prefRepo.FindOrCreateByUserID(ctx, userID)
if err != nil { if err != nil {
l.Error("Failed to load user preference", "error", err) l.Error("Failed to load user preference", "error", err)
return nil, err return nil, err
@@ -71,17 +70,11 @@ func updateUserProfile(ctx context.Context, db *gorm.DB, l logger.Logger, userID
prefChanged = true prefChanged = true
} }
if prefChanged { if prefChanged {
if err := db.WithContext(ctx).Save(pref).Error; err != nil { if err := prefRepo.Save(ctx, pref); err != nil {
l.Error("Failed to save user preference", "error", err) l.Error("Failed to save user preference", "error", err)
return nil, err return nil, err
} }
} }
u := query.User return userRepo.GetByID(ctx, userID)
user, err := u.WithContext(ctx).Where(u.ID.Eq(userID)).First()
if err != nil {
return nil, err
}
return user, nil
} }

View File

@@ -1,570 +0,0 @@
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"net/url"
"strings"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
"gorm.io/gorm"
appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
)
func ensurePaidPlan(user *model.User) error {
if user == nil {
return status.Error(codes.Unauthenticated, "Unauthorized")
}
if user.PlanID == nil || strings.TrimSpace(*user.PlanID) == "" {
return status.Error(codes.PermissionDenied, adTemplateUpgradeRequiredMessage)
}
return nil
}
func playerConfigActionAllowed(user *model.User, configCount int64, action string) error {
if user == nil {
return status.Error(codes.Unauthenticated, "Unauthorized")
}
if user.PlanID != nil && strings.TrimSpace(*user.PlanID) != "" {
return nil
}
switch action {
case "create":
if configCount > 0 {
return status.Error(codes.FailedPrecondition, playerConfigFreePlanLimitMessage)
}
return nil
case "delete":
return nil
case "update", "set-default", "toggle-active":
if configCount > 1 {
return status.Error(codes.FailedPrecondition, playerConfigFreePlanReconciliationMessage)
}
return nil
default:
return nil
}
}
func safeRole(role *string) string {
if role == nil || strings.TrimSpace(*role) == "" {
return "USER"
}
return *role
}
func generateOAuthState() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(buffer), nil
}
func googleOAuthStateCacheKey(state string) string {
return "google_oauth_state:" + state
}
func stringPointerOrNil(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
func toProtoVideo(item *model.Video, jobID ...string) *appv1.Video {
if item == nil {
return nil
}
statusValue := stringValue(item.Status)
if statusValue == "" {
statusValue = "ready"
}
var linkedJobID *string
if len(jobID) > 0 {
linkedJobID = stringPointerOrNil(jobID[0])
}
return &appv1.Video{
Id: item.ID,
UserId: item.UserID,
Title: item.Title,
Description: item.Description,
Url: item.URL,
Status: strings.ToLower(statusValue),
Size: item.Size,
Duration: item.Duration,
Format: item.Format,
Thumbnail: item.Thumbnail,
ProcessingStatus: item.ProcessingStatus,
StorageType: item.StorageType,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()),
JobId: linkedJobID,
}
}
func (s *appServices) buildVideo(ctx context.Context, video *model.Video) (*appv1.Video, error) {
if video == nil {
return nil, nil
}
jobID, err := s.loadLatestVideoJobID(ctx, video.ID)
if err != nil {
return nil, err
}
if jobID != nil {
return toProtoVideo(video, *jobID), nil
}
return toProtoVideo(video), nil
}
func normalizeVideoStatusValue(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "processing", "pending":
return "processing"
case "failed", "error":
return "failed"
default:
return "ready"
}
}
func detectStorageType(rawURL string) string {
if shouldDeleteStoredObject(rawURL) {
return "S3"
}
return "WORKER"
}
func shouldDeleteStoredObject(rawURL string) bool {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return false
}
parsed, err := url.Parse(trimmed)
if err != nil {
return !strings.HasPrefix(trimmed, "/")
}
return parsed.Scheme == "" && parsed.Host == "" && !strings.HasPrefix(trimmed, "/")
}
func extractObjectKey(rawURL string) string {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil {
return trimmed
}
return strings.TrimPrefix(parsed.Path, "/")
}
func protoUserFromPayload(user *userPayload) *appv1.User {
if user == nil {
return nil
}
return &appv1.User{
Id: user.ID,
Email: user.Email,
Username: user.Username,
Avatar: user.Avatar,
Role: user.Role,
GoogleId: user.GoogleID,
StorageUsed: user.StorageUsed,
PlanId: user.PlanID,
PlanStartedAt: timeToProto(user.PlanStartedAt),
PlanExpiresAt: timeToProto(user.PlanExpiresAt),
PlanTermMonths: user.PlanTermMonths,
PlanPaymentMethod: user.PlanPaymentMethod,
PlanExpiringSoon: user.PlanExpiringSoon,
WalletBalance: user.WalletBalance,
Language: user.Language,
Locale: user.Locale,
CreatedAt: timeToProto(user.CreatedAt),
UpdatedAt: timestamppb.New(user.UpdatedAt),
}
}
func toProtoUser(user *userPayload) *appv1.User {
return protoUserFromPayload(user)
}
func toProtoPreferences(pref *model.UserPreference) *appv1.Preferences {
if pref == nil {
return nil
}
return &appv1.Preferences{
EmailNotifications: boolValue(pref.EmailNotifications),
PushNotifications: boolValue(pref.PushNotifications),
MarketingNotifications: pref.MarketingNotifications,
TelegramNotifications: pref.TelegramNotifications,
Language: model.StringValue(pref.Language),
Locale: model.StringValue(pref.Locale),
}
}
func toProtoNotification(item model.Notification) *appv1.Notification {
return &appv1.Notification{
Id: item.ID,
Type: normalizeNotificationType(item.Type),
Title: item.Title,
Message: item.Message,
Read: item.IsRead,
ActionUrl: item.ActionURL,
ActionLabel: item.ActionLabel,
CreatedAt: timeToProto(item.CreatedAt),
}
}
func toProtoDomain(item *model.Domain) *appv1.Domain {
if item == nil {
return nil
}
return &appv1.Domain{
Id: item.ID,
Name: item.Name,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(item.UpdatedAt),
}
}
func toProtoAdTemplate(item *model.AdTemplate) *appv1.AdTemplate {
if item == nil {
return nil
}
return &appv1.AdTemplate{
Id: item.ID,
Name: item.Name,
Description: item.Description,
VastTagUrl: item.VastTagURL,
AdFormat: model.StringValue(item.AdFormat),
Duration: int64PtrToInt32Ptr(item.Duration),
IsActive: boolValue(item.IsActive),
IsDefault: item.IsDefault,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(item.UpdatedAt),
}
}
func toProtoPlayerConfig(item *model.PlayerConfig) *appv1.PlayerConfig {
if item == nil {
return nil
}
return &appv1.PlayerConfig{
Id: item.ID,
Name: item.Name,
Description: item.Description,
Autoplay: item.Autoplay,
Loop: item.Loop,
Muted: item.Muted,
ShowControls: boolValue(item.ShowControls),
Pip: boolValue(item.Pip),
Airplay: boolValue(item.Airplay),
Chromecast: boolValue(item.Chromecast),
IsActive: boolValue(item.IsActive),
IsDefault: item.IsDefault,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(&item.UpdatedAt),
EncrytionM3U8: boolValue(item.EncrytionM3u8),
LogoUrl: nullableTrimmedString(item.LogoURL),
}
}
func toProtoAdminPlayerConfig(item *model.PlayerConfig, ownerEmail *string) *appv1.AdminPlayerConfig {
if item == nil {
return nil
}
return &appv1.AdminPlayerConfig{
Id: item.ID,
UserId: item.UserID,
Name: item.Name,
Description: item.Description,
Autoplay: item.Autoplay,
Loop: item.Loop,
Muted: item.Muted,
ShowControls: boolValue(item.ShowControls),
Pip: boolValue(item.Pip),
Airplay: boolValue(item.Airplay),
Chromecast: boolValue(item.Chromecast),
IsActive: boolValue(item.IsActive),
IsDefault: item.IsDefault,
OwnerEmail: ownerEmail,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(&item.UpdatedAt),
EncrytionM3U8: boolValue(item.EncrytionM3u8),
LogoUrl: nullableTrimmedString(item.LogoURL),
}
}
func toProtoPlan(item *model.Plan) *appv1.Plan {
if item == nil {
return nil
}
return &appv1.Plan{
Id: item.ID,
Name: item.Name,
Description: item.Description,
Price: item.Price,
Cycle: item.Cycle,
StorageLimit: item.StorageLimit,
UploadLimit: item.UploadLimit,
DurationLimit: item.DurationLimit,
QualityLimit: item.QualityLimit,
Features: item.Features,
IsActive: boolValue(item.IsActive),
}
}
func toProtoPayment(item *model.Payment) *appv1.Payment {
if item == nil {
return nil
}
return &appv1.Payment{
Id: item.ID,
UserId: item.UserID,
PlanId: item.PlanID,
Amount: item.Amount,
Currency: normalizeCurrency(item.Currency),
Status: normalizePaymentStatus(item.Status),
Provider: strings.ToUpper(stringValue(item.Provider)),
TransactionId: item.TransactionID,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()),
}
}
func toProtoPlanSubscription(item *model.PlanSubscription) *appv1.PlanSubscription {
if item == nil {
return nil
}
return &appv1.PlanSubscription{
Id: item.ID,
UserId: item.UserID,
PaymentId: item.PaymentID,
PlanId: item.PlanID,
TermMonths: item.TermMonths,
PaymentMethod: item.PaymentMethod,
WalletAmount: item.WalletAmount,
TopupAmount: item.TopupAmount,
StartedAt: timestamppb.New(item.StartedAt.UTC()),
ExpiresAt: timestamppb.New(item.ExpiresAt.UTC()),
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(item.UpdatedAt),
}
}
func toProtoWalletTransaction(item *model.WalletTransaction) *appv1.WalletTransaction {
if item == nil {
return nil
}
return &appv1.WalletTransaction{
Id: item.ID,
UserId: item.UserID,
Type: item.Type,
Amount: item.Amount,
Currency: normalizeCurrency(item.Currency),
Note: item.Note,
PaymentId: item.PaymentID,
PlanId: item.PlanID,
TermMonths: item.TermMonths,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timeToProto(item.UpdatedAt),
}
}
func timeToProto(value *time.Time) *timestamppb.Timestamp {
if value == nil {
return nil
}
return timestamppb.New(value.UTC())
}
func boolValue(value *bool) bool {
return value != nil && *value
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func int32PtrToInt64Ptr(value *int32) *int64 {
if value == nil {
return nil
}
converted := int64(*value)
return &converted
}
func int64PtrToInt32Ptr(value *int64) *int32 {
if value == nil {
return nil
}
converted := int32(*value)
return &converted
}
func int32Ptr(value int32) *int32 {
return &value
}
func protoStringValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func nullableTrimmedStringPtr(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func nullableTrimmedString(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func normalizeNotificationType(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
switch {
case strings.Contains(lower, "video"):
return "video"
case strings.Contains(lower, "payment"), strings.Contains(lower, "billing"):
return "payment"
case strings.Contains(lower, "warning"):
return "warning"
case strings.Contains(lower, "error"):
return "error"
case strings.Contains(lower, "success"):
return "success"
case strings.Contains(lower, "system"):
return "system"
default:
return "info"
}
}
func normalizeDomain(value string) string {
normalized := strings.TrimSpace(strings.ToLower(value))
normalized = strings.TrimPrefix(normalized, "https://")
normalized = strings.TrimPrefix(normalized, "http://")
normalized = strings.TrimPrefix(normalized, "www.")
normalized = strings.TrimSuffix(normalized, "/")
return normalized
}
func normalizeAdFormat(value string) string {
switch strings.TrimSpace(strings.ToLower(value)) {
case "mid-roll", "post-roll":
return strings.TrimSpace(strings.ToLower(value))
default:
return "pre-roll"
}
}
func adTemplateIsActive(value *bool) bool {
return value == nil || *value
}
func playerConfigIsActive(value *bool) bool {
return value == nil || *value
}
func unsetDefaultTemplates(tx *gorm.DB, userID, excludeID string) error {
query := tx.Model(&model.AdTemplate{}).Where("user_id = ?", userID)
if excludeID != "" {
query = query.Where("id <> ?", excludeID)
}
return query.Update("is_default", false).Error
}
func unsetDefaultPlayerConfigs(tx *gorm.DB, userID, excludeID string) error {
query := tx.Model(&model.PlayerConfig{}).Where("user_id = ?", userID)
if excludeID != "" {
query = query.Where("id <> ?", excludeID)
}
return query.Update("is_default", false).Error
}
func normalizePaymentStatus(status *string) string {
value := strings.ToLower(strings.TrimSpace(stringValue(status)))
switch value {
case "success", "succeeded", "paid":
return "success"
case "failed", "error", "canceled", "cancelled":
return "failed"
case "pending", "processing":
return "pending"
default:
if value == "" {
return "success"
}
return value
}
}
func normalizeCurrency(currency *string) string {
value := strings.ToUpper(strings.TrimSpace(stringValue(currency)))
if value == "" {
return "USD"
}
return value
}
func normalizePaymentMethod(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case paymentMethodWallet:
return paymentMethodWallet
case paymentMethodTopup:
return paymentMethodTopup
default:
return ""
}
}
func normalizeOptionalPaymentMethod(value *string) *string {
normalized := normalizePaymentMethod(stringValue(value))
if normalized == "" {
return nil
}
return &normalized
}
func buildInvoiceID(id string) string {
trimmed := strings.ReplaceAll(strings.TrimSpace(id), "-", "")
if len(trimmed) > 12 {
trimmed = trimmed[:12]
}
return "INV-" + strings.ToUpper(trimmed)
}
func buildTransactionID(prefix string) string {
return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano())
}
func buildInvoiceFilename(id string) string {
return fmt.Sprintf("invoice-%s.txt", id)
}

View File

@@ -74,19 +74,7 @@ func (s *appServices) buildReferralShareLink(username *string) *string {
} }
func (s *appServices) loadReferralUsersByUsername(ctx context.Context, username string) ([]model.User, error) { func (s *appServices) loadReferralUsersByUsername(ctx context.Context, username string) ([]model.User, error) {
trimmed := strings.TrimSpace(username) return s.userRepository.FindByReferralUsername(ctx, username, 2)
if trimmed == "" {
return nil, nil
}
var users []model.User
if err := s.db.WithContext(ctx).
Where("LOWER(username) = LOWER(?)", trimmed).
Order("created_at ASC, id ASC").
Limit(2).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
} }
func (s *appServices) resolveReferralUserByUsername(ctx context.Context, username string) (*model.User, error) { func (s *appServices) resolveReferralUserByUsername(ctx context.Context, username string) (*model.User, error) {
@@ -160,7 +148,7 @@ func (s *appServices) maybeGrantReferralReward(ctx context.Context, tx *gorm.DB,
return &referralRewardResult{}, nil return &referralRewardResult{}, nil
} }
referee, err := lockUserForUpdate(ctx, tx, input.UserID) referee, err := s.userRepository.LockByIDTx(tx, ctx, input.UserID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -171,18 +159,15 @@ func (s *appServices) maybeGrantReferralReward(ctx context.Context, tx *gorm.DB,
return &referralRewardResult{}, nil return &referralRewardResult{}, nil
} }
var subscriptionCount int64 subscriptionCount, err := s.userRepository.CountSubscriptionsByUser(ctx, referee.ID)
if err := tx.WithContext(ctx). if err != nil {
Model(&model.PlanSubscription{}).
Where("user_id = ?", referee.ID).
Count(&subscriptionCount).Error; err != nil {
return nil, err return nil, err
} }
if subscriptionCount != 1 { if subscriptionCount != 1 {
return &referralRewardResult{}, nil return &referralRewardResult{}, nil
} }
referrer, err := lockUserForUpdate(ctx, tx, strings.TrimSpace(*referee.ReferredByUserID)) referrer, err := s.userRepository.LockByIDTx(tx, ctx, strings.TrimSpace(*referee.ReferredByUserID))
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return &referralRewardResult{}, nil return &referralRewardResult{}, nil
@@ -217,10 +202,10 @@ func (s *appServices) maybeGrantReferralReward(ctx context.Context, tx *gorm.DB,
PaymentID: &paymentRecord.ID, PaymentID: &paymentRecord.ID,
PlanID: &input.Plan.ID, PlanID: &input.Plan.ID,
} }
if err := tx.Create(rewardTransaction).Error; err != nil { if err := s.paymentRepository.CreateWalletTransactionTx(tx, ctx, rewardTransaction); err != nil {
return nil, err return nil, err
} }
if err := tx.Create(buildReferralRewardNotification(referrer.ID, rewardAmount, referee, paymentRecord)).Error; err != nil { if err := s.paymentRepository.CreateNotificationTx(tx, ctx, buildReferralRewardNotification(referrer.ID, rewardAmount, referee, paymentRecord)); err != nil {
return nil, err return nil, err
} }
@@ -230,7 +215,7 @@ func (s *appServices) maybeGrantReferralReward(ctx context.Context, tx *gorm.DB,
"referral_reward_payment_id": paymentRecord.ID, "referral_reward_payment_id": paymentRecord.ID,
"referral_reward_amount": rewardAmount, "referral_reward_amount": rewardAmount,
} }
if err := tx.WithContext(ctx).Model(&model.User{}).Where("id = ?", referee.ID).Updates(updates).Error; err != nil { if err := s.userRepository.UpdateFieldsByIDTx(tx, ctx, referee.ID, updates); err != nil {
return nil, err return nil, err
} }
referee.ReferralRewardGrantedAt = &now referee.ReferralRewardGrantedAt = &now

View File

@@ -7,46 +7,42 @@ import (
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/wrapperspb" "google.golang.org/protobuf/types/known/wrapperspb"
"gorm.io/gorm"
appv1 "stream.api/internal/api/proto/app/v1" appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
"stream.api/internal/database/query"
) )
func (s *appServices) GetMe(ctx context.Context, _ *appv1.GetMeRequest) (*appv1.GetMeResponse, error) { func (s *accountAppService) GetMe(ctx context.Context, _ *appv1.GetMeRequest) (*appv1.GetMeResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
payload, err := buildUserPayload(ctx, s.db, result.User) payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, result.User)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")
} }
return &appv1.GetMeResponse{User: toProtoUser(payload)}, nil return &appv1.GetMeResponse{User: toProtoUser(payload)}, nil
} }
func (s *appServices) GetUserById(ctx context.Context, req *wrapperspb.StringValue) (*appv1.User, error) { func (s *accountAppService) GetUserById(ctx context.Context, req *wrapperspb.StringValue) (*appv1.User, error) {
_, err := s.authenticator.RequireTrustedMetadata(ctx) _, err := s.authenticator.RequireTrustedMetadata(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
u := query.User user, err := s.userRepository.GetByID(ctx, req.Value)
user, err := u.WithContext(ctx).Where(u.ID.Eq(req.Value)).First()
if err != nil { if err != nil {
return nil, status.Error(codes.Unauthenticated, "Unauthorized") return nil, status.Error(codes.Unauthenticated, "Unauthorized")
} }
payload, err := buildUserPayload(ctx, s.db, user) payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, user)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")
} }
return toProtoUser(payload), nil return toProtoUser(payload), nil
} }
func (s *appServices) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest) (*appv1.UpdateMeResponse, error) { func (s *accountAppService) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest) (*appv1.UpdateMeResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
updatedUser, err := updateUserProfile(ctx, s.db, s.logger, result.UserID, updateProfileInput{ updatedUser, err := updateUserProfile(ctx, s.userRepository, s.preferenceRepository, s.logger, result.UserID, updateProfileInput{
Username: req.Username, Username: req.Username,
Email: req.Email, Email: req.Email,
Language: req.Language, Language: req.Language,
@@ -61,103 +57,57 @@ func (s *appServices) UpdateMe(ctx context.Context, req *appv1.UpdateMeRequest)
} }
} }
payload, err := buildUserPayload(ctx, s.db, updatedUser) payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, updatedUser)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")
} }
return &appv1.UpdateMeResponse{User: toProtoUser(payload)}, nil return &appv1.UpdateMeResponse{User: toProtoUser(payload)}, nil
} }
func (s *appServices) DeleteMe(ctx context.Context, _ *appv1.DeleteMeRequest) (*appv1.MessageResponse, error) { func (s *accountAppService) DeleteMe(ctx context.Context, _ *appv1.DeleteMeRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
userID := result.UserID userID := result.UserID
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.accountRepository.DeleteUserAccount(ctx, userID); err != nil {
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.WalletTransaction{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.PlanSubscription{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.UserPreference{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Payment{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
return err
}
if err := tx.Where("id = ?", userID).Delete(&model.User{}).Error; err != nil {
return err
}
return nil
}); err != nil {
s.logger.Error("Failed to delete user", "error", err) s.logger.Error("Failed to delete user", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete account") return nil, status.Error(codes.Internal, "Failed to delete account")
} }
return messageResponse("Account deleted successfully"), nil return messageResponse("Account deleted successfully"), nil
} }
func (s *appServices) ClearMyData(ctx context.Context, _ *appv1.ClearMyDataRequest) (*appv1.MessageResponse, error) { func (s *accountAppService) ClearMyData(ctx context.Context, _ *appv1.ClearMyDataRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
userID := result.UserID userID := result.UserID
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.accountRepository.ClearUserData(ctx, userID); err != nil {
if err := tx.Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Domain{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.AdTemplate{}).Error; err != nil {
return err
}
if err := tx.Where("user_id = ?", userID).Delete(&model.Video{}).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]interface{}{"storage_used": 0}).Error; err != nil {
return err
}
return nil
}); err != nil {
s.logger.Error("Failed to clear user data", "error", err) s.logger.Error("Failed to clear user data", "error", err)
return nil, status.Error(codes.Internal, "Failed to clear data") return nil, status.Error(codes.Internal, "Failed to clear data")
} }
return messageResponse("Data cleared successfully"), nil return messageResponse("Data cleared successfully"), nil
} }
func (s *appServices) GetPreferences(ctx context.Context, _ *appv1.GetPreferencesRequest) (*appv1.GetPreferencesResponse, error) { func (s *accountAppService) GetPreferences(ctx context.Context, _ *appv1.GetPreferencesRequest) (*appv1.GetPreferencesResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
pref, err := loadUserPreferences(ctx, s.db, result.UserID) pref, err := loadUserPreferences(ctx, s.preferenceRepository, result.UserID)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to load preferences") return nil, status.Error(codes.Internal, "Failed to load preferences")
} }
return &appv1.GetPreferencesResponse{Preferences: toProtoPreferences(pref)}, nil return &appv1.GetPreferencesResponse{Preferences: toProtoPreferences(pref)}, nil
} }
func (s *appServices) UpdatePreferences(ctx context.Context, req *appv1.UpdatePreferencesRequest) (*appv1.UpdatePreferencesResponse, error) { func (s *accountAppService) UpdatePreferences(ctx context.Context, req *appv1.UpdatePreferencesRequest) (*appv1.UpdatePreferencesResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
pref, err := updateUserPreferences(ctx, s.db, s.logger, result.UserID, updatePreferencesInput{ pref, err := updateUserPreferences(ctx, s.preferenceRepository, s.logger, result.UserID, updatePreferencesInput{
EmailNotifications: req.EmailNotifications, EmailNotifications: req.EmailNotifications,
PushNotifications: req.PushNotifications, PushNotifications: req.PushNotifications,
MarketingNotifications: req.MarketingNotifications, MarketingNotifications: req.MarketingNotifications,
@@ -170,12 +120,12 @@ func (s *appServices) UpdatePreferences(ctx context.Context, req *appv1.UpdatePr
} }
return &appv1.UpdatePreferencesResponse{Preferences: toProtoPreferences(pref)}, nil return &appv1.UpdatePreferencesResponse{Preferences: toProtoPreferences(pref)}, nil
} }
func (s *appServices) GetUsage(ctx context.Context, _ *appv1.GetUsageRequest) (*appv1.GetUsageResponse, error) { func (s *accountAppService) GetUsage(ctx context.Context, _ *appv1.GetUsageRequest) (*appv1.GetUsageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
payload, err := loadUsage(ctx, s.db, s.logger, result.User) payload, err := loadUsage(ctx, s.videoRepository, s.logger, result.User)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to load usage") return nil, status.Error(codes.Internal, "Failed to load usage")
} }

View File

@@ -19,25 +19,11 @@ func (s *appServices) ListAdminPayments(ctx context.Context, req *appv1.ListAdmi
} }
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
userID := strings.TrimSpace(req.GetUserId()) userID := strings.TrimSpace(req.GetUserId())
statusFilter := strings.TrimSpace(req.GetStatus()) statusFilter := strings.TrimSpace(req.GetStatus())
db := s.db.WithContext(ctx).Model(&model.Payment{}) payments, total, err := s.paymentRepository.ListForAdmin(ctx, userID, statusFilter, limit, offset)
if userID != "" { if err != nil {
db = db.Where("user_id = ?", userID)
}
if statusFilter != "" {
db = db.Where("UPPER(status) = ?", strings.ToUpper(statusFilter))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list payments")
}
var payments []model.Payment
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&payments).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list payments") return nil, status.Error(codes.Internal, "Failed to list payments")
} }
@@ -62,15 +48,15 @@ func (s *appServices) GetAdminPayment(ctx context.Context, req *appv1.GetAdminPa
return nil, status.Error(codes.NotFound, "Payment not found") return nil, status.Error(codes.NotFound, "Payment not found")
} }
var payment model.Payment payment, err := s.paymentRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Payment not found") return nil, status.Error(codes.NotFound, "Payment not found")
} }
return nil, status.Error(codes.Internal, "Failed to get payment") return nil, status.Error(codes.Internal, "Failed to get payment")
} }
payload, err := s.buildAdminPayment(ctx, &payment) payload, err := s.buildAdminPayment(ctx, payment)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to get payment") return nil, status.Error(codes.Internal, "Failed to get payment")
} }
@@ -148,8 +134,8 @@ func (s *appServices) UpdateAdminPayment(ctx context.Context, req *appv1.UpdateA
return nil, status.Error(codes.InvalidArgument, "Invalid payment status") return nil, status.Error(codes.InvalidArgument, "Invalid payment status")
} }
var payment model.Payment payment, err := s.paymentRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&payment).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Payment not found") return nil, status.Error(codes.NotFound, "Payment not found")
} }
@@ -162,12 +148,12 @@ func (s *appServices) UpdateAdminPayment(ctx context.Context, req *appv1.UpdateA
return nil, status.Error(codes.InvalidArgument, "Cannot transition payment to SUCCESS from admin update; recreate through the payment flow instead") return nil, status.Error(codes.InvalidArgument, "Cannot transition payment to SUCCESS from admin update; recreate through the payment flow instead")
} }
payment.Status = model.StringPtr(newStatus) payment.Status = model.StringPtr(newStatus)
if err := s.db.WithContext(ctx).Save(&payment).Error; err != nil { if err := s.paymentRepository.Save(ctx, payment); err != nil {
return nil, status.Error(codes.Internal, "Failed to update payment") return nil, status.Error(codes.Internal, "Failed to update payment")
} }
} }
payload, err := s.buildAdminPayment(ctx, &payment) payload, err := s.buildAdminPayment(ctx, payment)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to update payment") return nil, status.Error(codes.Internal, "Failed to update payment")
} }
@@ -178,8 +164,8 @@ func (s *appServices) ListAdminPlans(ctx context.Context, _ *appv1.ListAdminPlan
return nil, err return nil, err
} }
var plans []model.Plan plans, err := s.planRepository.ListAll(ctx)
if err := s.db.WithContext(ctx).Order("price ASC").Find(&plans).Error; err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to list plans") return nil, status.Error(codes.Internal, "Failed to list plans")
} }
@@ -216,7 +202,7 @@ func (s *appServices) CreateAdminPlan(ctx context.Context, req *appv1.CreateAdmi
IsActive: model.BoolPtr(req.GetIsActive()), IsActive: model.BoolPtr(req.GetIsActive()),
} }
if err := s.db.WithContext(ctx).Create(plan).Error; err != nil { if err := s.planRepository.Create(ctx, plan); err != nil {
return nil, status.Error(codes.Internal, "Failed to create plan") return nil, status.Error(codes.Internal, "Failed to create plan")
} }
@@ -239,8 +225,8 @@ func (s *appServices) UpdateAdminPlan(ctx context.Context, req *appv1.UpdateAdmi
return nil, status.Error(codes.InvalidArgument, msg) return nil, status.Error(codes.InvalidArgument, msg)
} }
var plan model.Plan plan, err := s.planRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Plan not found") return nil, status.Error(codes.NotFound, "Plan not found")
} }
@@ -256,11 +242,11 @@ func (s *appServices) UpdateAdminPlan(ctx context.Context, req *appv1.UpdateAdmi
plan.UploadLimit = req.GetUploadLimit() plan.UploadLimit = req.GetUploadLimit()
plan.IsActive = model.BoolPtr(req.GetIsActive()) plan.IsActive = model.BoolPtr(req.GetIsActive())
if err := s.db.WithContext(ctx).Save(&plan).Error; err != nil { if err := s.planRepository.Save(ctx, plan); err != nil {
return nil, status.Error(codes.Internal, "Failed to update plan") return nil, status.Error(codes.Internal, "Failed to update plan")
} }
payload, err := s.buildAdminPlan(ctx, &plan) payload, err := s.buildAdminPlan(ctx, plan)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to update plan") return nil, status.Error(codes.Internal, "Failed to update plan")
} }
@@ -276,32 +262,32 @@ func (s *appServices) DeleteAdminPlan(ctx context.Context, req *appv1.DeleteAdmi
return nil, status.Error(codes.NotFound, "Plan not found") return nil, status.Error(codes.NotFound, "Plan not found")
} }
var plan model.Plan _, err := s.planRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Plan not found") return nil, status.Error(codes.NotFound, "Plan not found")
} }
return nil, status.Error(codes.Internal, "Failed to delete plan") return nil, status.Error(codes.Internal, "Failed to delete plan")
} }
var paymentCount int64 paymentCount, err := s.planRepository.CountPaymentsByPlan(ctx, id)
if err := s.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", id).Count(&paymentCount).Error; err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan") return nil, status.Error(codes.Internal, "Failed to delete plan")
} }
var subscriptionCount int64 subscriptionCount, err := s.planRepository.CountSubscriptionsByPlan(ctx, id)
if err := s.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", id).Count(&subscriptionCount).Error; err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan") return nil, status.Error(codes.Internal, "Failed to delete plan")
} }
if paymentCount > 0 || subscriptionCount > 0 { if paymentCount > 0 || subscriptionCount > 0 {
inactive := false inactive := false
if err := s.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", id).Update("is_active", inactive).Error; err != nil { if err := s.planRepository.SetActive(ctx, id, inactive); err != nil {
return nil, status.Error(codes.Internal, "Failed to deactivate plan") return nil, status.Error(codes.Internal, "Failed to deactivate plan")
} }
return &appv1.DeleteAdminPlanResponse{Message: "Plan deactivated", Mode: "deactivated"}, nil return &appv1.DeleteAdminPlanResponse{Message: "Plan deactivated", Mode: "deactivated"}, nil
} }
if err := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Plan{}).Error; err != nil { if err := s.planRepository.DeleteByID(ctx, id); err != nil {
return nil, status.Error(codes.Internal, "Failed to delete plan") return nil, status.Error(codes.Internal, "Failed to delete plan")
} }
return &appv1.DeleteAdminPlanResponse{Message: "Plan deleted", Mode: "deleted"}, nil return &appv1.DeleteAdminPlanResponse{Message: "Plan deleted", Mode: "deleted"}, nil
@@ -312,26 +298,11 @@ func (s *appServices) ListAdminAdTemplates(ctx context.Context, req *appv1.ListA
} }
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(protoStringValue(req.Search)) search := strings.TrimSpace(protoStringValue(req.Search))
userID := strings.TrimSpace(protoStringValue(req.UserId)) userID := strings.TrimSpace(protoStringValue(req.UserId))
db := s.db.WithContext(ctx).Model(&model.AdTemplate{}) templates, total, err := s.adTemplateRepository.ListForAdmin(ctx, search, userID, limit, offset)
if search != "" { if err != nil {
like := "%" + search + "%"
db = db.Where("name ILIKE ?", like)
}
if userID != "" {
db = db.Where("user_id = ?", userID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates")
}
var templates []model.AdTemplate
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&templates).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list ad templates") return nil, status.Error(codes.Internal, "Failed to list ad templates")
} }
@@ -361,15 +332,15 @@ func (s *appServices) GetAdminAdTemplate(ctx context.Context, req *appv1.GetAdmi
return nil, status.Error(codes.NotFound, "Ad template not found") return nil, status.Error(codes.NotFound, "Ad template not found")
} }
var item model.AdTemplate item, err := s.adTemplateRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found") return nil, status.Error(codes.NotFound, "Ad template not found")
} }
return nil, status.Error(codes.Internal, "Failed to load ad template") return nil, status.Error(codes.Internal, "Failed to load ad template")
} }
payload, err := s.buildAdminAdTemplate(ctx, &item) payload, err := s.buildAdminAdTemplate(ctx, item)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to load ad template") return nil, status.Error(codes.Internal, "Failed to load ad template")
} }
@@ -385,8 +356,8 @@ func (s *appServices) CreateAdminAdTemplate(ctx context.Context, req *appv1.Crea
return nil, status.Error(codes.InvalidArgument, msg) return nil, status.Error(codes.InvalidArgument, msg)
} }
var user model.User user, err := s.userRepository.GetByID(ctx, strings.TrimSpace(req.GetUserId()))
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found") return nil, status.Error(codes.InvalidArgument, "User not found")
} }
@@ -408,14 +379,7 @@ func (s *appServices) CreateAdminAdTemplate(ctx context.Context, req *appv1.Crea
item.IsDefault = false item.IsDefault = false
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.adTemplateRepository.CreateWithDefault(ctx, item.UserID, item); err != nil {
if item.IsDefault {
if err := s.unsetAdminDefaultTemplates(ctx, tx, item.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template") return nil, status.Error(codes.Internal, "Failed to save ad template")
} }
@@ -439,16 +403,16 @@ func (s *appServices) UpdateAdminAdTemplate(ctx context.Context, req *appv1.Upda
return nil, status.Error(codes.InvalidArgument, msg) return nil, status.Error(codes.InvalidArgument, msg)
} }
var user model.User user, err := s.userRepository.GetByID(ctx, strings.TrimSpace(req.GetUserId()))
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found") return nil, status.Error(codes.InvalidArgument, "User not found")
} }
return nil, status.Error(codes.Internal, "Failed to save ad template") return nil, status.Error(codes.Internal, "Failed to save ad template")
} }
var item model.AdTemplate item, err := s.adTemplateRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found") return nil, status.Error(codes.NotFound, "Ad template not found")
} }
@@ -467,18 +431,11 @@ func (s *appServices) UpdateAdminAdTemplate(ctx context.Context, req *appv1.Upda
item.IsDefault = false item.IsDefault = false
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.adTemplateRepository.SaveWithDefault(ctx, item.UserID, item); err != nil {
if item.IsDefault {
if err := s.unsetAdminDefaultTemplates(ctx, tx, item.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template") return nil, status.Error(codes.Internal, "Failed to save ad template")
} }
payload, err := s.buildAdminAdTemplate(ctx, &item) payload, err := s.buildAdminAdTemplate(ctx, item)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to save ad template") return nil, status.Error(codes.Internal, "Failed to save ad template")
} }
@@ -494,19 +451,7 @@ func (s *appServices) DeleteAdminAdTemplate(ctx context.Context, req *appv1.Dele
return nil, status.Error(codes.NotFound, "Ad template not found") return nil, status.Error(codes.NotFound, "Ad template not found")
} }
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err := s.adTemplateRepository.DeleteByIDAndClearVideos(ctx, id)
if err := tx.Model(&model.Video{}).Where("ad_id = ?", id).Update("ad_id", nil).Error; err != nil {
return err
}
res := tx.Where("id = ?", id).Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
})
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found") return nil, status.Error(codes.NotFound, "Ad template not found")
@@ -522,26 +467,11 @@ func (s *appServices) ListAdminPlayerConfigs(ctx context.Context, req *appv1.Lis
} }
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(protoStringValue(req.Search)) search := strings.TrimSpace(protoStringValue(req.Search))
userID := strings.TrimSpace(protoStringValue(req.UserId)) userID := strings.TrimSpace(protoStringValue(req.UserId))
db := s.db.WithContext(ctx).Model(&model.PlayerConfig{}) configs, total, err := s.playerConfigRepo.ListForAdmin(ctx, search, userID, limit, offset)
if search != "" { if err != nil {
like := "%" + search + "%"
db = db.Where("name ILIKE ?", like)
}
if userID != "" {
db = db.Where("user_id = ?", userID)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list player configs")
}
var configs []model.PlayerConfig
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&configs).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list player configs") return nil, status.Error(codes.Internal, "Failed to list player configs")
} }
@@ -572,15 +502,15 @@ func (s *appServices) GetAdminPlayerConfig(ctx context.Context, req *appv1.GetAd
return nil, status.Error(codes.NotFound, "Player config not found") return nil, status.Error(codes.NotFound, "Player config not found")
} }
var item model.PlayerConfig item, err := s.playerConfigRepo.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found") return nil, status.Error(codes.NotFound, "Player config not found")
} }
return nil, status.Error(codes.Internal, "Failed to load player config") return nil, status.Error(codes.Internal, "Failed to load player config")
} }
payload, err := s.buildAdminPlayerConfig(ctx, &item) payload, err := s.buildAdminPlayerConfig(ctx, item)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to load player config") return nil, status.Error(codes.Internal, "Failed to load player config")
} }
@@ -596,8 +526,8 @@ func (s *appServices) CreateAdminPlayerConfig(ctx context.Context, req *appv1.Cr
return nil, status.Error(codes.InvalidArgument, msg) return nil, status.Error(codes.InvalidArgument, msg)
} }
var user model.User user, err := s.userRepository.GetByID(ctx, strings.TrimSpace(req.GetUserId()))
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found") return nil, status.Error(codes.InvalidArgument, "User not found")
} }
@@ -625,14 +555,7 @@ func (s *appServices) CreateAdminPlayerConfig(ctx context.Context, req *appv1.Cr
item.IsDefault = false item.IsDefault = false
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.playerConfigRepo.CreateWithDefault(ctx, item.UserID, item); err != nil {
if item.IsDefault {
if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config") return nil, status.Error(codes.Internal, "Failed to save player config")
} }
@@ -657,16 +580,16 @@ func (s *appServices) UpdateAdminPlayerConfig(ctx context.Context, req *appv1.Up
return nil, status.Error(codes.InvalidArgument, msg) return nil, status.Error(codes.InvalidArgument, msg)
} }
var user model.User user, err := s.userRepository.GetByID(ctx, strings.TrimSpace(req.GetUserId()))
if err := s.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.GetUserId())).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found") return nil, status.Error(codes.InvalidArgument, "User not found")
} }
return nil, status.Error(codes.Internal, "Failed to save player config") return nil, status.Error(codes.Internal, "Failed to save player config")
} }
var item model.PlayerConfig item, err := s.playerConfigRepo.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found") return nil, status.Error(codes.NotFound, "Player config not found")
} }
@@ -695,18 +618,11 @@ func (s *appServices) UpdateAdminPlayerConfig(ctx context.Context, req *appv1.Up
item.IsDefault = false item.IsDefault = false
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.playerConfigRepo.SaveWithDefault(ctx, item.UserID, item); err != nil {
if item.IsDefault {
if err := s.unsetAdminDefaultPlayerConfigs(ctx, tx, item.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config") return nil, status.Error(codes.Internal, "Failed to save player config")
} }
payload, err := s.buildAdminPlayerConfig(ctx, &item) payload, err := s.buildAdminPlayerConfig(ctx, item)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to save player config") return nil, status.Error(codes.Internal, "Failed to save player config")
} }
@@ -723,11 +639,11 @@ func (s *appServices) DeleteAdminPlayerConfig(ctx context.Context, req *appv1.De
return nil, status.Error(codes.NotFound, "Player config not found") return nil, status.Error(codes.NotFound, "Player config not found")
} }
res := s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.PlayerConfig{}) rowsAffected, err := s.playerConfigRepo.DeleteByID(ctx, id)
if res.Error != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to delete player config") return nil, status.Error(codes.Internal, "Failed to delete player config")
} }
if res.RowsAffected == 0 { if rowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Player config not found") return nil, status.Error(codes.NotFound, "Player config not found")
} }

View File

@@ -17,7 +17,7 @@ func (s *appServices) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJob
if _, err := s.requireAdmin(ctx); err != nil { if _, err := s.requireAdmin(ctx); err != nil {
return nil, err return nil, err
} }
if s.videoService == nil { if s.videoWorkflowService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable") return nil, status.Error(codes.Unavailable, "Job service is unavailable")
} }
@@ -32,11 +32,11 @@ func (s *appServices) ListAdminJobs(ctx context.Context, req *appv1.ListAdminJob
err error err error
) )
if useCursorPagination { if useCursorPagination {
result, err = s.videoService.ListJobsByCursor(ctx, agentID, req.GetCursor(), pageSize) result, err = s.videoWorkflowService.ListJobsByCursor(ctx, agentID, req.GetCursor(), pageSize)
} else if agentID != "" { } else if agentID != "" {
result, err = s.videoService.ListJobsByAgent(ctx, agentID, offset, limit) result, err = s.videoWorkflowService.ListJobsByAgent(ctx, agentID, offset, limit)
} else { } else {
result, err = s.videoService.ListJobs(ctx, offset, limit) result, err = s.videoWorkflowService.ListJobs(ctx, offset, limit)
} }
if err != nil { if err != nil {
if errors.Is(err, ErrInvalidJobCursor) { if errors.Is(err, ErrInvalidJobCursor) {
@@ -67,7 +67,7 @@ func (s *appServices) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobReq
if _, err := s.requireAdmin(ctx); err != nil { if _, err := s.requireAdmin(ctx); err != nil {
return nil, err return nil, err
} }
if s.videoService == nil { if s.videoWorkflowService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable") return nil, status.Error(codes.Unavailable, "Job service is unavailable")
} }
@@ -75,7 +75,7 @@ func (s *appServices) GetAdminJob(ctx context.Context, req *appv1.GetAdminJobReq
if id == "" { if id == "" {
return nil, status.Error(codes.NotFound, "Job not found") return nil, status.Error(codes.NotFound, "Job not found")
} }
job, err := s.videoService.GetJob(ctx, id) job, err := s.videoWorkflowService.GetJob(ctx, id)
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Job not found") return nil, status.Error(codes.NotFound, "Job not found")
@@ -95,7 +95,7 @@ func (s *appServices) CreateAdminJob(ctx context.Context, req *appv1.CreateAdmin
if _, err := s.requireAdmin(ctx); err != nil { if _, err := s.requireAdmin(ctx); err != nil {
return nil, err return nil, err
} }
if s.videoService == nil { if s.videoWorkflowService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable") return nil, status.Error(codes.Unavailable, "Job service is unavailable")
} }
@@ -124,7 +124,7 @@ func (s *appServices) CreateAdminJob(ctx context.Context, req *appv1.CreateAdmin
if req.VideoId != nil { if req.VideoId != nil {
videoID = strings.TrimSpace(req.GetVideoId()) videoID = strings.TrimSpace(req.GetVideoId())
} }
job, err := s.videoService.CreateJob(ctx, strings.TrimSpace(req.GetUserId()), videoID, name, payload, int(req.GetPriority()), req.GetTimeLimit()) job, err := s.videoWorkflowService.CreateJob(ctx, strings.TrimSpace(req.GetUserId()), videoID, name, payload, int(req.GetPriority()), req.GetTimeLimit())
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to create job") return nil, status.Error(codes.Internal, "Failed to create job")
} }
@@ -134,7 +134,7 @@ func (s *appServices) CancelAdminJob(ctx context.Context, req *appv1.CancelAdmin
if _, err := s.requireAdmin(ctx); err != nil { if _, err := s.requireAdmin(ctx); err != nil {
return nil, err return nil, err
} }
if s.videoService == nil { if s.videoWorkflowService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable") return nil, status.Error(codes.Unavailable, "Job service is unavailable")
} }
@@ -142,7 +142,7 @@ func (s *appServices) CancelAdminJob(ctx context.Context, req *appv1.CancelAdmin
if id == "" { if id == "" {
return nil, status.Error(codes.NotFound, "Job not found") return nil, status.Error(codes.NotFound, "Job not found")
} }
if err := s.videoService.CancelJob(ctx, id); err != nil { if err := s.videoWorkflowService.CancelJob(ctx, id); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") { if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil, status.Error(codes.NotFound, "Job not found") return nil, status.Error(codes.NotFound, "Job not found")
} }
@@ -154,7 +154,7 @@ func (s *appServices) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJo
if _, err := s.requireAdmin(ctx); err != nil { if _, err := s.requireAdmin(ctx); err != nil {
return nil, err return nil, err
} }
if s.videoService == nil { if s.videoWorkflowService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable") return nil, status.Error(codes.Unavailable, "Job service is unavailable")
} }
@@ -162,7 +162,7 @@ func (s *appServices) RetryAdminJob(ctx context.Context, req *appv1.RetryAdminJo
if id == "" { if id == "" {
return nil, status.Error(codes.NotFound, "Job not found") return nil, status.Error(codes.NotFound, "Job not found")
} }
job, err := s.videoService.RetryJob(ctx, id) job, err := s.videoWorkflowService.RetryJob(ctx, id)
if err != nil { if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") { if strings.Contains(strings.ToLower(err.Error()), "not found") {
return nil, status.Error(codes.NotFound, "Job not found") return nil, status.Error(codes.NotFound, "Job not found")

View File

@@ -20,20 +20,55 @@ func (s *appServices) GetAdminDashboard(ctx context.Context, _ *appv1.GetAdminDa
return nil, err return nil, err
} }
dashboard := &appv1.AdminDashboard{}
db := s.db.WithContext(ctx)
db.Model(&model.User{}).Count(&dashboard.TotalUsers)
db.Model(&model.Video{}).Count(&dashboard.TotalVideos)
db.Model(&model.User{}).Select("COALESCE(SUM(storage_used), 0)").Row().Scan(&dashboard.TotalStorageUsed)
db.Model(&model.Payment{}).Count(&dashboard.TotalPayments)
db.Model(&model.Payment{}).Where("status = ?", "SUCCESS").Select("COALESCE(SUM(amount), 0)").Row().Scan(&dashboard.TotalRevenue)
db.Model(&model.PlanSubscription{}).Where("expires_at > ?", time.Now()).Count(&dashboard.ActiveSubscriptions)
db.Model(&model.AdTemplate{}).Count(&dashboard.TotalAdTemplates)
today := time.Now().Truncate(24 * time.Hour) today := time.Now().Truncate(24 * time.Hour)
db.Model(&model.User{}).Where("created_at >= ?", today).Count(&dashboard.NewUsersToday) totalUsers, err := s.userRepository.CountAll(ctx)
db.Model(&model.Video{}).Where("created_at >= ?", today).Count(&dashboard.NewVideosToday) if err != nil {
return nil, status.Error(codes.Internal, "Failed to load dashboard")
}
totalVideos, err := s.videoRepository.CountAll(ctx)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load dashboard")
}
totalStorageUsed, err := s.userRepository.SumStorageUsed(ctx)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load dashboard")
}
totalPayments, err := s.paymentRepository.CountAll(ctx)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load dashboard")
}
totalRevenue, err := s.paymentRepository.SumSuccessfulAmount(ctx)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load dashboard")
}
activeSubscriptions, err := s.billingRepository.CountActiveSubscriptions(ctx, time.Now())
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load dashboard")
}
totalAdTemplates, err := s.adTemplateRepository.CountAll(ctx)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load dashboard")
}
newUsersToday, err := s.userRepository.CountCreatedSince(ctx, today)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load dashboard")
}
newVideosToday, err := s.videoRepository.CountCreatedSince(ctx, today)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to load dashboard")
}
dashboard := &appv1.AdminDashboard{
TotalUsers: totalUsers,
TotalVideos: totalVideos,
TotalStorageUsed: totalStorageUsed,
TotalPayments: totalPayments,
TotalRevenue: totalRevenue,
ActiveSubscriptions: activeSubscriptions,
TotalAdTemplates: totalAdTemplates,
NewUsersToday: newUsersToday,
NewVideosToday: newVideosToday,
}
return &appv1.GetAdminDashboardResponse{Dashboard: dashboard}, nil return &appv1.GetAdminDashboardResponse{Dashboard: dashboard}, nil
} }
@@ -43,26 +78,11 @@ func (s *appServices) ListAdminUsers(ctx context.Context, req *appv1.ListAdminUs
} }
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(req.GetSearch()) search := strings.TrimSpace(req.GetSearch())
role := strings.TrimSpace(req.GetRole()) role := strings.TrimSpace(req.GetRole())
db := s.db.WithContext(ctx).Model(&model.User{}) users, total, err := s.userRepository.ListForAdmin(ctx, search, role, limit, offset)
if search != "" { if err != nil {
like := "%" + search + "%"
db = db.Where("email ILIKE ? OR username ILIKE ?", like, like)
}
if role != "" {
db = db.Where("UPPER(role) = ?", strings.ToUpper(role))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list users")
}
var users []model.User
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&users).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list users") return nil, status.Error(codes.Internal, "Failed to list users")
} }
@@ -87,8 +107,8 @@ func (s *appServices) GetAdminUser(ctx context.Context, req *appv1.GetAdminUserR
return nil, status.Error(codes.NotFound, "User not found") return nil, status.Error(codes.NotFound, "User not found")
} }
var user model.User user, err := s.userRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "User not found") return nil, status.Error(codes.NotFound, "User not found")
} }
@@ -96,14 +116,13 @@ func (s *appServices) GetAdminUser(ctx context.Context, req *appv1.GetAdminUserR
} }
var subscription *model.PlanSubscription var subscription *model.PlanSubscription
var subscriptionRecord model.PlanSubscription if subscriptionRecord, err := s.billingRepository.GetLatestPlanSubscription(ctx, id); err == nil {
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil { subscription = subscriptionRecord
subscription = &subscriptionRecord
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.Internal, "Failed to get user") return nil, status.Error(codes.Internal, "Failed to get user")
} }
detail, err := s.buildAdminUserDetail(ctx, &user, subscription) detail, err := s.buildAdminUserDetail(ctx, user, subscription)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to get user") return nil, status.Error(codes.Internal, "Failed to get user")
} }
@@ -144,7 +163,7 @@ func (s *appServices) CreateAdminUser(ctx context.Context, req *appv1.CreateAdmi
PlanID: planID, PlanID: planID,
} }
if err := s.db.WithContext(ctx).Create(user).Error; err != nil { if err := s.userRepository.Create(ctx, user); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, status.Error(codes.AlreadyExists, "Email already registered") return nil, status.Error(codes.AlreadyExists, "Email already registered")
} }
@@ -207,36 +226,38 @@ func (s *appServices) UpdateAdminUser(ctx context.Context, req *appv1.UpdateAdmi
updates["password"] = string(hashedPassword) updates["password"] = string(hashedPassword)
} }
if len(updates) == 0 { if len(updates) == 0 {
var user model.User user, err := s.userRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "User not found") return nil, status.Error(codes.NotFound, "User not found")
} }
return nil, status.Error(codes.Internal, "Failed to update user") return nil, status.Error(codes.Internal, "Failed to update user")
} }
payload, err := s.buildAdminUser(ctx, &user) payload, err := s.buildAdminUser(ctx, user)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to update user") return nil, status.Error(codes.Internal, "Failed to update user")
} }
return &appv1.UpdateAdminUserResponse{User: payload}, nil return &appv1.UpdateAdminUserResponse{User: payload}, nil
} }
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates) if _, err := s.userRepository.GetByID(ctx, id); err != nil {
if result.Error != nil { if errors.Is(err, gorm.ErrRecordNotFound) {
if errors.Is(result.Error, gorm.ErrDuplicatedKey) { return nil, status.Error(codes.NotFound, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to update user")
}
if err := s.userRepository.UpdateFieldsByID(ctx, id, updates); err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, status.Error(codes.AlreadyExists, "Email already registered") return nil, status.Error(codes.AlreadyExists, "Email already registered")
} }
return nil, status.Error(codes.Internal, "Failed to update user") return nil, status.Error(codes.Internal, "Failed to update user")
} }
if result.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "User not found")
}
var user model.User user, err := s.userRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to update user") return nil, status.Error(codes.Internal, "Failed to update user")
} }
payload, err := s.buildAdminUser(ctx, &user) payload, err := s.buildAdminUser(ctx, user)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to update user") return nil, status.Error(codes.Internal, "Failed to update user")
} }
@@ -264,8 +285,8 @@ func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req *
} }
} }
var user model.User user, err := s.userRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "User not found") return nil, status.Error(codes.NotFound, "User not found")
} }
@@ -274,7 +295,7 @@ func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req *
updates := map[string]any{} updates := map[string]any{}
if req.RefUsername != nil || (req.ClearReferrer != nil && req.GetClearReferrer()) { if req.RefUsername != nil || (req.ClearReferrer != nil && req.GetClearReferrer()) {
if referralRewardProcessed(&user) { if referralRewardProcessed(user) {
return nil, status.Error(codes.InvalidArgument, "Cannot change referrer after reward has been granted") return nil, status.Error(codes.InvalidArgument, "Cannot change referrer after reward has been granted")
} }
if req.ClearReferrer != nil && req.GetClearReferrer() { if req.ClearReferrer != nil && req.GetClearReferrer() {
@@ -300,26 +321,22 @@ func (s *appServices) UpdateAdminUserReferralSettings(ctx context.Context, req *
} }
if len(updates) > 0 { if len(updates) > 0 {
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Updates(updates) if err := s.userRepository.UpdateFieldsByID(ctx, id, updates); err != nil {
if result.Error != nil {
return nil, status.Error(codes.Internal, "Failed to update referral settings") return nil, status.Error(codes.Internal, "Failed to update referral settings")
} }
if result.RowsAffected == 0 {
return nil, status.Error(codes.NotFound, "User not found")
}
} }
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { user, err = s.userRepository.GetByID(ctx, id)
if err != nil {
return nil, status.Error(codes.Internal, "Failed to update referral settings") return nil, status.Error(codes.Internal, "Failed to update referral settings")
} }
var subscription *model.PlanSubscription var subscription *model.PlanSubscription
var subscriptionRecord model.PlanSubscription if subscriptionRecord, err := s.billingRepository.GetLatestPlanSubscription(ctx, id); err == nil {
if err := s.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&subscriptionRecord).Error; err == nil { subscription = subscriptionRecord
subscription = &subscriptionRecord
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.Internal, "Failed to update referral settings") return nil, status.Error(codes.Internal, "Failed to update referral settings")
} }
payload, err := s.buildAdminUserDetail(ctx, &user, subscription) payload, err := s.buildAdminUserDetail(ctx, user, subscription)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to update referral settings") return nil, status.Error(codes.Internal, "Failed to update referral settings")
} }
@@ -344,12 +361,14 @@ func (s *appServices) UpdateAdminUserRole(ctx context.Context, req *appv1.Update
return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK") return nil, status.Error(codes.InvalidArgument, "Invalid role. Must be USER, ADMIN, or BLOCK")
} }
result := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", id).Update("role", role) if _, err := s.userRepository.GetByID(ctx, id); err != nil {
if result.Error != nil { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "User not found")
}
return nil, status.Error(codes.Internal, "Failed to update role") return nil, status.Error(codes.Internal, "Failed to update role")
} }
if result.RowsAffected == 0 { if err := s.userRepository.UpdateFieldsByID(ctx, id, map[string]any{"role": role}); err != nil {
return nil, status.Error(codes.NotFound, "User not found") return nil, status.Error(codes.Internal, "Failed to update role")
} }
return &appv1.UpdateAdminUserRoleResponse{Message: "Role updated", Role: role}, nil return &appv1.UpdateAdminUserRoleResponse{Message: "Role updated", Role: role}, nil
@@ -368,35 +387,14 @@ func (s *appServices) DeleteAdminUser(ctx context.Context, req *appv1.DeleteAdmi
return nil, status.Error(codes.InvalidArgument, "Cannot delete your own account") return nil, status.Error(codes.InvalidArgument, "Cannot delete your own account")
} }
var user model.User if _, err := s.userRepository.GetByID(ctx, id); err != nil {
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "User not found") return nil, status.Error(codes.NotFound, "User not found")
} }
return nil, status.Error(codes.Internal, "Failed to find user") return nil, status.Error(codes.Internal, "Failed to find user")
} }
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err = s.accountRepository.DeleteUserAccount(ctx, id)
tables := []struct {
model interface{}
where string
}{
{&model.AdTemplate{}, "user_id = ?"},
{&model.Notification{}, "user_id = ?"},
{&model.Domain{}, "user_id = ?"},
{&model.WalletTransaction{}, "user_id = ?"},
{&model.PlanSubscription{}, "user_id = ?"},
{&model.UserPreference{}, "user_id = ?"},
{&model.Video{}, "user_id = ?"},
{&model.Payment{}, "user_id = ?"},
}
for _, item := range tables {
if err := tx.Where(item.where, id).Delete(item.model).Error; err != nil {
return err
}
}
return tx.Where("id = ?", id).Delete(&model.User{}).Error
})
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to delete user") return nil, status.Error(codes.Internal, "Failed to delete user")
} }
@@ -409,30 +407,12 @@ func (s *appServices) ListAdminVideos(ctx context.Context, req *appv1.ListAdminV
} }
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
limitInt := int(limit)
search := strings.TrimSpace(req.GetSearch()) search := strings.TrimSpace(req.GetSearch())
userID := strings.TrimSpace(req.GetUserId()) userID := strings.TrimSpace(req.GetUserId())
statusFilter := strings.TrimSpace(req.GetStatus()) statusFilter := strings.TrimSpace(req.GetStatus())
db := s.db.WithContext(ctx).Model(&model.Video{}) videos, total, err := s.videoRepository.ListForAdmin(ctx, search, userID, normalizeVideoStatusValue(statusFilter), offset, int(limit))
if search != "" { if err != nil {
like := "%" + search + "%"
db = db.Where("title ILIKE ?", like)
}
if userID != "" {
db = db.Where("user_id = ?", userID)
}
if statusFilter != "" && !strings.EqualFold(statusFilter, "all") {
db = db.Where("status = ?", normalizeVideoStatusValue(statusFilter))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos")
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(limitInt).Find(&videos).Error; err != nil {
return nil, status.Error(codes.Internal, "Failed to list videos") return nil, status.Error(codes.Internal, "Failed to list videos")
} }
@@ -457,15 +437,15 @@ func (s *appServices) GetAdminVideo(ctx context.Context, req *appv1.GetAdminVide
return nil, status.Error(codes.NotFound, "Video not found") return nil, status.Error(codes.NotFound, "Video not found")
} }
var video model.Video video, err := s.videoRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found") return nil, status.Error(codes.NotFound, "Video not found")
} }
return nil, status.Error(codes.Internal, "Failed to get video") return nil, status.Error(codes.Internal, "Failed to get video")
} }
payload, err := s.buildAdminVideo(ctx, &video) payload, err := s.buildAdminVideo(ctx, video)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to get video") return nil, status.Error(codes.Internal, "Failed to get video")
} }
@@ -476,7 +456,7 @@ func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdm
if _, err := s.requireAdmin(ctx); err != nil { if _, err := s.requireAdmin(ctx); err != nil {
return nil, err return nil, err
} }
if s.videoService == nil { if s.videoWorkflowService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable") return nil, status.Error(codes.Unavailable, "Job service is unavailable")
} }
@@ -490,7 +470,7 @@ func (s *appServices) CreateAdminVideo(ctx context.Context, req *appv1.CreateAdm
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0") return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
} }
created, err := s.videoService.CreateVideo(ctx, CreateVideoInput{ created, err := s.videoWorkflowService.CreateVideo(ctx, CreateVideoInput{
UserID: userID, UserID: userID,
Title: title, Title: title,
Description: req.Description, Description: req.Description,
@@ -538,16 +518,16 @@ func (s *appServices) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdm
return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0") return nil, status.Error(codes.InvalidArgument, "Size must be greater than or equal to 0")
} }
var video model.Video video, err := s.videoRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found") return nil, status.Error(codes.NotFound, "Video not found")
} }
return nil, status.Error(codes.Internal, "Failed to update video") return nil, status.Error(codes.Internal, "Failed to update video")
} }
var user model.User user, err := s.userRepository.GetByID(ctx, userID)
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "User not found") return nil, status.Error(codes.InvalidArgument, "User not found")
} }
@@ -570,35 +550,15 @@ func (s *appServices) UpdateAdminVideo(ctx context.Context, req *appv1.UpdateAdm
video.ProcessingStatus = model.StringPtr(processingStatus) video.ProcessingStatus = model.StringPtr(processingStatus)
video.StorageType = model.StringPtr(detectStorageType(videoURL)) video.StorageType = model.StringPtr(detectStorageType(videoURL))
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { err = s.videoRepository.UpdateAdminVideo(ctx, video, oldUserID, oldSize, nullableTrimmedString(req.AdTemplateId))
if err := tx.Save(&video).Error; err != nil {
return err
}
if oldUserID == user.ID {
delta := video.Size - oldSize
if delta != 0 {
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used + ?, 0)", delta)).Error; err != nil {
return err
}
}
} else {
if err := tx.Model(&model.User{}).Where("id = ?", oldUserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", oldSize)).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
return err
}
}
return s.saveAdminVideoAdConfig(ctx, tx, &video, user.ID, nullableTrimmedString(req.AdTemplateId))
})
if err != nil { if err != nil {
if strings.Contains(err.Error(), "Ad template not found") { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.InvalidArgument, "Ad template not found") return nil, status.Error(codes.InvalidArgument, "Ad template not found")
} }
return nil, status.Error(codes.Internal, "Failed to update video") return nil, status.Error(codes.Internal, "Failed to update video")
} }
payload, err := s.buildAdminVideo(ctx, &video) payload, err := s.buildAdminVideo(ctx, video)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to update video") return nil, status.Error(codes.Internal, "Failed to update video")
} }
@@ -614,21 +574,15 @@ func (s *appServices) DeleteAdminVideo(ctx context.Context, req *appv1.DeleteAdm
return nil, status.Error(codes.NotFound, "Video not found") return nil, status.Error(codes.NotFound, "Video not found")
} }
var video model.Video video, err := s.videoRepository.GetByID(ctx, id)
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found") return nil, status.Error(codes.NotFound, "Video not found")
} }
return nil, status.Error(codes.Internal, "Failed to find video") return nil, status.Error(codes.Internal, "Failed to find video")
} }
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.videoRepository.DeleteByIDWithStorageUpdate(ctx, video.ID, video.UserID, video.Size); err != nil {
if err := tx.Where("id = ?", video.ID).Delete(&model.Video{}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", video.UserID).UpdateColumn("storage_used", gorm.Expr("GREATEST(storage_used - ?, 0)", video.Size)).Error
})
if err != nil {
return nil, status.Error(codes.Internal, "Failed to delete video") return nil, status.Error(codes.Internal, "Failed to delete video")
} }

View File

@@ -16,18 +16,16 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
appv1 "stream.api/internal/api/proto/app/v1" appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model" "stream.api/internal/database/model"
"stream.api/internal/database/query"
) )
func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) { func (s *authAppService) Login(ctx context.Context, req *appv1.LoginRequest) (*appv1.LoginResponse, error) {
email := strings.TrimSpace(req.GetEmail()) email := strings.TrimSpace(req.GetEmail())
password := req.GetPassword() password := req.GetPassword()
if email == "" || password == "" { if email == "" || password == "" {
return nil, status.Error(codes.InvalidArgument, "Email and password are required") return nil, status.Error(codes.InvalidArgument, "Email and password are required")
} }
u := query.User user, err := s.userRepository.GetByEmail(ctx, email)
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
if err != nil { if err != nil {
return nil, status.Error(codes.Unauthenticated, "Invalid credentials") return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
} }
@@ -38,13 +36,13 @@ func (s *appServices) Login(ctx context.Context, req *appv1.LoginRequest) (*appv
return nil, status.Error(codes.Unauthenticated, "Invalid credentials") return nil, status.Error(codes.Unauthenticated, "Invalid credentials")
} }
payload, err := buildUserPayload(ctx, s.db, user) payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, user)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")
} }
return &appv1.LoginResponse{User: toProtoUser(payload)}, nil return &appv1.LoginResponse{User: toProtoUser(payload)}, nil
} }
func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) { func (s *authAppService) Register(ctx context.Context, req *appv1.RegisterRequest) (*appv1.RegisterResponse, error) {
email := strings.TrimSpace(req.GetEmail()) email := strings.TrimSpace(req.GetEmail())
username := strings.TrimSpace(req.GetUsername()) username := strings.TrimSpace(req.GetUsername())
password := req.GetPassword() password := req.GetPassword()
@@ -53,8 +51,7 @@ func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest)
return nil, status.Error(codes.InvalidArgument, "Username, email and password are required") return nil, status.Error(codes.InvalidArgument, "Username, email and password are required")
} }
u := query.User count, err := s.userRepository.CountByEmail(ctx, email)
count, err := u.WithContext(ctx).Where(u.Email.Eq(email)).Count()
if err != nil { if err != nil {
s.logger.Error("Failed to check existing user", "error", err) s.logger.Error("Failed to check existing user", "error", err)
return nil, status.Error(codes.Internal, "Failed to register") return nil, status.Error(codes.Internal, "Failed to register")
@@ -85,21 +82,21 @@ func (s *appServices) Register(ctx context.Context, req *appv1.RegisterRequest)
ReferredByUserID: referrerID, ReferredByUserID: referrerID,
ReferralEligible: model.BoolPtr(true), ReferralEligible: model.BoolPtr(true),
} }
if err := u.WithContext(ctx).Create(newUser); err != nil { if err := s.userRepository.Create(ctx, newUser); err != nil {
s.logger.Error("Failed to create user", "error", err) s.logger.Error("Failed to create user", "error", err)
return nil, status.Error(codes.Internal, "Failed to register") return nil, status.Error(codes.Internal, "Failed to register")
} }
payload, err := buildUserPayload(ctx, s.db, newUser) payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, newUser)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")
} }
return &appv1.RegisterResponse{User: toProtoUser(payload)}, nil return &appv1.RegisterResponse{User: toProtoUser(payload)}, nil
} }
func (s *appServices) Logout(ctx context.Context, _ *appv1.LogoutRequest) (*appv1.MessageResponse, error) { func (s *authAppService) Logout(ctx context.Context, _ *appv1.LogoutRequest) (*appv1.MessageResponse, error) {
return messageResponse("Logged out"), nil return messageResponse("Logged out"), nil
} }
func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) { func (s *authAppService) ChangePassword(ctx context.Context, req *appv1.ChangePasswordRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -122,22 +119,19 @@ func (s *appServices) ChangePassword(ctx context.Context, req *appv1.ChangePassw
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to change password") return nil, status.Error(codes.Internal, "Failed to change password")
} }
if _, err := query.User.WithContext(ctx). if err := s.userRepository.UpdatePassword(ctx, result.UserID, string(newHash)); err != nil {
Where(query.User.ID.Eq(result.UserID)).
Update(query.User.Password, string(newHash)); err != nil {
s.logger.Error("Failed to change password", "error", err) s.logger.Error("Failed to change password", "error", err)
return nil, status.Error(codes.Internal, "Failed to change password") return nil, status.Error(codes.Internal, "Failed to change password")
} }
return messageResponse("Password changed successfully"), nil return messageResponse("Password changed successfully"), nil
} }
func (s *appServices) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) { func (s *authAppService) ForgotPassword(ctx context.Context, req *appv1.ForgotPasswordRequest) (*appv1.MessageResponse, error) {
email := strings.TrimSpace(req.GetEmail()) email := strings.TrimSpace(req.GetEmail())
if email == "" { if email == "" {
return nil, status.Error(codes.InvalidArgument, "Email is required") return nil, status.Error(codes.InvalidArgument, "Email is required")
} }
u := query.User user, err := s.userRepository.GetByEmail(ctx, email)
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
if err != nil { if err != nil {
return messageResponse("If email exists, a reset link has been sent"), nil return messageResponse("If email exists, a reset link has been sent"), nil
} }
@@ -151,7 +145,7 @@ func (s *appServices) ForgotPassword(ctx context.Context, req *appv1.ForgotPassw
s.logger.Info("Generated password reset token", "email", email, "token", tokenID) s.logger.Info("Generated password reset token", "email", email, "token", tokenID)
return messageResponse("If email exists, a reset link has been sent"), nil return messageResponse("If email exists, a reset link has been sent"), nil
} }
func (s *appServices) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) { func (s *authAppService) ResetPassword(ctx context.Context, req *appv1.ResetPasswordRequest) (*appv1.MessageResponse, error) {
resetToken := strings.TrimSpace(req.GetToken()) resetToken := strings.TrimSpace(req.GetToken())
newPassword := req.GetNewPassword() newPassword := req.GetNewPassword()
if resetToken == "" || newPassword == "" { if resetToken == "" || newPassword == "" {
@@ -168,9 +162,7 @@ func (s *appServices) ResetPassword(ctx context.Context, req *appv1.ResetPasswor
return nil, status.Error(codes.Internal, "Internal error") return nil, status.Error(codes.Internal, "Internal error")
} }
if _, err := query.User.WithContext(ctx). if err := s.userRepository.UpdatePassword(ctx, userID, string(hashedPassword)); err != nil {
Where(query.User.ID.Eq(userID)).
Update(query.User.Password, string(hashedPassword)); err != nil {
s.logger.Error("Failed to update password", "error", err) s.logger.Error("Failed to update password", "error", err)
return nil, status.Error(codes.Internal, "Failed to update password") return nil, status.Error(codes.Internal, "Failed to update password")
} }
@@ -178,7 +170,7 @@ func (s *appServices) ResetPassword(ctx context.Context, req *appv1.ResetPasswor
_ = s.cache.Del(ctx, "reset_pw:"+resetToken) _ = s.cache.Del(ctx, "reset_pw:"+resetToken)
return messageResponse("Password reset successfully"), nil return messageResponse("Password reset successfully"), nil
} }
func (s *appServices) GetGoogleLoginUrl(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) { func (s *authAppService) GetGoogleLoginUrl(ctx context.Context, _ *appv1.GetGoogleLoginUrlRequest) (*appv1.GetGoogleLoginUrlResponse, error) {
if err := s.authenticator.RequireInternalCall(ctx); err != nil { if err := s.authenticator.RequireInternalCall(ctx); err != nil {
return nil, err return nil, err
} }
@@ -200,7 +192,7 @@ func (s *appServices) GetGoogleLoginUrl(ctx context.Context, _ *appv1.GetGoogleL
loginURL := s.googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline) loginURL := s.googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline)
return &appv1.GetGoogleLoginUrlResponse{Url: loginURL}, nil return &appv1.GetGoogleLoginUrlResponse{Url: loginURL}, nil
} }
func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) { func (s *authAppService) CompleteGoogleLogin(ctx context.Context, req *appv1.CompleteGoogleLoginRequest) (*appv1.CompleteGoogleLoginResponse, error) {
if err := s.authenticator.RequireInternalCall(ctx); err != nil { if err := s.authenticator.RequireInternalCall(ctx); err != nil {
return nil, err return nil, err
} }
@@ -249,8 +241,7 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple
return nil, status.Error(codes.InvalidArgument, "missing_email") return nil, status.Error(codes.InvalidArgument, "missing_email")
} }
u := query.User user, err := s.userRepository.GetByEmail(ctx, email)
user, err := u.WithContext(ctx).Where(u.Email.Eq(email)).First()
if err != nil { if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) { if !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Error("Failed to load Google user", "error", err) s.logger.Error("Failed to load Google user", "error", err)
@@ -272,7 +263,7 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple
ReferredByUserID: referrerID, ReferredByUserID: referrerID,
ReferralEligible: model.BoolPtr(true), ReferralEligible: model.BoolPtr(true),
} }
if err := u.WithContext(ctx).Create(user); err != nil { if err := s.userRepository.Create(ctx, user); err != nil {
s.logger.Error("Failed to create Google user", "error", err) s.logger.Error("Failed to create Google user", "error", err)
return nil, status.Error(codes.Internal, "create_user_failed") return nil, status.Error(codes.Internal, "create_user_failed")
} }
@@ -288,11 +279,11 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple
updates["username"] = googleUser.Name updates["username"] = googleUser.Name
} }
if len(updates) > 0 { if len(updates) > 0 {
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil { if err := s.userRepository.UpdateFieldsByID(ctx, user.ID, updates); err != nil {
s.logger.Error("Failed to update Google user", "error", err) s.logger.Error("Failed to update Google user", "error", err)
return nil, status.Error(codes.Internal, "update_user_failed") return nil, status.Error(codes.Internal, "update_user_failed")
} }
user, err = u.WithContext(ctx).Where(u.ID.Eq(user.ID)).First() user, err = s.userRepository.GetByID(ctx, user.ID)
if err != nil { if err != nil {
s.logger.Error("Failed to reload Google user", "error", err) s.logger.Error("Failed to reload Google user", "error", err)
return nil, status.Error(codes.Internal, "reload_user_failed") return nil, status.Error(codes.Internal, "reload_user_failed")
@@ -300,7 +291,7 @@ func (s *appServices) CompleteGoogleLogin(ctx context.Context, req *appv1.Comple
} }
} }
payload, err := buildUserPayload(ctx, s.db, user) payload, err := buildUserPayload(ctx, s.preferenceRepository, s.billingRepository, user)
if err != nil { if err != nil {
return nil, status.Error(codes.Internal, "Failed to build user payload") return nil, status.Error(codes.Internal, "Failed to build user payload")
} }

View File

@@ -11,6 +11,7 @@ import (
"stream.api/internal/config" "stream.api/internal/config"
"stream.api/internal/database/model" "stream.api/internal/database/model"
"stream.api/internal/middleware" "stream.api/internal/middleware"
"stream.api/internal/repository"
"stream.api/pkg/logger" "stream.api/pkg/logger"
"stream.api/pkg/storage" "stream.api/pkg/storage"
) )
@@ -55,6 +56,18 @@ type Services struct {
appv1.AdminServer appv1.AdminServer
} }
type authAppService struct{ *appServices }
type accountAppService struct{ *appServices }
type usageAppService struct{ *appServices }
type notificationsAppService struct{ *appServices }
type domainsAppService struct{ *appServices }
type adTemplatesAppService struct{ *appServices }
type playerConfigsAppService struct{ *appServices }
type plansAppService struct{ *appServices }
type paymentsAppService struct{ *appServices }
type videosAppService struct{ *appServices }
type adminAppService struct{ *appServices }
type appServices struct { type appServices struct {
appv1.UnimplementedAuthServer appv1.UnimplementedAuthServer
appv1.UnimplementedAccountServer appv1.UnimplementedAccountServer
@@ -68,17 +81,29 @@ type appServices struct {
appv1.UnimplementedVideosServer appv1.UnimplementedVideosServer
appv1.UnimplementedAdminServer appv1.UnimplementedAdminServer
db *gorm.DB db *gorm.DB
logger logger.Logger logger logger.Logger
authenticator *middleware.Authenticator authenticator *middleware.Authenticator
cache *redis.RedisAdapter cache *redis.RedisAdapter
storageProvider storage.Provider storageProvider storage.Provider
videoService *Service videoWorkflowService VideoWorkflow
agentRuntime AgentRuntime videoRepository VideoRepository
googleOauth *oauth2.Config userRepository UserRepository
googleStateTTL time.Duration preferenceRepository UserPreferenceRepository
googleUserInfoURL string billingRepository BillingRepository
frontendBaseURL string planRepository PlanRepository
paymentRepository PaymentRepository
accountRepository AccountRepository
notificationRepo NotificationRepository
domainRepository DomainRepository
adTemplateRepository AdTemplateRepository
playerConfigRepo PlayerConfigRepository
agentRuntime AgentRuntime
googleOauth *oauth2.Config
googleStateTTL time.Duration
googleUserInfoURL string
frontendBaseURL string
jobRepository JobRepository
} }
type paymentInvoiceDetails struct { type paymentInvoiceDetails struct {
@@ -116,7 +141,7 @@ type apiErrorBody struct {
Data any `json:"data,omitempty"` Data any `json:"data,omitempty"`
} }
func NewServices(c *redis.RedisAdapter, db *gorm.DB, l logger.Logger, cfg *config.Config, videoService *Service, agentRuntime AgentRuntime) *Services { func NewServices(c *redis.RedisAdapter, db *gorm.DB, l logger.Logger, cfg *config.Config, videoWorkflowService VideoWorkflow, agentRuntime AgentRuntime) *Services {
var storageProvider storage.Provider var storageProvider storage.Provider
if cfg != nil { if cfg != nil {
provider, err := storage.NewS3Provider(cfg) provider, err := storage.NewS3Provider(cfg)
@@ -151,29 +176,41 @@ func NewServices(c *redis.RedisAdapter, db *gorm.DB, l logger.Logger, cfg *confi
} }
service := &appServices{ service := &appServices{
db: db, db: db,
logger: l, logger: l,
authenticator: middleware.NewAuthenticator(db, l, cfg.Internal.Marker), authenticator: middleware.NewAuthenticator(db, l, cfg.Internal.Marker),
cache: c, cache: c,
storageProvider: storageProvider, storageProvider: storageProvider,
videoService: videoService, videoWorkflowService: videoWorkflowService,
agentRuntime: agentRuntime, videoRepository: repository.NewVideoRepository(db),
googleOauth: googleOauth, userRepository: repository.NewUserRepository(db),
googleStateTTL: googleStateTTL, preferenceRepository: repository.NewUserPreferenceRepository(db),
googleUserInfoURL: defaultGoogleUserInfoURL, billingRepository: repository.NewBillingRepository(db),
frontendBaseURL: frontendBaseURL, planRepository: repository.NewPlanRepository(db),
paymentRepository: repository.NewPaymentRepository(db),
accountRepository: repository.NewAccountRepository(db),
notificationRepo: repository.NewNotificationRepository(db),
domainRepository: repository.NewDomainRepository(db),
adTemplateRepository: repository.NewAdTemplateRepository(db),
playerConfigRepo: repository.NewPlayerConfigRepository(db),
jobRepository: repository.NewJobRepository(db),
agentRuntime: agentRuntime,
googleOauth: googleOauth,
googleStateTTL: googleStateTTL,
googleUserInfoURL: defaultGoogleUserInfoURL,
frontendBaseURL: frontendBaseURL,
} }
return &Services{ return &Services{
AuthServer: service, AuthServer: &authAppService{appServices: service},
AccountServer: service, AccountServer: &accountAppService{appServices: service},
UsageServer: service, UsageServer: &usageAppService{appServices: service},
NotificationsServer: service, NotificationsServer: &notificationsAppService{appServices: service},
DomainsServer: service, DomainsServer: &domainsAppService{appServices: service},
AdTemplatesServer: service, AdTemplatesServer: &adTemplatesAppService{appServices: service},
PlayerConfigsServer: service, PlayerConfigsServer: &playerConfigsAppService{appServices: service},
PlansServer: service, PlansServer: &plansAppService{appServices: service},
PaymentsServer: service, PaymentsServer: &paymentsAppService{appServices: service},
VideosServer: service, VideosServer: &videosAppService{appServices: service},
AdminServer: service, AdminServer: &adminAppService{appServices: service},
} }
} }

View File

@@ -82,10 +82,6 @@ func (s *HealthService) checkDatabase(ctx context.Context) ComponentHealth {
if err := sqlDB.PingContext(ctx); err != nil { if err := sqlDB.PingContext(ctx); err != nil {
return ComponentHealth{Status: HealthStatusUnhealthy, Message: fmt.Sprintf("database ping failed: %v", err), Latency: time.Since(start).String(), CheckedAt: time.Now()} return ComponentHealth{Status: HealthStatusUnhealthy, Message: fmt.Sprintf("database ping failed: %v", err), Latency: time.Since(start).String(), CheckedAt: time.Now()}
} }
var result int
if err := s.db.WithContext(ctx).Raw("SELECT 1").Scan(&result).Error; err != nil {
return ComponentHealth{Status: HealthStatusUnhealthy, Message: fmt.Sprintf("database query failed: %v", err), Latency: time.Since(start).String(), CheckedAt: time.Now()}
}
return ComponentHealth{Status: HealthStatusHealthy, Latency: time.Since(start).String(), CheckedAt: time.Now()} return ComponentHealth{Status: HealthStatusHealthy, Latency: time.Since(start).String(), CheckedAt: time.Now()}
} }

View File

@@ -11,9 +11,10 @@ import (
"strings" "strings"
"time" "time"
"gorm.io/gorm"
"stream.api/internal/database/model" "stream.api/internal/database/model"
"stream.api/internal/database/query"
"stream.api/internal/dto" "stream.api/internal/dto"
"stream.api/internal/repository"
) )
type JobQueue interface { type JobQueue interface {
@@ -33,12 +34,17 @@ type LogPubSub interface {
} }
type JobService struct { type JobService struct {
queue JobQueue queue JobQueue
pubsub LogPubSub pubsub LogPubSub
jobRepository JobRepository
} }
func NewJobService(queue JobQueue, pubsub LogPubSub) *JobService { func NewJobService(db *gorm.DB, queue JobQueue, pubsub LogPubSub) *JobService {
return &JobService{queue: queue, pubsub: pubsub} return &JobService{
queue: queue,
pubsub: pubsub,
jobRepository: repository.NewJobRepository(db),
}
} }
var ErrInvalidJobCursor = errors.New("invalid job cursor") var ErrInvalidJobCursor = errors.New("invalid job cursor")
@@ -127,28 +133,16 @@ func buildJobListCursor(job *model.Job, agentID string) (string, error) {
}) })
} }
func listJobsByOffset(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) { func listJobsByOffset(ctx context.Context, jobRepository JobRepository, agentID string, offset, limit int) (*dto.PaginatedJobs, error) {
if offset < 0 { if offset < 0 {
offset = 0 offset = 0
} }
limit = normalizeJobPageSize(limit) limit = normalizeJobPageSize(limit)
q := query.Job.WithContext(ctx).Order(query.Job.CreatedAt.Desc(), query.Job.ID.Desc()) jobs, total, err := jobRepository.ListByOffset(ctx, agentID, offset, limit)
if agentID != "" {
agentNumeric, err := strconv.ParseInt(agentID, 10, 64)
if err != nil {
return &dto.PaginatedJobs{Jobs: []*model.Job{}, Total: 0, Offset: offset, Limit: limit, PageSize: limit, HasMore: false}, nil
}
q = q.Where(query.Job.AgentID.Eq(agentNumeric))
}
jobs, total, err := q.FindByPage(offset, limit)
if err != nil { if err != nil {
return nil, err return nil, err
} }
items := make([]*model.Job, 0, len(jobs)) return &dto.PaginatedJobs{Jobs: jobs, Total: total, Offset: offset, Limit: limit, PageSize: limit, HasMore: offset+len(jobs) < int(total)}, nil
for _, job := range jobs {
items = append(items, job)
}
return &dto.PaginatedJobs{Jobs: items, Total: total, Offset: offset, Limit: limit, PageSize: limit, HasMore: offset+len(items) < int(total)}, nil
} }
func (s *JobService) CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error) { func (s *JobService) CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error) {
@@ -165,10 +159,10 @@ func (s *JobService) CreateJob(ctx context.Context, userID string, videoID strin
CreatedAt: timePtr(now), CreatedAt: timePtr(now),
UpdatedAt: timePtr(now), UpdatedAt: timePtr(now),
} }
if err := query.Job.WithContext(ctx).Create(job); err != nil { if err := s.jobRepository.Create(ctx, job); err != nil {
return nil, err return nil, err
} }
if err := syncVideoStatus(ctx, videoID, dto.JobStatusPending); err != nil { if err := syncVideoStatus(ctx, s.jobRepository, videoID, dto.JobStatusPending); err != nil {
return nil, err return nil, err
} }
// dtoJob := todtoJob(job) // dtoJob := todtoJob(job)
@@ -180,11 +174,11 @@ func (s *JobService) CreateJob(ctx context.Context, userID string, videoID strin
} }
func (s *JobService) ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error) { func (s *JobService) ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error) {
return listJobsByOffset(ctx, "", offset, limit) return listJobsByOffset(ctx, s.jobRepository, "", offset, limit)
} }
func (s *JobService) ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) { func (s *JobService) ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) {
return listJobsByOffset(ctx, strings.TrimSpace(agentID), offset, limit) return listJobsByOffset(ctx, s.jobRepository, strings.TrimSpace(agentID), offset, limit)
} }
func (s *JobService) ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error) { func (s *JobService) ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error) {
@@ -199,39 +193,19 @@ func (s *JobService) ListJobsByCursor(ctx context.Context, agentID string, curso
return nil, ErrInvalidJobCursor return nil, ErrInvalidJobCursor
} }
q := query.Job.WithContext(ctx).Order(query.Job.CreatedAt.Desc(), query.Job.ID.Desc())
if agentID != "" {
agentNumeric, err := strconv.ParseInt(agentID, 10, 64)
if err != nil {
return &dto.PaginatedJobs{Jobs: []*model.Job{}, Total: 0, Limit: pageSize, PageSize: pageSize, HasMore: false}, nil
}
q = q.Where(query.Job.AgentID.Eq(agentNumeric))
}
var cursorTime time.Time var cursorTime time.Time
if decodedCursor != nil { if decodedCursor != nil {
cursorTime = time.Unix(0, decodedCursor.CreatedAtUnixNano).UTC() cursorTime = time.Unix(0, decodedCursor.CreatedAtUnixNano).UTC()
} }
cursorID := ""
queryDB := q.UnderlyingDB()
if decodedCursor != nil { if decodedCursor != nil {
queryDB = queryDB.Where("(created_at < ?) OR (created_at = ? AND id < ?)", cursorTime, cursorTime, decodedCursor.ID) cursorID = decodedCursor.ID
} }
jobs, hasMore, err := s.jobRepository.ListByCursor(ctx, agentID, cursorTime, cursorID, pageSize)
var jobs []*model.Job if err != nil {
if err := queryDB.Limit(pageSize + 1).Find(&jobs).Error; err != nil {
return nil, err return nil, err
} }
hasMore := len(jobs) > pageSize
if hasMore {
jobs = jobs[:pageSize]
}
items := make([]*model.Job, 0, len(jobs))
for _, job := range jobs {
items = append(items, job)
}
nextCursor := "" nextCursor := ""
if hasMore && len(jobs) > 0 { if hasMore && len(jobs) > 0 {
nextCursor, err = buildJobListCursor(jobs[len(jobs)-1], agentID) nextCursor, err = buildJobListCursor(jobs[len(jobs)-1], agentID)
@@ -241,7 +215,7 @@ func (s *JobService) ListJobsByCursor(ctx context.Context, agentID string, curso
} }
return &dto.PaginatedJobs{ return &dto.PaginatedJobs{
Jobs: items, Jobs: jobs,
Total: 0, Total: 0,
Limit: pageSize, Limit: pageSize,
PageSize: pageSize, PageSize: pageSize,
@@ -251,7 +225,7 @@ func (s *JobService) ListJobsByCursor(ctx context.Context, agentID string, curso
} }
func (s *JobService) GetJob(ctx context.Context, id string) (*model.Job, error) { func (s *JobService) GetJob(ctx context.Context, id string) (*model.Job, error) {
job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(id)).First() job, err := s.jobRepository.GetByID(ctx, id)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -275,25 +249,25 @@ func (s *JobService) SubscribeJobUpdates(ctx context.Context) (<-chan string, er
} }
func (s *JobService) UpdateJobStatus(ctx context.Context, jobID string, status dto.JobStatus) error { func (s *JobService) UpdateJobStatus(ctx context.Context, jobID string, status dto.JobStatus) error {
job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() job, err := s.jobRepository.GetByID(ctx, jobID)
if err != nil { if err != nil {
return err return err
} }
now := time.Now() now := time.Now()
job.Status = strPtr(string(status)) job.Status = strPtr(string(status))
job.UpdatedAt = &now job.UpdatedAt = &now
if err := query.Job.WithContext(ctx).Save(job); err != nil { if err := s.jobRepository.Save(ctx, job); err != nil {
return err return err
} }
cfg := parseJobConfig(job.Config) cfg := parseJobConfig(job.Config)
if err := syncVideoStatus(ctx, cfg.VideoID, status); err != nil { if err := syncVideoStatus(ctx, s.jobRepository, cfg.VideoID, status); err != nil {
return err return err
} }
return s.pubsub.PublishJobUpdate(ctx, jobID, string(status), cfg.VideoID) return s.pubsub.PublishJobUpdate(ctx, jobID, string(status), cfg.VideoID)
} }
func (s *JobService) AssignJob(ctx context.Context, jobID string, agentID string) error { func (s *JobService) AssignJob(ctx context.Context, jobID string, agentID string) error {
job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() job, err := s.jobRepository.GetByID(ctx, jobID)
if err != nil { if err != nil {
return err return err
} }
@@ -306,18 +280,18 @@ func (s *JobService) AssignJob(ctx context.Context, jobID string, agentID string
job.AgentID = &agentNumeric job.AgentID = &agentNumeric
job.Status = &status job.Status = &status
job.UpdatedAt = &now job.UpdatedAt = &now
if err := query.Job.WithContext(ctx).Save(job); err != nil { if err := s.jobRepository.Save(ctx, job); err != nil {
return err return err
} }
cfg := parseJobConfig(job.Config) cfg := parseJobConfig(job.Config)
if err := syncVideoStatus(ctx, cfg.VideoID, dto.JobStatusRunning); err != nil { if err := syncVideoStatus(ctx, s.jobRepository, cfg.VideoID, dto.JobStatusRunning); err != nil {
return err return err
} }
return s.pubsub.PublishJobUpdate(ctx, jobID, status, cfg.VideoID) return s.pubsub.PublishJobUpdate(ctx, jobID, status, cfg.VideoID)
} }
func (s *JobService) CancelJob(ctx context.Context, jobID string) error { func (s *JobService) CancelJob(ctx context.Context, jobID string) error {
job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() job, err := s.jobRepository.GetByID(ctx, jobID)
if err != nil { if err != nil {
return fmt.Errorf("job not found: %w", err) return fmt.Errorf("job not found: %w", err)
} }
@@ -334,11 +308,11 @@ func (s *JobService) CancelJob(ctx context.Context, jobID string) error {
job.Cancelled = &cancelled job.Cancelled = &cancelled
job.Status = &status job.Status = &status
job.UpdatedAt = &now job.UpdatedAt = &now
if err := query.Job.WithContext(ctx).Save(job); err != nil { if err := s.jobRepository.Save(ctx, job); err != nil {
return err return err
} }
cfg := parseJobConfig(job.Config) cfg := parseJobConfig(job.Config)
if err := syncVideoStatus(ctx, cfg.VideoID, dto.JobStatusCancelled); err != nil { if err := syncVideoStatus(ctx, s.jobRepository, cfg.VideoID, dto.JobStatusCancelled); err != nil {
return err return err
} }
_ = s.pubsub.PublishJobUpdate(ctx, jobID, status, cfg.VideoID) _ = s.pubsub.PublishJobUpdate(ctx, jobID, status, cfg.VideoID)
@@ -349,7 +323,7 @@ func (s *JobService) CancelJob(ctx context.Context, jobID string) error {
} }
func (s *JobService) RetryJob(ctx context.Context, jobID string) (*model.Job, error) { func (s *JobService) RetryJob(ctx context.Context, jobID string) (*model.Job, error) {
job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() job, err := s.jobRepository.GetByID(ctx, jobID)
if err != nil { if err != nil {
return nil, fmt.Errorf("job not found: %w", err) return nil, fmt.Errorf("job not found: %w", err)
} }
@@ -381,11 +355,11 @@ func (s *JobService) RetryJob(ctx context.Context, jobID string) (*model.Job, er
job.Progress = &progress job.Progress = &progress
job.AgentID = nil job.AgentID = nil
job.UpdatedAt = &now job.UpdatedAt = &now
if err := query.Job.WithContext(ctx).Save(job); err != nil { if err := s.jobRepository.Save(ctx, job); err != nil {
return nil, err return nil, err
} }
cfg := parseJobConfig(job.Config) cfg := parseJobConfig(job.Config)
if err := syncVideoStatus(ctx, cfg.VideoID, dto.JobStatusPending); err != nil { if err := syncVideoStatus(ctx, s.jobRepository, cfg.VideoID, dto.JobStatusPending); err != nil {
return nil, err return nil, err
} }
// dtoJob := todtoJob(job) // dtoJob := todtoJob(job)
@@ -397,14 +371,14 @@ func (s *JobService) RetryJob(ctx context.Context, jobID string) (*model.Job, er
} }
func (s *JobService) UpdateJobProgress(ctx context.Context, jobID string, progress float64) error { func (s *JobService) UpdateJobProgress(ctx context.Context, jobID string, progress float64) error {
job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() job, err := s.jobRepository.GetByID(ctx, jobID)
if err != nil { if err != nil {
return err return err
} }
now := time.Now() now := time.Now()
job.Progress = float64Ptr(progress) job.Progress = float64Ptr(progress)
job.UpdatedAt = &now job.UpdatedAt = &now
if err := query.Job.WithContext(ctx).Save(job); err != nil { if err := s.jobRepository.Save(ctx, job); err != nil {
return err return err
} }
return s.pubsub.Publish(ctx, jobID, "", progress) return s.pubsub.Publish(ctx, jobID, "", progress)
@@ -421,7 +395,7 @@ func (s *JobService) ProcessLog(ctx context.Context, jobID string, logData []byt
progress = float64(us) / 1000000.0 progress = float64(us) / 1000000.0
} }
} }
job, err := query.Job.WithContext(ctx).Where(query.Job.ID.Eq(jobID)).First() job, err := s.jobRepository.GetByID(ctx, jobID)
if err != nil { if err != nil {
return err return err
} }
@@ -443,13 +417,13 @@ func (s *JobService) ProcessLog(ctx context.Context, jobID string, logData []byt
job.Progress = float64Ptr(progress) job.Progress = float64Ptr(progress)
} }
job.UpdatedAt = &now job.UpdatedAt = &now
if err := query.Job.WithContext(ctx).Save(job); err != nil { if err := s.jobRepository.Save(ctx, job); err != nil {
return err return err
} }
return s.pubsub.Publish(ctx, jobID, line, progress) return s.pubsub.Publish(ctx, jobID, line, progress)
} }
func syncVideoStatus(ctx context.Context, videoID string, status dto.JobStatus) error { func syncVideoStatus(ctx context.Context, jobRepository JobRepository, videoID string, status dto.JobStatus) error {
videoID = strings.TrimSpace(videoID) videoID = strings.TrimSpace(videoID)
if videoID == "" { if videoID == "" {
return nil return nil
@@ -466,10 +440,7 @@ func syncVideoStatus(ctx context.Context, videoID string, status dto.JobStatus)
processingStatus = "FAILED" processingStatus = "FAILED"
} }
_, err := query.Video.WithContext(ctx). return jobRepository.UpdateVideoStatus(ctx, videoID, statusValue, processingStatus)
Where(query.Video.ID.Eq(videoID)).
Updates(map[string]any{"status": statusValue, "processing_status": processingStatus})
return err
} }
func (s *JobService) PublishSystemResources(ctx context.Context, agentID string, data []byte) error { func (s *JobService) PublishSystemResources(ctx context.Context, agentID string, data []byte) error {

View File

@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
@@ -13,10 +12,9 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
appv1 "stream.api/internal/api/proto/app/v1" appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model" "stream.api/internal/database/model"
"stream.api/internal/database/query"
) )
func (s *appServices) CreatePayment(ctx context.Context, req *appv1.CreatePaymentRequest) (*appv1.CreatePaymentResponse, error) { func (s *paymentsAppService) CreatePayment(ctx context.Context, req *appv1.CreatePaymentRequest) (*appv1.CreatePaymentResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -63,7 +61,7 @@ func (s *appServices) CreatePayment(ctx context.Context, req *appv1.CreatePaymen
Message: "Payment completed successfully", Message: "Payment completed successfully",
}, nil }, nil
} }
func (s *appServices) ListPaymentHistory(ctx context.Context, req *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) { func (s *paymentsAppService) ListPaymentHistory(ctx context.Context, req *appv1.ListPaymentHistoryRequest) (*appv1.ListPaymentHistoryResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -71,71 +69,8 @@ func (s *appServices) ListPaymentHistory(ctx context.Context, req *appv1.ListPay
page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit()) page, limit, offset := adminPageLimitOffset(req.GetPage(), req.GetLimit())
type paymentHistoryRow struct { rows, total, err := s.paymentRepository.ListHistoryByUser(ctx, result.UserID, paymentKindSubscription, paymentKindWalletTopup, walletTransactionTypeTopup, limit, offset)
ID string `gorm:"column:id"` if err != nil {
Amount float64 `gorm:"column:amount"`
Currency *string `gorm:"column:currency"`
Status *string `gorm:"column:status"`
PlanID *string `gorm:"column:plan_id"`
PlanName *string `gorm:"column:plan_name"`
InvoiceID string `gorm:"column:invoice_id"`
Kind string `gorm:"column:kind"`
TermMonths *int32 `gorm:"column:term_months"`
PaymentMethod *string `gorm:"column:payment_method"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
CreatedAt *time.Time `gorm:"column:created_at"`
}
baseQuery := `
WITH history AS (
SELECT
p.id AS id,
p.amount AS amount,
p.currency AS currency,
p.status AS status,
p.plan_id AS plan_id,
pl.name AS plan_name,
p.id AS invoice_id,
? AS kind,
ps.term_months AS term_months,
ps.payment_method AS payment_method,
ps.expires_at AS expires_at,
p.created_at AS created_at
FROM payment AS p
LEFT JOIN plan AS pl ON pl.id = p.plan_id
LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id
WHERE p.user_id = ?
UNION ALL
SELECT
wt.id AS id,
wt.amount AS amount,
wt.currency AS currency,
'SUCCESS' AS status,
NULL AS plan_id,
NULL AS plan_name,
wt.id AS invoice_id,
? AS kind,
NULL AS term_months,
NULL AS payment_method,
NULL AS expires_at,
wt.created_at AS created_at
FROM wallet_transactions AS wt
WHERE wt.user_id = ? AND wt.type = ? AND wt.payment_id IS NULL
)
`
var total int64
if err := s.db.WithContext(ctx).
Raw(baseQuery+`SELECT COUNT(*) FROM history`, paymentKindSubscription, result.UserID, paymentKindWalletTopup, result.UserID, walletTransactionTypeTopup).
Scan(&total).Error; err != nil {
s.logger.Error("Failed to count payment history", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch payment history")
}
var rows []paymentHistoryRow
if err := s.db.WithContext(ctx).
Raw(baseQuery+`SELECT * FROM history ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, paymentKindSubscription, result.UserID, paymentKindWalletTopup, result.UserID, walletTransactionTypeTopup, limit, offset).
Scan(&rows).Error; err != nil {
s.logger.Error("Failed to fetch payment history", "error", err) s.logger.Error("Failed to fetch payment history", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch payment history") return nil, status.Error(codes.Internal, "Failed to fetch payment history")
} }
@@ -170,7 +105,7 @@ func (s *appServices) ListPaymentHistory(ctx context.Context, req *appv1.ListPay
}, nil }, nil
} }
func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) { func (s *paymentsAppService) TopupWallet(ctx context.Context, req *appv1.TopupWalletRequest) (*appv1.TopupWalletResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -202,23 +137,12 @@ func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletReq
})), })),
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.paymentRepository.CreateWalletTopupAndNotification(ctx, result.UserID, transaction, notification); err != nil {
if _, err := lockUserForUpdate(ctx, tx, result.UserID); err != nil {
return err
}
if err := tx.Create(transaction).Error; err != nil {
return err
}
if err := tx.Create(notification).Error; err != nil {
return err
}
return nil
}); err != nil {
s.logger.Error("Failed to top up wallet", "error", err) s.logger.Error("Failed to top up wallet", "error", err)
return nil, status.Error(codes.Internal, "Failed to top up wallet") return nil, status.Error(codes.Internal, "Failed to top up wallet")
} }
balance, err := model.GetWalletBalance(ctx, s.db, result.UserID) balance, err := s.billingRepository.GetWalletBalance(ctx, result.UserID)
if err != nil { if err != nil {
s.logger.Error("Failed to calculate wallet balance", "error", err) s.logger.Error("Failed to calculate wallet balance", "error", err)
return nil, status.Error(codes.Internal, "Failed to top up wallet") return nil, status.Error(codes.Internal, "Failed to top up wallet")
@@ -230,7 +154,7 @@ func (s *appServices) TopupWallet(ctx context.Context, req *appv1.TopupWalletReq
InvoiceId: buildInvoiceID(transaction.ID), InvoiceId: buildInvoiceID(transaction.ID),
}, nil }, nil
} }
func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadInvoiceRequest) (*appv1.DownloadInvoiceResponse, error) { func (s *paymentsAppService) DownloadInvoice(ctx context.Context, req *appv1.DownloadInvoiceRequest) (*appv1.DownloadInvoiceResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -241,9 +165,7 @@ func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadIn
return nil, status.Error(codes.NotFound, "Invoice not found") return nil, status.Error(codes.NotFound, "Invoice not found")
} }
paymentRecord, err := query.Payment.WithContext(ctx). paymentRecord, err := s.paymentRepository.GetByIDAndUser(ctx, id, result.UserID)
Where(query.Payment.ID.Eq(id), query.Payment.UserID.Eq(result.UserID)).
First()
if err == nil { if err == nil {
invoiceText, filename, buildErr := s.buildPaymentInvoice(ctx, paymentRecord) invoiceText, filename, buildErr := s.buildPaymentInvoice(ctx, paymentRecord)
if buildErr != nil { if buildErr != nil {
@@ -261,14 +183,12 @@ func (s *appServices) DownloadInvoice(ctx context.Context, req *appv1.DownloadIn
return nil, status.Error(codes.Internal, "Failed to download invoice") return nil, status.Error(codes.Internal, "Failed to download invoice")
} }
var topup model.WalletTransaction topup, err := s.paymentRepository.GetStandaloneTopupByIDAndUser(ctx, id, result.UserID, walletTransactionTypeTopup)
if err := s.db.WithContext(ctx). if err == nil {
Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", id, result.UserID, walletTransactionTypeTopup).
First(&topup).Error; err == nil {
return &appv1.DownloadInvoiceResponse{ return &appv1.DownloadInvoiceResponse{
Filename: buildInvoiceFilename(topup.ID), Filename: buildInvoiceFilename(topup.ID),
ContentType: "text/plain; charset=utf-8", ContentType: "text/plain; charset=utf-8",
Content: buildTopupInvoice(&topup), Content: buildTopupInvoice(topup),
}, nil }, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) { } else if !errors.Is(err, gorm.ErrRecordNotFound) {
s.logger.Error("Failed to load topup invoice", "error", err) s.logger.Error("Failed to load topup invoice", "error", err)

View File

@@ -0,0 +1,52 @@
package service
import (
"strings"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"stream.api/internal/database/model"
)
func ensurePaidPlan(user *model.User) error {
if user == nil {
return status.Error(codes.Unauthenticated, "Unauthorized")
}
if user.PlanID == nil || strings.TrimSpace(*user.PlanID) == "" {
return status.Error(codes.PermissionDenied, adTemplateUpgradeRequiredMessage)
}
return nil
}
func playerConfigActionAllowed(user *model.User, configCount int64, action string) error {
if user == nil {
return status.Error(codes.Unauthenticated, "Unauthorized")
}
if user.PlanID != nil && strings.TrimSpace(*user.PlanID) != "" {
return nil
}
switch action {
case "create":
if configCount > 0 {
return status.Error(codes.FailedPrecondition, playerConfigFreePlanLimitMessage)
}
return nil
case "delete":
return nil
case "update", "set-default", "toggle-active":
if configCount > 1 {
return status.Error(codes.FailedPrecondition, playerConfigFreePlanReconciliationMessage)
}
return nil
default:
return nil
}
}
func safeRole(role *string) string {
if role == nil || strings.TrimSpace(*role) == "" {
return "USER"
}
return *role
}

View File

@@ -1,225 +0,0 @@
package service
import (
"context"
"encoding/json"
"errors"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/dto"
)
type AgentRuntime interface {
ListAgentsWithStats() []*dto.AgentWithStats
SendCommand(agentID string, cmd string) bool
}
var (
ErrUserNotFound = errors.New("user not found")
ErrAdTemplateNotFound = errors.New("ad template not found")
ErrJobServiceUnavailable = errors.New("job service is unavailable")
)
type Service struct {
db *gorm.DB
jobService *JobService
}
type CreateVideoInput struct {
UserID string
Title string
Description *string
URL string
Size int64
Duration int32
Format string
AdTemplateID *string
}
type CreateVideoResult struct {
Video *model.Video
Job model.Job
}
func NewService(db *gorm.DB, jobService *JobService) *Service {
return &Service{db: db, jobService: jobService}
}
func (s *Service) JobService() *JobService {
if s == nil {
return nil
}
return s.jobService
}
func (s *Service) CreateVideo(ctx context.Context, input CreateVideoInput) (*CreateVideoResult, error) {
if s == nil || s.db == nil {
return nil, gorm.ErrInvalidDB
}
userID := strings.TrimSpace(input.UserID)
if userID == "" {
return nil, ErrUserNotFound
}
var user model.User
if err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
title := strings.TrimSpace(input.Title)
videoURL := strings.TrimSpace(input.URL)
format := strings.TrimSpace(input.Format)
statusValue := "processing"
processingStatus := "PENDING"
storageType := detectStorageType(videoURL)
video := &model.Video{
ID: uuid.NewString(),
UserID: user.ID,
Name: title,
Title: title,
Description: nullableTrimmedString(input.Description),
URL: videoURL,
Size: input.Size,
Duration: input.Duration,
Format: format,
Status: model.StringPtr(statusValue),
ProcessingStatus: model.StringPtr(processingStatus),
StorageType: model.StringPtr(storageType),
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(video).Error; err != nil {
return err
}
if err := tx.Model(&model.User{}).Where("id = ?", user.ID).UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil {
return err
}
return saveVideoAdConfig(ctx, tx, video, user.ID, input.AdTemplateID)
}); err != nil {
return nil, err
}
if s.jobService == nil {
_ = markVideoJobFailed(ctx, s.db, video.ID)
return nil, ErrJobServiceUnavailable
}
jobPayload, err := buildJobPayload(video.ID, user.ID, videoURL, format)
if err != nil {
_ = markVideoJobFailed(ctx, s.db, video.ID)
return nil, err
}
job, err := s.jobService.CreateJob(ctx, user.ID, video.ID, title, jobPayload, 0, 0)
if err != nil {
_ = markVideoJobFailed(ctx, s.db, video.ID)
return nil, err
}
return &CreateVideoResult{Video: video, Job: *job}, nil
}
func (s *Service) ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error) {
if s == nil || s.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return s.jobService.ListJobs(ctx, offset, limit)
}
func (s *Service) ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) {
if s == nil || s.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return s.jobService.ListJobsByAgent(ctx, agentID, offset, limit)
}
func (s *Service) ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error) {
if s == nil || s.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return s.jobService.ListJobsByCursor(ctx, agentID, cursor, pageSize)
}
func (s *Service) GetJob(ctx context.Context, id string) (*model.Job, error) {
if s == nil || s.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return s.jobService.GetJob(ctx, id)
}
func (s *Service) CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error) {
if s == nil || s.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return s.jobService.CreateJob(ctx, userID, videoID, name, config, priority, timeLimit)
}
func (s *Service) CancelJob(ctx context.Context, id string) error {
if s == nil || s.jobService == nil {
return ErrJobServiceUnavailable
}
return s.jobService.CancelJob(ctx, id)
}
func (s *Service) RetryJob(ctx context.Context, id string) (*model.Job, error) {
if s == nil || s.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return s.jobService.RetryJob(ctx, id)
}
func buildJobPayload(videoID, userID, videoURL, format string) ([]byte, error) {
return json.Marshal(map[string]any{
"video_id": videoID,
"user_id": userID,
"input_url": videoURL,
"source_url": videoURL,
"format": format,
})
}
func saveVideoAdConfig(ctx context.Context, tx *gorm.DB, video *model.Video, userID string, adTemplateID *string) error {
if video == nil || adTemplateID == nil {
return nil
}
trimmed := strings.TrimSpace(*adTemplateID)
if trimmed == "" {
if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", nil).Error; err != nil {
return err
}
video.AdID = nil
return nil
}
var template model.AdTemplate
if err := tx.WithContext(ctx).Select("id").Where("id = ? AND user_id = ?", trimmed, userID).First(&template).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrAdTemplateNotFound
}
return err
}
if err := tx.WithContext(ctx).Model(&model.Video{}).Where("id = ?", video.ID).Update("ad_id", template.ID).Error; err != nil {
return err
}
video.AdID = &template.ID
return nil
}
func markVideoJobFailed(ctx context.Context, db *gorm.DB, videoID string) error {
if db == nil {
return nil
}
return db.WithContext(ctx).
Model(&model.Video{}).
Where("id = ?", strings.TrimSpace(videoID)).
Updates(map[string]any{"status": "failed", "processing_status": "FAILED"}).Error
}

View File

@@ -13,17 +13,14 @@ import (
"stream.api/internal/database/model" "stream.api/internal/database/model"
) )
func (s *appServices) ListNotifications(ctx context.Context, _ *appv1.ListNotificationsRequest) (*appv1.ListNotificationsResponse, error) { func (s *notificationsAppService) ListNotifications(ctx context.Context, _ *appv1.ListNotificationsRequest) (*appv1.ListNotificationsResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var rows []model.Notification rows, err := s.notificationRepo.ListByUser(ctx, result.UserID)
if err := s.db.WithContext(ctx). if err != nil {
Where("user_id = ?", result.UserID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
s.logger.Error("Failed to list notifications", "error", err) s.logger.Error("Failed to list notifications", "error", err)
return nil, status.Error(codes.Internal, "Failed to load notifications") return nil, status.Error(codes.Internal, "Failed to load notifications")
} }
@@ -35,7 +32,7 @@ func (s *appServices) ListNotifications(ctx context.Context, _ *appv1.ListNotifi
return &appv1.ListNotificationsResponse{Notifications: items}, nil return &appv1.ListNotificationsResponse{Notifications: items}, nil
} }
func (s *appServices) MarkNotificationRead(ctx context.Context, req *appv1.MarkNotificationReadRequest) (*appv1.MessageResponse, error) { func (s *notificationsAppService) MarkNotificationRead(ctx context.Context, req *appv1.MarkNotificationReadRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -46,37 +43,31 @@ func (s *appServices) MarkNotificationRead(ctx context.Context, req *appv1.MarkN
return nil, status.Error(codes.NotFound, "Notification not found") return nil, status.Error(codes.NotFound, "Notification not found")
} }
res := s.db.WithContext(ctx). rowsAffected, err := s.notificationRepo.MarkReadByIDAndUser(ctx, id, result.UserID)
Model(&model.Notification{}). if err != nil {
Where("id = ? AND user_id = ?", id, result.UserID). s.logger.Error("Failed to update notification", "error", err)
Update("is_read", true)
if res.Error != nil {
s.logger.Error("Failed to update notification", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to update notification") return nil, status.Error(codes.Internal, "Failed to update notification")
} }
if res.RowsAffected == 0 { if rowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Notification not found") return nil, status.Error(codes.NotFound, "Notification not found")
} }
return messageResponse("Notification updated"), nil return messageResponse("Notification updated"), nil
} }
func (s *appServices) MarkAllNotificationsRead(ctx context.Context, _ *appv1.MarkAllNotificationsReadRequest) (*appv1.MessageResponse, error) { func (s *notificationsAppService) MarkAllNotificationsRead(ctx context.Context, _ *appv1.MarkAllNotificationsReadRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := s.db.WithContext(ctx). if err := s.notificationRepo.MarkAllReadByUser(ctx, result.UserID); err != nil {
Model(&model.Notification{}).
Where("user_id = ? AND is_read = ?", result.UserID, false).
Update("is_read", true).Error; err != nil {
s.logger.Error("Failed to mark all notifications as read", "error", err) s.logger.Error("Failed to mark all notifications as read", "error", err)
return nil, status.Error(codes.Internal, "Failed to update notifications") return nil, status.Error(codes.Internal, "Failed to update notifications")
} }
return messageResponse("All notifications marked as read"), nil return messageResponse("All notifications marked as read"), nil
} }
func (s *appServices) DeleteNotification(ctx context.Context, req *appv1.DeleteNotificationRequest) (*appv1.MessageResponse, error) { func (s *notificationsAppService) DeleteNotification(ctx context.Context, req *appv1.DeleteNotificationRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -87,43 +78,38 @@ func (s *appServices) DeleteNotification(ctx context.Context, req *appv1.DeleteN
return nil, status.Error(codes.NotFound, "Notification not found") return nil, status.Error(codes.NotFound, "Notification not found")
} }
res := s.db.WithContext(ctx). rowsAffected, err := s.notificationRepo.DeleteByIDAndUser(ctx, id, result.UserID)
Where("id = ? AND user_id = ?", id, result.UserID). if err != nil {
Delete(&model.Notification{}) s.logger.Error("Failed to delete notification", "error", err)
if res.Error != nil {
s.logger.Error("Failed to delete notification", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to delete notification") return nil, status.Error(codes.Internal, "Failed to delete notification")
} }
if res.RowsAffected == 0 { if rowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Notification not found") return nil, status.Error(codes.NotFound, "Notification not found")
} }
return messageResponse("Notification deleted"), nil return messageResponse("Notification deleted"), nil
} }
func (s *appServices) ClearNotifications(ctx context.Context, _ *appv1.ClearNotificationsRequest) (*appv1.MessageResponse, error) { func (s *notificationsAppService) ClearNotifications(ctx context.Context, _ *appv1.ClearNotificationsRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := s.db.WithContext(ctx).Where("user_id = ?", result.UserID).Delete(&model.Notification{}).Error; err != nil { if err := s.notificationRepo.DeleteAllByUser(ctx, result.UserID); err != nil {
s.logger.Error("Failed to clear notifications", "error", err) s.logger.Error("Failed to clear notifications", "error", err)
return nil, status.Error(codes.Internal, "Failed to clear notifications") return nil, status.Error(codes.Internal, "Failed to clear notifications")
} }
return messageResponse("All notifications deleted"), nil return messageResponse("All notifications deleted"), nil
} }
func (s *appServices) ListDomains(ctx context.Context, _ *appv1.ListDomainsRequest) (*appv1.ListDomainsResponse, error) { func (s *domainsAppService) ListDomains(ctx context.Context, _ *appv1.ListDomainsRequest) (*appv1.ListDomainsResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var rows []model.Domain rows, err := s.domainRepository.ListByUser(ctx, result.UserID)
if err := s.db.WithContext(ctx). if err != nil {
Where("user_id = ?", result.UserID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
s.logger.Error("Failed to list domains", "error", err) s.logger.Error("Failed to list domains", "error", err)
return nil, status.Error(codes.Internal, "Failed to load domains") return nil, status.Error(codes.Internal, "Failed to load domains")
} }
@@ -136,7 +122,7 @@ func (s *appServices) ListDomains(ctx context.Context, _ *appv1.ListDomainsReque
return &appv1.ListDomainsResponse{Domains: items}, nil return &appv1.ListDomainsResponse{Domains: items}, nil
} }
func (s *appServices) CreateDomain(ctx context.Context, req *appv1.CreateDomainRequest) (*appv1.CreateDomainResponse, error) { func (s *domainsAppService) CreateDomain(ctx context.Context, req *appv1.CreateDomainRequest) (*appv1.CreateDomainResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -147,11 +133,8 @@ func (s *appServices) CreateDomain(ctx context.Context, req *appv1.CreateDomainR
return nil, status.Error(codes.InvalidArgument, "Invalid domain") return nil, status.Error(codes.InvalidArgument, "Invalid domain")
} }
var count int64 count, err := s.domainRepository.CountByUserAndName(ctx, result.UserID, name)
if err := s.db.WithContext(ctx). if err != nil {
Model(&model.Domain{}).
Where("user_id = ? AND name = ?", result.UserID, name).
Count(&count).Error; err != nil {
s.logger.Error("Failed to validate domain", "error", err) s.logger.Error("Failed to validate domain", "error", err)
return nil, status.Error(codes.Internal, "Failed to create domain") return nil, status.Error(codes.Internal, "Failed to create domain")
} }
@@ -164,14 +147,14 @@ func (s *appServices) CreateDomain(ctx context.Context, req *appv1.CreateDomainR
UserID: result.UserID, UserID: result.UserID,
Name: name, Name: name,
} }
if err := s.db.WithContext(ctx).Create(item).Error; err != nil { if err := s.domainRepository.Create(ctx, item); err != nil {
s.logger.Error("Failed to create domain", "error", err) s.logger.Error("Failed to create domain", "error", err)
return nil, status.Error(codes.Internal, "Failed to create domain") return nil, status.Error(codes.Internal, "Failed to create domain")
} }
return &appv1.CreateDomainResponse{Domain: toProtoDomain(item)}, nil return &appv1.CreateDomainResponse{Domain: toProtoDomain(item)}, nil
} }
func (s *appServices) DeleteDomain(ctx context.Context, req *appv1.DeleteDomainRequest) (*appv1.MessageResponse, error) { func (s *domainsAppService) DeleteDomain(ctx context.Context, req *appv1.DeleteDomainRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -182,31 +165,25 @@ func (s *appServices) DeleteDomain(ctx context.Context, req *appv1.DeleteDomainR
return nil, status.Error(codes.NotFound, "Domain not found") return nil, status.Error(codes.NotFound, "Domain not found")
} }
res := s.db.WithContext(ctx). rowsAffected, err := s.domainRepository.DeleteByIDAndUser(ctx, id, result.UserID)
Where("id = ? AND user_id = ?", id, result.UserID). if err != nil {
Delete(&model.Domain{}) s.logger.Error("Failed to delete domain", "error", err)
if res.Error != nil {
s.logger.Error("Failed to delete domain", "error", res.Error)
return nil, status.Error(codes.Internal, "Failed to delete domain") return nil, status.Error(codes.Internal, "Failed to delete domain")
} }
if res.RowsAffected == 0 { if rowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Domain not found") return nil, status.Error(codes.NotFound, "Domain not found")
} }
return messageResponse("Domain deleted"), nil return messageResponse("Domain deleted"), nil
} }
func (s *appServices) ListAdTemplates(ctx context.Context, _ *appv1.ListAdTemplatesRequest) (*appv1.ListAdTemplatesResponse, error) { func (s *adTemplatesAppService) ListAdTemplates(ctx context.Context, _ *appv1.ListAdTemplatesRequest) (*appv1.ListAdTemplatesResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var items []model.AdTemplate items, err := s.adTemplateRepository.ListByUser(ctx, result.UserID)
if err := s.db.WithContext(ctx). if err != nil {
Where("user_id = ?", result.UserID).
Order("is_default DESC").
Order("created_at DESC").
Find(&items).Error; err != nil {
s.logger.Error("Failed to list ad templates", "error", err) s.logger.Error("Failed to list ad templates", "error", err)
return nil, status.Error(codes.Internal, "Failed to load ad templates") return nil, status.Error(codes.Internal, "Failed to load ad templates")
} }
@@ -219,7 +196,7 @@ func (s *appServices) ListAdTemplates(ctx context.Context, _ *appv1.ListAdTempla
return &appv1.ListAdTemplatesResponse{Templates: payload}, nil return &appv1.ListAdTemplatesResponse{Templates: payload}, nil
} }
func (s *appServices) CreateAdTemplate(ctx context.Context, req *appv1.CreateAdTemplateRequest) (*appv1.CreateAdTemplateResponse, error) { func (s *adTemplatesAppService) CreateAdTemplate(ctx context.Context, req *appv1.CreateAdTemplateRequest) (*appv1.CreateAdTemplateResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -254,21 +231,14 @@ func (s *appServices) CreateAdTemplate(ctx context.Context, req *appv1.CreateAdT
item.IsDefault = false item.IsDefault = false
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.adTemplateRepository.CreateWithDefault(ctx, result.UserID, item); err != nil {
if item.IsDefault {
if err := unsetDefaultTemplates(tx, result.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil {
s.logger.Error("Failed to create ad template", "error", err) s.logger.Error("Failed to create ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template") return nil, status.Error(codes.Internal, "Failed to save ad template")
} }
return &appv1.CreateAdTemplateResponse{Template: toProtoAdTemplate(item)}, nil return &appv1.CreateAdTemplateResponse{Template: toProtoAdTemplate(item)}, nil
} }
func (s *appServices) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdTemplateRequest) (*appv1.UpdateAdTemplateResponse, error) { func (s *adTemplatesAppService) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdTemplateRequest) (*appv1.UpdateAdTemplateResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -293,8 +263,8 @@ func (s *appServices) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdT
return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates") return nil, status.Error(codes.InvalidArgument, "Duration is required for mid-roll templates")
} }
var item model.AdTemplate item, err := s.adTemplateRepository.GetByIDAndUser(ctx, id, result.UserID)
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil { if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found") return nil, status.Error(codes.NotFound, "Ad template not found")
} }
@@ -317,21 +287,14 @@ func (s *appServices) UpdateAdTemplate(ctx context.Context, req *appv1.UpdateAdT
item.IsDefault = false item.IsDefault = false
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.adTemplateRepository.SaveWithDefault(ctx, result.UserID, item); err != nil {
if item.IsDefault {
if err := unsetDefaultTemplates(tx, result.UserID, item.ID); err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
s.logger.Error("Failed to update ad template", "error", err) s.logger.Error("Failed to update ad template", "error", err)
return nil, status.Error(codes.Internal, "Failed to save ad template") return nil, status.Error(codes.Internal, "Failed to save ad template")
} }
return &appv1.UpdateAdTemplateResponse{Template: toProtoAdTemplate(&item)}, nil return &appv1.UpdateAdTemplateResponse{Template: toProtoAdTemplate(item)}, nil
} }
func (s *appServices) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdTemplateRequest) (*appv1.MessageResponse, error) { func (s *adTemplatesAppService) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdTemplateRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -345,22 +308,7 @@ func (s *appServices) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdT
return nil, status.Error(codes.NotFound, "Ad template not found") return nil, status.Error(codes.NotFound, "Ad template not found")
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.adTemplateRepository.DeleteByIDAndUserAndClearVideos(ctx, id, result.UserID); err != nil {
if err := tx.Model(&model.Video{}).
Where("user_id = ? AND ad_id = ?", result.UserID, id).
Update("ad_id", nil).Error; err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.AdTemplate{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Ad template not found") return nil, status.Error(codes.NotFound, "Ad template not found")
} }
@@ -370,13 +318,13 @@ func (s *appServices) DeleteAdTemplate(ctx context.Context, req *appv1.DeleteAdT
return messageResponse("Ad template deleted"), nil return messageResponse("Ad template deleted"), nil
} }
func (s *appServices) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest) (*appv1.ListPlansResponse, error) { func (s *plansAppService) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest) (*appv1.ListPlansResponse, error) {
if _, err := s.authenticate(ctx); err != nil { if _, err := s.authenticate(ctx); err != nil {
return nil, err return nil, err
} }
var plans []model.Plan plans, err := s.planRepository.ListActive(ctx)
if err := s.db.WithContext(ctx).Where("is_active = ?", true).Find(&plans).Error; err != nil { if err != nil {
s.logger.Error("Failed to fetch plans", "error", err) s.logger.Error("Failed to fetch plans", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch plans") return nil, status.Error(codes.Internal, "Failed to fetch plans")
} }
@@ -390,18 +338,14 @@ func (s *appServices) ListPlans(ctx context.Context, _ *appv1.ListPlansRequest)
return &appv1.ListPlansResponse{Plans: items}, nil return &appv1.ListPlansResponse{Plans: items}, nil
} }
func (s *appServices) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayerConfigsRequest) (*appv1.ListPlayerConfigsResponse, error) { func (s *playerConfigsAppService) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayerConfigsRequest) (*appv1.ListPlayerConfigsResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var items []model.PlayerConfig items, err := s.playerConfigRepo.ListByUser(ctx, result.UserID)
if err := s.db.WithContext(ctx). if err != nil {
Where("user_id = ?", result.UserID).
Order("is_default DESC").
Order("created_at DESC").
Find(&items).Error; err != nil {
s.logger.Error("Failed to list player configs", "error", err) s.logger.Error("Failed to list player configs", "error", err)
return nil, status.Error(codes.Internal, "Failed to load player configs") return nil, status.Error(codes.Internal, "Failed to load player configs")
} }
@@ -415,7 +359,7 @@ func (s *appServices) ListPlayerConfigs(ctx context.Context, _ *appv1.ListPlayer
return &appv1.ListPlayerConfigsResponse{Configs: payload}, nil return &appv1.ListPlayerConfigsResponse{Configs: payload}, nil
} }
func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) { func (s *playerConfigsAppService) CreatePlayerConfig(ctx context.Context, req *appv1.CreatePlayerConfigRequest) (*appv1.CreatePlayerConfigResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -447,29 +391,8 @@ func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreateP
item.IsDefault = false item.IsDefault = false
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.playerConfigRepo.CreateManaged(ctx, result.UserID, item, func(lockedUser *model.User, configCount int64) error {
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID) return playerConfigActionAllowed(lockedUser, configCount, "create")
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", result.UserID).
Count(&configCount).Error; err != nil {
return err
}
if err := playerConfigActionAllowed(lockedUser, configCount, "create"); err != nil {
return err
}
if item.IsDefault {
if err := unsetDefaultPlayerConfigs(tx, result.UserID, ""); err != nil {
return err
}
}
return tx.Create(item).Error
}); err != nil { }); err != nil {
if status.Code(err) != codes.Unknown { if status.Code(err) != codes.Unknown {
return nil, err return nil, err
@@ -481,7 +404,7 @@ func (s *appServices) CreatePlayerConfig(ctx context.Context, req *appv1.CreateP
return &appv1.CreatePlayerConfigResponse{Config: toProtoPlayerConfig(item)}, nil return &appv1.CreatePlayerConfigResponse{Config: toProtoPlayerConfig(item)}, nil
} }
func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) { func (s *playerConfigsAppService) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdatePlayerConfigRequest) (*appv1.UpdatePlayerConfigResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -497,25 +420,7 @@ func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdateP
return nil, status.Error(codes.InvalidArgument, "Name is required") return nil, status.Error(codes.InvalidArgument, "Name is required")
} }
var item model.PlayerConfig item, err := s.playerConfigRepo.UpdateManaged(ctx, result.UserID, id, func(item *model.PlayerConfig, lockedUser *model.User, configCount int64) error {
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID)
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", result.UserID).
Count(&configCount).Error; err != nil {
return err
}
if err := tx.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&item).Error; err != nil {
return err
}
action := "update" action := "update"
wasActive := playerConfigIsActive(item.IsActive) wasActive := playerConfigIsActive(item.IsActive)
if req.IsActive != nil && *req.IsActive != wasActive { if req.IsActive != nil && *req.IsActive != wasActive {
@@ -552,14 +457,9 @@ func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdateP
if !playerConfigIsActive(item.IsActive) { if !playerConfigIsActive(item.IsActive) {
item.IsDefault = false item.IsDefault = false
} }
return nil
if item.IsDefault { })
if err := unsetDefaultPlayerConfigs(tx, result.UserID, item.ID); err != nil { if err != nil {
return err
}
}
return tx.Save(&item).Error
}); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found") return nil, status.Error(codes.NotFound, "Player config not found")
} }
@@ -570,10 +470,10 @@ func (s *appServices) UpdatePlayerConfig(ctx context.Context, req *appv1.UpdateP
return nil, status.Error(codes.Internal, "Failed to save player config") return nil, status.Error(codes.Internal, "Failed to save player config")
} }
return &appv1.UpdatePlayerConfigResponse{Config: toProtoPlayerConfig(&item)}, nil return &appv1.UpdatePlayerConfigResponse{Config: toProtoPlayerConfig(item)}, nil
} }
func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) { func (s *playerConfigsAppService) DeletePlayerConfig(ctx context.Context, req *appv1.DeletePlayerConfigRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -584,31 +484,8 @@ func (s *appServices) DeletePlayerConfig(ctx context.Context, req *appv1.DeleteP
return nil, status.Error(codes.NotFound, "Player config not found") return nil, status.Error(codes.NotFound, "Player config not found")
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.playerConfigRepo.DeleteManaged(ctx, result.UserID, id, func(lockedUser *model.User, configCount int64) error {
lockedUser, err := lockUserForUpdate(ctx, tx, result.UserID) return playerConfigActionAllowed(lockedUser, configCount, "delete")
if err != nil {
return err
}
var configCount int64
if err := tx.WithContext(ctx).
Model(&model.PlayerConfig{}).
Where("user_id = ?", result.UserID).
Count(&configCount).Error; err != nil {
return err
}
if err := playerConfigActionAllowed(lockedUser, configCount, "delete"); err != nil {
return err
}
res := tx.Where("id = ? AND user_id = ?", id, result.UserID).Delete(&model.PlayerConfig{})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}); err != nil { }); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Player config not found") return nil, status.Error(codes.NotFound, "Player config not found")

View File

@@ -12,10 +12,9 @@ import (
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"gorm.io/gorm" "gorm.io/gorm"
appv1 "stream.api/internal/api/proto/app/v1" appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
) )
func (s *appServices) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlRequest) (*appv1.GetUploadUrlResponse, error) { func (s *videosAppService) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlRequest) (*appv1.GetUploadUrlResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -39,12 +38,12 @@ func (s *appServices) GetUploadUrl(ctx context.Context, req *appv1.GetUploadUrlR
return &appv1.GetUploadUrlResponse{UploadUrl: uploadURL, Key: key, FileId: fileID}, nil return &appv1.GetUploadUrlResponse{UploadUrl: uploadURL, Key: key, FileId: fileID}, nil
} }
func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoRequest) (*appv1.CreateVideoResponse, error) { func (s *videosAppService) CreateVideo(ctx context.Context, req *appv1.CreateVideoRequest) (*appv1.CreateVideoResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if s.videoService == nil { if s.videoWorkflowService == nil {
return nil, status.Error(codes.Unavailable, "Job service is unavailable") return nil, status.Error(codes.Unavailable, "Job service is unavailable")
} }
@@ -58,7 +57,7 @@ func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoReq
} }
description := strings.TrimSpace(req.GetDescription()) description := strings.TrimSpace(req.GetDescription())
created, err := s.videoService.CreateVideo(ctx, CreateVideoInput{ created, err := s.videoWorkflowService.CreateVideo(ctx, CreateVideoInput{
UserID: result.UserID, UserID: result.UserID,
Title: title, Title: title,
Description: &description, Description: &description,
@@ -79,7 +78,7 @@ func (s *appServices) CreateVideo(ctx context.Context, req *appv1.CreateVideoReq
return &appv1.CreateVideoResponse{Video: toProtoVideo(created.Video, created.Job.ID)}, nil return &appv1.CreateVideoResponse{Video: toProtoVideo(created.Video, created.Job.ID)}, nil
} }
func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosRequest) (*appv1.ListVideosResponse, error) { func (s *videosAppService) ListVideos(ctx context.Context, req *appv1.ListVideosRequest) (*appv1.ListVideosResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -97,24 +96,12 @@ func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosReque
limit = 100 limit = 100
} }
offset := int((page - 1) * limit) offset := int((page - 1) * limit)
if s.videoRepository == nil {
db := s.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", result.UserID) return nil, status.Error(codes.Internal, "Video repository is unavailable")
if search := strings.TrimSpace(req.GetSearch()); search != "" {
like := "%" + search + "%"
db = db.Where("title ILIKE ? OR description ILIKE ?", like, like)
}
if st := strings.TrimSpace(req.GetStatus()); st != "" && !strings.EqualFold(st, "all") {
db = db.Where("status = ?", normalizeVideoStatusValue(st))
} }
var total int64 videos, total, err := s.videoRepository.ListByUser(ctx, result.UserID, req.GetSearch(), normalizeVideoStatusFilter(req.GetStatus()), offset, int(limit))
if err := db.Count(&total).Error; err != nil { if err != nil {
s.logger.Error("Failed to count videos", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch videos")
}
var videos []model.Video
if err := db.Order("created_at DESC").Offset(offset).Limit(int(limit)).Find(&videos).Error; err != nil {
s.logger.Error("Failed to list videos", "error", err) s.logger.Error("Failed to list videos", "error", err)
return nil, status.Error(codes.Internal, "Failed to fetch videos") return nil, status.Error(codes.Internal, "Failed to fetch videos")
} }
@@ -131,7 +118,7 @@ func (s *appServices) ListVideos(ctx context.Context, req *appv1.ListVideosReque
return &appv1.ListVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil return &appv1.ListVideosResponse{Videos: items, Total: total, Page: page, Limit: limit}, nil
} }
func (s *appServices) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) (*appv1.GetVideoResponse, error) { func (s *videosAppService) GetVideo(ctx context.Context, req *appv1.GetVideoRequest) (*appv1.GetVideoResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -142,12 +129,14 @@ func (s *appServices) GetVideo(ctx context.Context, req *appv1.GetVideoRequest)
return nil, status.Error(codes.NotFound, "Video not found") return nil, status.Error(codes.NotFound, "Video not found")
} }
_ = s.db.WithContext(ctx).Model(&model.Video{}). if s.videoRepository == nil {
Where("id = ? AND user_id = ?", id, result.UserID). return nil, status.Error(codes.Internal, "Video repository is unavailable")
UpdateColumn("views", gorm.Expr("views + ?", 1)).Error }
var video model.Video _ = s.videoRepository.IncrementViews(ctx, id, result.UserID)
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil {
video, err := s.videoRepository.GetByIDAndUser(ctx, id, result.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found") return nil, status.Error(codes.NotFound, "Video not found")
} }
@@ -155,14 +144,14 @@ func (s *appServices) GetVideo(ctx context.Context, req *appv1.GetVideoRequest)
return nil, status.Error(codes.Internal, "Failed to fetch video") return nil, status.Error(codes.Internal, "Failed to fetch video")
} }
payload, err := s.buildVideo(ctx, &video) payload, err := s.buildVideo(ctx, video)
if err != nil { if err != nil {
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID) s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to fetch video") return nil, status.Error(codes.Internal, "Failed to fetch video")
} }
return &appv1.GetVideoResponse{Video: payload}, nil return &appv1.GetVideoResponse{Video: payload}, nil
} }
func (s *appServices) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoRequest) (*appv1.UpdateVideoResponse, error) { func (s *videosAppService) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoRequest) (*appv1.UpdateVideoResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -201,32 +190,33 @@ func (s *appServices) UpdateVideo(ctx context.Context, req *appv1.UpdateVideoReq
return nil, status.Error(codes.InvalidArgument, "No changes provided") return nil, status.Error(codes.InvalidArgument, "No changes provided")
} }
res := s.db.WithContext(ctx). if s.videoRepository == nil {
Model(&model.Video{}). return nil, status.Error(codes.Internal, "Video repository is unavailable")
Where("id = ? AND user_id = ?", id, result.UserID). }
Updates(updates)
if res.Error != nil { rowsAffected, err := s.videoRepository.UpdateByIDAndUser(ctx, id, result.UserID, updates)
s.logger.Error("Failed to update video", "error", res.Error) if err != nil {
s.logger.Error("Failed to update video", "error", err)
return nil, status.Error(codes.Internal, "Failed to update video") return nil, status.Error(codes.Internal, "Failed to update video")
} }
if res.RowsAffected == 0 { if rowsAffected == 0 {
return nil, status.Error(codes.NotFound, "Video not found") return nil, status.Error(codes.NotFound, "Video not found")
} }
var video model.Video video, err := s.videoRepository.GetByIDAndUser(ctx, id, result.UserID)
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil { if err != nil {
s.logger.Error("Failed to reload video", "error", err) s.logger.Error("Failed to reload video", "error", err)
return nil, status.Error(codes.Internal, "Failed to update video") return nil, status.Error(codes.Internal, "Failed to update video")
} }
payload, err := s.buildVideo(ctx, &video) payload, err := s.buildVideo(ctx, video)
if err != nil { if err != nil {
s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID) s.logger.Error("Failed to build video payload", "error", err, "video_id", video.ID)
return nil, status.Error(codes.Internal, "Failed to update video") return nil, status.Error(codes.Internal, "Failed to update video")
} }
return &appv1.UpdateVideoResponse{Video: payload}, nil return &appv1.UpdateVideoResponse{Video: payload}, nil
} }
func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoRequest) (*appv1.MessageResponse, error) { func (s *videosAppService) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoRequest) (*appv1.MessageResponse, error) {
result, err := s.authenticate(ctx) result, err := s.authenticate(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -237,8 +227,12 @@ func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoReq
return nil, status.Error(codes.NotFound, "Video not found") return nil, status.Error(codes.NotFound, "Video not found")
} }
var video model.Video if s.videoRepository == nil {
if err := s.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, result.UserID).First(&video).Error; err != nil { return nil, status.Error(codes.Internal, "Video repository is unavailable")
}
video, err := s.videoRepository.GetByIDAndUser(ctx, id, result.UserID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, status.Error(codes.NotFound, "Video not found") return nil, status.Error(codes.NotFound, "Video not found")
} }
@@ -260,14 +254,7 @@ func (s *appServices) DeleteVideo(ctx context.Context, req *appv1.DeleteVideoReq
} }
} }
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.videoRepository.DeleteByIDAndUserWithStorageUpdate(ctx, video.ID, result.UserID, video.Size); err != nil {
if err := tx.Where("id = ? AND user_id = ?", video.ID, result.UserID).Delete(&model.Video{}).Error; err != nil {
return err
}
return tx.Model(&model.User{}).
Where("id = ?", result.UserID).
UpdateColumn("storage_used", gorm.Expr("storage_used - ?", video.Size)).Error
}); err != nil {
s.logger.Error("Failed to delete video", "error", err) s.logger.Error("Failed to delete video", "error", err)
return nil, status.Error(codes.Internal, "Failed to delete video") return nil, status.Error(codes.Internal, "Failed to delete video")
} }

View File

@@ -3,7 +3,6 @@ package service
import ( import (
"context" "context"
"gorm.io/gorm"
"stream.api/internal/database/model" "stream.api/internal/database/model"
"stream.api/pkg/logger" "stream.api/pkg/logger"
) )
@@ -14,9 +13,9 @@ type usagePayload struct {
TotalStorage int64 `json:"total_storage"` TotalStorage int64 `json:"total_storage"`
} }
func loadUsage(ctx context.Context, db *gorm.DB, l logger.Logger, user *model.User) (*usagePayload, error) { func loadUsage(ctx context.Context, videoRepo VideoRepository, l logger.Logger, user *model.User) (*usagePayload, error) {
var totalVideos int64 totalVideos, err := videoRepo.CountByUser(ctx, user.ID)
if err := db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", user.ID).Count(&totalVideos).Error; err != nil { if err != nil {
l.Error("Failed to count user videos", "error", err, "user_id", user.ID) l.Error("Failed to count user videos", "error", err, "user_id", user.ID)
return nil, err return nil, err
} }

View File

@@ -0,0 +1,86 @@
package service
import (
"strings"
"google.golang.org/protobuf/types/known/timestamppb"
appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
)
func protoUserFromPayload(user *userPayload) *appv1.User {
if user == nil {
return nil
}
return &appv1.User{
Id: user.ID,
Email: user.Email,
Username: user.Username,
Avatar: user.Avatar,
Role: user.Role,
GoogleId: user.GoogleID,
StorageUsed: user.StorageUsed,
PlanId: user.PlanID,
PlanStartedAt: timeToProto(user.PlanStartedAt),
PlanExpiresAt: timeToProto(user.PlanExpiresAt),
PlanTermMonths: user.PlanTermMonths,
PlanPaymentMethod: user.PlanPaymentMethod,
PlanExpiringSoon: user.PlanExpiringSoon,
WalletBalance: user.WalletBalance,
Language: user.Language,
Locale: user.Locale,
CreatedAt: timeToProto(user.CreatedAt),
UpdatedAt: timestamppb.New(user.UpdatedAt),
}
}
func toProtoUser(user *userPayload) *appv1.User {
return protoUserFromPayload(user)
}
func toProtoPreferences(pref *model.UserPreference) *appv1.Preferences {
if pref == nil {
return nil
}
return &appv1.Preferences{
EmailNotifications: boolValue(pref.EmailNotifications),
PushNotifications: boolValue(pref.PushNotifications),
MarketingNotifications: pref.MarketingNotifications,
TelegramNotifications: pref.TelegramNotifications,
Language: model.StringValue(pref.Language),
Locale: model.StringValue(pref.Locale),
}
}
func toProtoNotification(item model.Notification) *appv1.Notification {
return &appv1.Notification{
Id: item.ID,
Type: normalizeNotificationType(item.Type),
Title: item.Title,
Message: item.Message,
Read: item.IsRead,
ActionUrl: item.ActionURL,
ActionLabel: item.ActionLabel,
CreatedAt: timeToProto(item.CreatedAt),
}
}
func normalizeNotificationType(value string) string {
lower := strings.ToLower(strings.TrimSpace(value))
switch {
case strings.Contains(lower, "video"):
return "video"
case strings.Contains(lower, "payment"), strings.Contains(lower, "billing"):
return "payment"
case strings.Contains(lower, "warning"):
return "warning"
case strings.Contains(lower, "error"):
return "error"
case strings.Contains(lower, "success"):
return "success"
case strings.Contains(lower, "system"):
return "system"
default:
return "info"
}
}

View File

@@ -31,13 +31,13 @@ type userPayload struct {
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*userPayload, error) { func buildUserPayload(ctx context.Context, preferenceRepo UserPreferenceRepository, billingRepo BillingRepository, user *model.User) (*userPayload, error) {
pref, err := model.FindOrCreateUserPreference(ctx, db, user.ID) pref, err := preferenceRepo.FindOrCreateByUserID(ctx, user.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
walletBalance, err := model.GetWalletBalance(ctx, db, user.ID) walletBalance, err := billingRepo.GetWalletBalance(ctx, user.ID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -59,7 +59,7 @@ func buildUserPayload(ctx context.Context, db *gorm.DB, user *model.User) (*user
planExpiringSoon := false planExpiringSoon := false
now := time.Now().UTC() now := time.Now().UTC()
subscription, err := model.GetLatestPlanSubscription(ctx, db, user.ID) subscription, err := billingRepo.GetLatestPlanSubscription(ctx, user.ID)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err return nil, err
} }

View File

@@ -0,0 +1,83 @@
package service
import (
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
)
func stringPointerOrNil(value string) *string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil
}
return &trimmed
}
func timeToProto(value *time.Time) *timestamppb.Timestamp {
if value == nil {
return nil
}
return timestamppb.New(value.UTC())
}
func boolValue(value *bool) bool {
return value != nil && *value
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func int32PtrToInt64Ptr(value *int32) *int64 {
if value == nil {
return nil
}
converted := int64(*value)
return &converted
}
func int64PtrToInt32Ptr(value *int64) *int32 {
if value == nil {
return nil
}
converted := int32(*value)
return &converted
}
func int32Ptr(value int32) *int32 {
return &value
}
func protoStringValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func nullableTrimmedStringPtr(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func nullableTrimmedString(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}

View File

@@ -0,0 +1,56 @@
package service
import (
"net/url"
"strings"
)
func normalizeVideoStatusValue(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "processing", "pending":
return "processing"
case "failed", "error":
return "failed"
default:
return "ready"
}
}
func normalizeVideoStatusFilter(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" || strings.EqualFold(trimmed, "all") {
return ""
}
return normalizeVideoStatusValue(trimmed)
}
func detectStorageType(rawURL string) string {
if shouldDeleteStoredObject(rawURL) {
return "S3"
}
return "WORKER"
}
func shouldDeleteStoredObject(rawURL string) bool {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return false
}
parsed, err := url.Parse(trimmed)
if err != nil {
return !strings.HasPrefix(trimmed, "/")
}
return parsed.Scheme == "" && parsed.Host == "" && !strings.HasPrefix(trimmed, "/")
}
func extractObjectKey(rawURL string) string {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return ""
}
parsed, err := url.Parse(trimmed)
if err != nil {
return trimmed
}
return strings.TrimPrefix(parsed.Path, "/")
}

View File

@@ -0,0 +1,55 @@
package service
import (
"context"
"strings"
"google.golang.org/protobuf/types/known/timestamppb"
appv1 "stream.api/internal/api/proto/app/v1"
"stream.api/internal/database/model"
)
func toProtoVideo(item *model.Video, jobID ...string) *appv1.Video {
if item == nil {
return nil
}
statusValue := stringValue(item.Status)
if statusValue == "" {
statusValue = "ready"
}
var linkedJobID *string
if len(jobID) > 0 {
linkedJobID = stringPointerOrNil(jobID[0])
}
return &appv1.Video{
Id: item.ID,
UserId: item.UserID,
Title: item.Title,
Description: item.Description,
Url: item.URL,
Status: strings.ToLower(statusValue),
Size: item.Size,
Duration: item.Duration,
Format: item.Format,
Thumbnail: item.Thumbnail,
ProcessingStatus: item.ProcessingStatus,
StorageType: item.StorageType,
CreatedAt: timeToProto(item.CreatedAt),
UpdatedAt: timestamppb.New(item.UpdatedAt.UTC()),
JobId: linkedJobID,
}
}
func (s *videosAppService) buildVideo(ctx context.Context, video *model.Video) (*appv1.Video, error) {
if video == nil {
return nil, nil
}
jobID, err := s.loadLatestVideoJobID(ctx, video.ID)
if err != nil {
return nil, err
}
if jobID != nil {
return toProtoVideo(video, *jobID), nil
}
return toProtoVideo(video), nil
}

View File

@@ -11,6 +11,7 @@ import (
"stream.api/internal/dto" "stream.api/internal/dto"
"stream.api/internal/service" "stream.api/internal/service"
"stream.api/internal/transport/mqtt" "stream.api/internal/transport/mqtt"
renderworkflow "stream.api/internal/workflow/render"
"stream.api/pkg/logger" "stream.api/pkg/logger"
) )
@@ -23,9 +24,9 @@ type GRPCModule struct {
} }
func NewGRPCModule(ctx context.Context, cfg *config.Config, db *gorm.DB, rds *redisadapter.RedisAdapter, appLogger logger.Logger) (*GRPCModule, error) { func NewGRPCModule(ctx context.Context, cfg *config.Config, db *gorm.DB, rds *redisadapter.RedisAdapter, appLogger logger.Logger) (*GRPCModule, error) {
jobService := service.NewJobService(rds, rds) jobService := service.NewJobService(db, rds, rds)
agentRuntime := NewServer(jobService, cfg.Render.AgentSecret) agentRuntime := NewServer(jobService, cfg.Render.AgentSecret)
videoService := service.NewService(db, jobService) videoService := renderworkflow.New(db, jobService)
grpcServer := grpcpkg.NewServer() grpcServer := grpcpkg.NewServer()
module := &GRPCModule{ module := &GRPCModule{

View File

@@ -0,0 +1,228 @@
package render
import (
"context"
"encoding/json"
"errors"
"net/url"
"strings"
"github.com/google/uuid"
"gorm.io/gorm"
"stream.api/internal/database/model"
"stream.api/internal/dto"
"stream.api/internal/repository"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrAdTemplateNotFound = errors.New("ad template not found")
ErrJobServiceUnavailable = errors.New("job service is unavailable")
)
type JobService interface {
ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error)
ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error)
ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error)
GetJob(ctx context.Context, id string) (*model.Job, error)
CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error)
CancelJob(ctx context.Context, id string) error
RetryJob(ctx context.Context, id string) (*model.Job, error)
}
type Workflow struct {
db *gorm.DB
jobService JobService
userRepository userRepository
workflowRepository videoWorkflowRepository
}
type userRepository interface {
GetByID(ctx context.Context, userID string) (*model.User, error)
}
type videoWorkflowRepository interface {
GetUserByID(ctx context.Context, userID string) (*model.User, error)
CreateVideoWithStorageAndAd(ctx context.Context, video *model.Video, userID string, adTemplateID *string) error
MarkVideoJobFailed(ctx context.Context, videoID string) error
}
type CreateVideoInput struct {
UserID string
Title string
Description *string
URL string
Size int64
Duration int32
Format string
AdTemplateID *string
}
type CreateVideoResult struct {
Video *model.Video
Job model.Job
}
func New(db *gorm.DB, jobService JobService) *Workflow {
return &Workflow{
db: db,
jobService: jobService,
userRepository: repository.NewUserRepository(db),
workflowRepository: repository.NewVideoWorkflowRepository(db),
}
}
func (w *Workflow) CreateVideo(ctx context.Context, input CreateVideoInput) (*CreateVideoResult, error) {
if w == nil || w.db == nil {
return nil, gorm.ErrInvalidDB
}
userID := strings.TrimSpace(input.UserID)
if userID == "" {
return nil, ErrUserNotFound
}
user, err := w.workflowRepository.GetUserByID(ctx, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
title := strings.TrimSpace(input.Title)
videoURL := strings.TrimSpace(input.URL)
format := strings.TrimSpace(input.Format)
statusValue := "processing"
processingStatus := "PENDING"
storageType := detectStorageType(videoURL)
video := &model.Video{
ID: uuid.NewString(),
UserID: user.ID,
Name: title,
Title: title,
Description: nullableTrimmedString(input.Description),
URL: videoURL,
Size: input.Size,
Duration: input.Duration,
Format: format,
Status: model.StringPtr(statusValue),
ProcessingStatus: model.StringPtr(processingStatus),
StorageType: model.StringPtr(storageType),
}
if err := w.workflowRepository.CreateVideoWithStorageAndAd(ctx, video, user.ID, input.AdTemplateID); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrAdTemplateNotFound
}
return nil, err
}
if w.jobService == nil {
_ = w.workflowRepository.MarkVideoJobFailed(ctx, video.ID)
return nil, ErrJobServiceUnavailable
}
jobPayload, err := buildJobPayload(video.ID, user.ID, videoURL, format)
if err != nil {
_ = w.workflowRepository.MarkVideoJobFailed(ctx, video.ID)
return nil, err
}
job, err := w.jobService.CreateJob(ctx, user.ID, video.ID, title, jobPayload, 0, 0)
if err != nil {
_ = w.workflowRepository.MarkVideoJobFailed(ctx, video.ID)
return nil, err
}
return &CreateVideoResult{Video: video, Job: *job}, nil
}
func (w *Workflow) ListJobs(ctx context.Context, offset, limit int) (*dto.PaginatedJobs, error) {
if w == nil || w.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return w.jobService.ListJobs(ctx, offset, limit)
}
func (w *Workflow) ListJobsByAgent(ctx context.Context, agentID string, offset, limit int) (*dto.PaginatedJobs, error) {
if w == nil || w.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return w.jobService.ListJobsByAgent(ctx, agentID, offset, limit)
}
func (w *Workflow) ListJobsByCursor(ctx context.Context, agentID string, cursor string, pageSize int) (*dto.PaginatedJobs, error) {
if w == nil || w.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return w.jobService.ListJobsByCursor(ctx, agentID, cursor, pageSize)
}
func (w *Workflow) GetJob(ctx context.Context, id string) (*model.Job, error) {
if w == nil || w.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return w.jobService.GetJob(ctx, id)
}
func (w *Workflow) CreateJob(ctx context.Context, userID string, videoID string, name string, config []byte, priority int, timeLimit int64) (*model.Job, error) {
if w == nil || w.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return w.jobService.CreateJob(ctx, userID, videoID, name, config, priority, timeLimit)
}
func (w *Workflow) CancelJob(ctx context.Context, id string) error {
if w == nil || w.jobService == nil {
return ErrJobServiceUnavailable
}
return w.jobService.CancelJob(ctx, id)
}
func (w *Workflow) RetryJob(ctx context.Context, id string) (*model.Job, error) {
if w == nil || w.jobService == nil {
return nil, ErrJobServiceUnavailable
}
return w.jobService.RetryJob(ctx, id)
}
func buildJobPayload(videoID, userID, videoURL, format string) ([]byte, error) {
return json.Marshal(map[string]any{
"video_id": videoID,
"user_id": userID,
"input_url": videoURL,
"source_url": videoURL,
"format": format,
})
}
func nullableTrimmedString(value *string) *string {
if value == nil {
return nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil
}
return &trimmed
}
func detectStorageType(rawURL string) string {
if shouldDeleteStoredObject(rawURL) {
return "S3"
}
return "WORKER"
}
func shouldDeleteStoredObject(rawURL string) bool {
trimmed := strings.TrimSpace(rawURL)
if trimmed == "" {
return false
}
parsed, err := url.Parse(trimmed)
if err != nil {
return !strings.HasPrefix(trimmed, "/")
}
return parsed.Scheme == "" && parsed.Host == "" && !strings.HasPrefix(trimmed, "/")
}