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:
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"gopls-lsp@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
82
CLAUDE.md
Normal file
82
CLAUDE.md
Normal 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
|
||||||
4251
docs/docs.go
4251
docs/docs.go
File diff suppressed because it is too large
Load Diff
4224
docs/swagger.json
4224
docs/swagger.json
File diff suppressed because it is too large
Load Diff
2690
docs/swagger.yaml
2690
docs/swagger.yaml
File diff suppressed because it is too large
Load Diff
1
go.mod
1
go.mod
@@ -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
2
go.sum
@@ -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=
|
||||||
|
|||||||
66
internal/adapters/circuitbreaker/circuit_breaker.go
Normal file
66
internal/adapters/circuitbreaker/circuit_breaker.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
73
internal/repository/account_repository.go
Normal file
73
internal/repository/account_repository.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
152
internal/repository/ad_template_repository.go
Normal file
152
internal/repository/ad_template_repository.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
41
internal/repository/billing_repository.go
Normal file
41
internal/repository/billing_repository.go
Normal 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
|
||||||
|
}
|
||||||
46
internal/repository/domain_repository.go
Normal file
46
internal/repository/domain_repository.go
Normal 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
|
||||||
|
}
|
||||||
102
internal/repository/job_repository.go
Normal file
102
internal/repository/job_repository.go
Normal 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
|
||||||
|
}
|
||||||
54
internal/repository/notification_repository.go
Normal file
54
internal/repository/notification_repository.go
Normal 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 ¬ificationRepository{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
|
||||||
|
}
|
||||||
469
internal/repository/payment_repository.go
Normal file
469
internal/repository/payment_repository.go
Normal 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)
|
||||||
|
}
|
||||||
69
internal/repository/plan_repository.go
Normal file
69
internal/repository/plan_repository.go
Normal 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
|
||||||
|
}
|
||||||
277
internal/repository/player_config_repository.go
Normal file
277
internal/repository/player_config_repository.go
Normal 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
|
||||||
|
}
|
||||||
24
internal/repository/user_preference_repository.go
Normal file
24
internal/repository/user_preference_repository.go
Normal 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
|
||||||
|
}
|
||||||
173
internal/repository/user_repository.go
Normal file
173
internal/repository/user_repository.go
Normal 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
|
||||||
|
}
|
||||||
210
internal/repository/video_repository.go
Normal file
210
internal/repository/video_repository.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
67
internal/repository/video_workflow_repository.go
Normal file
67
internal/repository/video_workflow_repository.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
103
internal/service/catalog_mapper.go
Normal file
103
internal/service/catalog_mapper.go
Normal 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
29
internal/service/domain_helpers.go
Normal file
29
internal/service/domain_helpers.go
Normal 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
|
||||||
|
}
|
||||||
191
internal/service/interface.go
Normal file
191
internal/service/interface.go
Normal 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
|
||||||
|
}
|
||||||
18
internal/service/oauth_helpers.go
Normal file
18
internal/service/oauth_helpers.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
128
internal/service/payment_proto_helpers.go
Normal file
128
internal/service/payment_proto_helpers.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: ¬ificationsAppService{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},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
52
internal/service/service_policy_helpers.go
Normal file
52
internal/service/service_policy_helpers.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
86
internal/service/user_mapper.go
Normal file
86
internal/service/user_mapper.go
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
83
internal/service/value_helpers.go
Normal file
83
internal/service/value_helpers.go
Normal 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
|
||||||
|
}
|
||||||
56
internal/service/video_helpers.go
Normal file
56
internal/service/video_helpers.go
Normal 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, "/")
|
||||||
|
}
|
||||||
55
internal/service/video_mapper.go
Normal file
55
internal/service/video_mapper.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
|
|||||||
228
internal/workflow/render/workflow.go
Normal file
228
internal/workflow/render/workflow.go
Normal 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, "/")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user