diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..2a5c60e --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,188 @@ +# Player Configs Migration Guide + +## Overview + +Đây là tài liệu hướng dẫn migrate player settings từ bảng `user_preferences` sang bảng `player_configs` mới. + +### Tại sao cần migrate? + +- **user_preferences**: Một hàng mỗi user, không thể có nhiều cấu hình player +- **player_configs**: Nhiều hàng mỗi user, hỗ trợ nhiều cấu hình player khác nhau + +### Các bước thực hiện + +## Bước 1: Chạy Migration SQL + +```bash +cd /home/dat/projects/stream/stream.api + +# Cách 1: Dùng script tự động (khuyến nghị) +./migrate_player_configs.sh + +# Cách 2: Chạy SQL thủ công +psql -h 47.84.63.130 -U postgres -d video_db -f full_player_configs_migration.sql +``` + +### Script sẽ thực hiện: + +1. ✅ Tạo bảng `player_configs` với các columns: + - id, user_id, name, description + - autoplay, loop, muted, show_controls, pip, airplay, chromecast + - is_active, is_default, created_at, updated_at, version + +2. ✅ Migrate dữ liệu từ `user_preferences` sang `player_configs`: + - Mỗi user sẽ có 1 config mặc định tên "Default Config" + - Description là "Migrated from user_preferences" + +3. ✅ Xóa các columns player khỏi `user_preferences`: + - autoplay, loop, muted + - show_controls, pip, airplay, chromecast + - encrytion_m3u8 (VIP feature không dùng) + +4. ✅ Tạo indexes và triggers + +## Bước 2: Regenerate Go Models + +Sau khi migration xong, chạy: + +```bash +go run cmd/gendb/main.go +``` + +Script này sẽ đọc schema mới từ database và regenerate: +- `internal/database/model/user_preferences.gen.go` (không còn player fields) +- `internal/database/model/player_configs.gen.go` (mới) +- `internal/database/query/*.gen.go` + +## Bước 3: Cập nhật Code + +Sau khi models được regenerate, cập nhật các file sau: + +### 3.1. Cập nhật `internal/api/preferences/service.go` + +```go +// Xóa các field player settings khỏi UpdateInput +type UpdateInput struct { + EmailNotifications *bool + PushNotifications *bool + MarketingNotifications *bool + TelegramNotifications *bool + // XÓA: Autoplay, Loop, Muted, ShowControls, Pip, Airplay, Chromecast + Language *string + Locale *string +} + +// Xóa logic update player settings khỏi UpdateUserPreferences +``` + +### 3.2. Cập nhật `internal/rpc/app/service_account.go` + +```go +// UpdatePreferences chỉ update notification settings + language/locale +func (s *appServices) UpdatePreferences(...) { + // Chỉ update các fields không phải player settings + pref, err := preferencesapi.UpdateUserPreferences(ctx, s.db, s.logger, result.UserID, preferencesapi.UpdateInput{ + EmailNotifications: req.EmailNotifications, + PushNotifications: req.PushNotifications, + MarketingNotifications: req.MarketingNotifications, + TelegramNotifications: req.TelegramNotifications, + Language: req.Language, + Locale: req.Locale, + // XÓA: Autoplay, Loop, Muted, ShowControls, Pip, Airplay, Chromecast + }) +} +``` + +### 3.3. Sử dụng PlayerConfigs API cho player settings + +Thay vì dùng `UpdatePreferences` cho player settings, dùng: + +```go +// Tạo hoặc cập nhật default player config +func (s *appServices) UpdatePlayerSettings(ctx context.Context, req *appv1.UpdatePlayerSettingsRequest) { + // Dùng player_configs API đã implement + // ListPlayerConfigs, CreatePlayerConfig, UpdatePlayerConfig +} +``` + +## Bước 4: Build và Test + +```bash +# Build +go build -o bin/api ./cmd/api + +# Test migration +# 1. Kiểm tra bảng player_configs +psql -h 47.84.63.130 -U postgres -d video_db -c "SELECT COUNT(*) FROM player_configs;" + +# 2. Kiểm tra user_preferences không còn player columns +psql -h 47.84.63.130 -U postgres -d video_db -c "\d user_preferences" +``` + +## Rollback (nếu cần) + +Nếu muốn rollback: + +```sql +-- Thêm lại columns vào user_preferences +ALTER TABLE user_preferences + ADD COLUMN IF NOT EXISTS autoplay BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS loop BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS muted BOOLEAN DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS show_controls BOOLEAN DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS pip BOOLEAN DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS airplay BOOLEAN DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS chromecast BOOLEAN DEFAULT TRUE, + ADD COLUMN IF NOT EXISTS encrytion_m3u8 BOOLEAN DEFAULT FALSE; + +-- Copy dữ liệu từ player_configs về (cho default config) +UPDATE user_preferences up +SET autoplay = pc.autoplay, + loop = pc.loop, + muted = pc.muted, + show_controls = pc.show_controls, + pip = pc.pip, + airplay = pc.airplay, + chromecast = pc.chromecast +FROM player_configs pc +WHERE pc.user_id = up.user_id AND pc.is_default = TRUE; + +-- Xóa bảng player_configs +DROP TABLE IF EXISTS player_configs CASCADE; +``` + +## Files liên quan + +### Migration scripts: +- `migrations/001_create_player_configs_table.sql` - Tạo bảng +- `migrations/002_migrate_player_settings.sql` - Migrate data +- `full_player_configs_migration.sql` - Kết hợp cả 2 +- `install_player_configs.sql` - Script đơn giản để chạy trực tiếp +- `migrate_player_configs.sh` - Shell script tự động hóa + +### Models: +- `internal/database/model/player_configs.gen.go` - Model mới +- `internal/database/model/user_preferences.gen.go` - Model cũ (sẽ thay đổi) + +### Services: +- `internal/rpc/app/service_user_features.go` - Player configs CRUD +- `internal/rpc/app/service_admin_finance_catalog.go` - Admin player configs +- `internal/api/preferences/service.go` - Legacy preferences (cần update) + +### Frontend: +- `stream.ui/src/routes/settings/PlayerConfigs/PlayerConfigs.vue` - UI mới +- `stream.ui/src/routes/settings/Settings.vue` - Menu navigation + +## Timeline khuyến nghị + +1. **Tuần 1**: Chạy migration trên staging, test kỹ +2. **Tuần 2**: Cập nhật code backend (preferences service) +3. **Tuần 3**: Cập nhật frontend nếu cần +4. **Tuần 4**: Deploy production + +## Lưu ý + +- ✅ Backup database trước khi chạy migration +- ✅ Test trên staging trước khi production +- ✅ Migration có transaction, sẽ rollback nếu lỗi +- ✅ Dữ liệu user_preferences được giữ nguyên cho notification settings diff --git a/api b/api index a1b9cc8..50ecd83 100755 Binary files a/api and b/api differ diff --git a/bin/api b/bin/api index 8bcd5dc..362acea 100755 Binary files a/bin/api and b/bin/api differ diff --git a/buf.gen.yaml b/buf.gen.yaml index 4f8d0b1..87b07bd 100644 --- a/buf.gen.yaml +++ b/buf.gen.yaml @@ -9,7 +9,7 @@ plugins: opt: - paths=source_relative - remote: buf.build/community/stephenh-ts-proto - out: ../stream-ui/src/server/gen/proto + out: ../stream.ui/src/server/gen/proto opt: - env=node - esModuleInterop=true diff --git a/cmd/api/main.go b/cmd/api/main.go index 3490a56..4cadcb0 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -8,9 +8,9 @@ import ( "os/signal" "syscall" + bootstrap "stream.api/internal/app" "stream.api/internal/config" "stream.api/internal/database/query" - videoruntime "stream.api/internal/video/runtime" "stream.api/pkg/cache" "stream.api/pkg/database" "stream.api/pkg/logger" @@ -47,7 +47,7 @@ func main() { tokenProvider := token.NewJWTProvider(cfg.JWT.Secret) appLogger := logger.NewLogger(cfg.Server.Mode) - module, err := videoruntime.NewModule(context.Background(), cfg, db, rdb, tokenProvider, appLogger) + module, err := bootstrap.NewGRPCModule(context.Background(), cfg, db, rdb, tokenProvider, appLogger) if err != nil { log.Fatalf("Failed to setup gRPC runtime module: %v", err) } diff --git a/cmd/gendb/main.go b/cmd/gendb/main.go index 5b31af9..1c7c41b 100644 --- a/cmd/gendb/main.go +++ b/cmd/gendb/main.go @@ -2,16 +2,22 @@ package main import ( "log" - "os" + "gorm.io/datatypes" "gorm.io/driver/postgres" "gorm.io/gen" "gorm.io/gen/field" "gorm.io/gorm" + "stream.api/internal/config" ) func main() { - dsn := os.Getenv("APP_DATABASE_DSN") + cfg, err := config.LoadConfig() + if err != nil { + log.Fatal(err) + } + + dsn := cfg.Database.DSN if dsn == "" { log.Fatal("APP_DATABASE_DSN is required") } @@ -21,42 +27,44 @@ func main() { log.Fatal("Không thể kết nối database:", err) } - // 2. CẤU HÌNH GENERATOR g := gen.NewGenerator(gen.Config{ OutPath: "internal/database/query", ModelPkgPath: "internal/database/model", - Mode: gen.WithDefaultQuery | gen.WithQueryInterface, // Tạo cả query mặc định và interface để dễ mock test + Mode: gen.WithDefaultQuery | gen.WithQueryInterface, - // Tùy chọn sinh code cho Model - FieldNullable: true, // Sinh pointer (*) cho các field có thể NULL trong DB - FieldCoverable: true, // Sinh pointer cho tất cả field (hữu ích khi dùng hàm Update zero value) - FieldSignable: true, // Hỗ trợ unsigned integer - FieldWithIndexTag: true, // Sinh tag gorm:index - FieldWithTypeTag: true, // Sinh tag gorm:type + FieldNullable: true, + FieldCoverable: true, + FieldSignable: true, + FieldWithIndexTag: true, + FieldWithTypeTag: true, }) g.UseDB(db) - // 3. XỬ LÝ KIỂU DỮ LIỆU (Data Mapping) - // Ví dụ: Map decimal sang float64 thay vì string hoặc mảng byte - dataMap := map[string]func(gorm.ColumnType) (dataType string){ - "decimal": func(columnType gorm.ColumnType) (dataType string) { - return "float64" // Hoặc "decimal.Decimal" nếu dùng shopspring/decimal - }, - "numeric": func(columnType gorm.ColumnType) (dataType string) { + dataMap := map[string]func(gorm.ColumnType) string{ + "decimal": func(columnType gorm.ColumnType) string { return "float64" }, - "text[]": func(columnType gorm.ColumnType) (dataType string) { - return "[]string" + "numeric": func(columnType gorm.ColumnType) string { + return "float64" }, - "_text": func(columnType gorm.ColumnType) (dataType string) { - return "[]string" + "text[]": func(columnType gorm.ColumnType) string { + return "pq.StringArray" + }, + "_text": func(columnType gorm.ColumnType) string { + return "pq.StringArray" + }, + "json": func(columnType gorm.ColumnType) string { + return "datatypes.JSON" + }, + "jsonb": func(columnType gorm.ColumnType) string { + return "datatypes.JSON" }, } - g.WithDataTypeMap(dataMap) - g.WithImportPkgPath("github.com/lib/pq") - // 4. CÁC TÙY CHỌN (OPTIONS) + g.WithDataTypeMap(dataMap) + g.WithImportPkgPath("github.com/lib/pq", "gorm.io/datatypes") + g.WithOpts( gen.FieldType("id", "string"), gen.FieldType("features", "pq.StringArray"), @@ -64,26 +72,13 @@ func main() { gen.FieldGORMTag("version", func(tag field.GormTag) field.GormTag { return tag.Set("version", "") }), - gen.FieldJSONTag("password", "-"), gen.FieldJSONTag("version", "-"), ) - // 5. CHỌN TABLE ĐỂ GENERATE - // GenerateAllTable() sẽ lấy tất cả, bao gồm cả bảng migration nếu có. - // Nếu muốn loại trừ bảng nào đó, hãy dùng logic filter hoặc liệt kê cụ thể. - - // Cách 1: Lấy tất cả (như code cũ) allTables := g.GenerateAllTable() - - // Cách 2: (Khuyên dùng) Lọc bỏ các bảng rác hoặc hệ thống - // var tableModels []interface{} - // for _, tbl := range allTables { - // // Logic lọc bảng ở đây nếu cần - // tableModels = append(tableModels, tbl) - // } - g.ApplyBasic(allTables...) - g.Execute() + + _ = datatypes.JSON{} // tránh import bị báo unused trong vài trường hợp } diff --git a/full_player_configs_migration.sql b/full_player_configs_migration.sql new file mode 100644 index 0000000..3d1990a --- /dev/null +++ b/full_player_configs_migration.sql @@ -0,0 +1,143 @@ +-- Full migration script for player_configs +-- This combines all migrations into one file for easy execution +-- Run: psql -h 47.84.63.130 -U postgres -d video_db -f full_player_configs_migration.sql + +\echo '==============================================' +\echo 'Starting full player_configs migration...' +\echo '==============================================' + +BEGIN; + +-- ============================================================ +-- PART 1: Create player_configs table +-- ============================================================ + +CREATE TABLE IF NOT EXISTS player_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + autoplay BOOLEAN NOT NULL DEFAULT FALSE, + loop BOOLEAN NOT NULL DEFAULT FALSE, + muted BOOLEAN NOT NULL DEFAULT FALSE, + show_controls BOOLEAN NOT NULL DEFAULT TRUE, + pip BOOLEAN NOT NULL DEFAULT TRUE, + airplay BOOLEAN NOT NULL DEFAULT TRUE, + chromecast BOOLEAN NOT NULL DEFAULT TRUE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP(3) WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) WITHOUT TIME ZONE NOT NULL, + version BIGINT NOT NULL DEFAULT 1 +); + +CREATE INDEX IF NOT EXISTS idx_player_configs_user_id ON player_configs(user_id); +CREATE INDEX IF NOT EXISTS idx_player_configs_is_default ON player_configs(is_default); +CREATE INDEX IF NOT EXISTS idx_player_configs_user_default ON player_configs(user_id, is_default); + +CREATE OR REPLACE FUNCTION update_player_configs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + NEW.version = OLD.version + 1; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_update_player_configs ON player_configs; + +CREATE TRIGGER trg_update_player_configs +BEFORE UPDATE ON player_configs +FOR EACH ROW +EXECUTE FUNCTION update_player_configs_updated_at(); + +-- ============================================================ +-- PART 2: Migrate data from user_preferences +-- ============================================================ + +INSERT INTO player_configs ( + id, user_id, name, description, + autoplay, loop, muted, + show_controls, pip, airplay, chromecast, + is_active, is_default, + created_at, updated_at, version +) +SELECT + gen_random_uuid(), + up.user_id, + 'Default Config', + 'Migrated from user_preferences', + COALESCE(up.autoplay, FALSE), + COALESCE(up.loop, FALSE), + COALESCE(up.muted, FALSE), + COALESCE(up.show_controls, TRUE), + COALESCE(up.pip, TRUE), + COALESCE(up.airplay, TRUE), + COALESCE(up.chromecast, TRUE), + TRUE, + TRUE, + COALESCE(up.created_at, CURRENT_TIMESTAMP), + CURRENT_TIMESTAMP, + COALESCE(up.version, 1) +FROM user_preferences up +WHERE NOT EXISTS ( + SELECT 1 FROM player_configs pc WHERE pc.user_id = up.user_id AND pc.is_default = TRUE +); + +-- ============================================================ +-- PART 3: Remove old columns from user_preferences +-- ============================================================ + +ALTER TABLE user_preferences + DROP COLUMN IF EXISTS autoplay, + DROP COLUMN IF EXISTS loop, + DROP COLUMN IF EXISTS muted, + DROP COLUMN IF EXISTS show_controls, + DROP COLUMN IF EXISTS pip, + DROP COLUMN IF EXISTS airplay, + DROP COLUMN IF EXISTS chromecast, + DROP COLUMN IF EXISTS encrytion_m3u8; + +-- ============================================================ +-- PART 4: Add constraints +-- ============================================================ + +CREATE UNIQUE INDEX IF NOT EXISTS idx_player_configs_one_default_per_user +ON player_configs(user_id) +WHERE is_default = TRUE; + +-- ============================================================ +-- Verification +-- ============================================================ + +DO $$ +DECLARE + migrated_count INTEGER; + prefs_count INTEGER; +BEGIN + SELECT COUNT(*) INTO migrated_count FROM player_configs WHERE description = 'Migrated from user_preferences'; + SELECT COUNT(*) INTO prefs_count FROM user_preferences; + + RAISE NOTICE '============================================'; + RAISE NOTICE 'Migration completed!'; + RAISE NOTICE 'User preferences rows: %', prefs_count; + RAISE NOTICE 'Player configs created: %', migrated_count; + RAISE NOTICE '============================================'; +END $$; + +COMMIT; + +-- Verify columns removed from user_preferences +SELECT 'user_preferences columns:' AS info; +SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'user_preferences' ORDER BY ordinal_position; + +-- Verify player_configs structure +SELECT 'player_configs columns:' AS info; +SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'player_configs' ORDER BY ordinal_position; + +-- Sample data +SELECT 'Sample migrated data:' AS info; +SELECT pc.user_id, pc.name, pc.autoplay, pc.loop, pc.muted, pc.show_controls, pc.is_default +FROM player_configs pc +WHERE pc.description = 'Migrated from user_preferences' +LIMIT 5; diff --git a/go.mod b/go.mod index 261f1d7..928f1c1 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( google.golang.org/grpc v1.79.2 google.golang.org/protobuf v1.36.11 gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 gorm.io/gen v0.3.27 gorm.io/gorm v1.31.1 gorm.io/plugin/dbresolver v1.6.2 @@ -44,6 +45,7 @@ require ( github.com/aws/smithy-go v1.24.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -54,7 +56,11 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -64,6 +70,7 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect @@ -74,4 +81,8 @@ require ( gorm.io/datatypes v1.2.4 // indirect gorm.io/driver/mysql v1.5.7 // indirect gorm.io/hints v1.1.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect ) diff --git a/go.sum b/go.sum index e5b2c50..f21c9ed 100644 --- a/go.sum +++ b/go.sum @@ -51,6 +51,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -99,17 +101,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.8/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE= github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 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= @@ -153,6 +161,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= @@ -161,6 +171,7 @@ golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= @@ -203,3 +214,11 @@ gorm.io/hints v1.1.0 h1:Lp4z3rxREufSdxn4qmkK3TLDltrM10FLTHiuqwDPvXw= gorm.io/hints v1.1.0/go.mod h1:lKQ0JjySsPBj3uslFzY3JhYDtqEwzm+G1hv8rWujB6Y= gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/install_player_configs.sql b/install_player_configs.sql new file mode 100644 index 0000000..b5a9120 --- /dev/null +++ b/install_player_configs.sql @@ -0,0 +1,55 @@ +-- Quick install script for player_configs table +-- Run this directly in your PostgreSQL database +-- Usage: psql -d video_db -f install_player_configs.sql + +BEGIN; + +-- Create player_configs table +CREATE TABLE IF NOT EXISTS player_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE, + name TEXT NOT NULL, + description TEXT, + autoplay BOOLEAN NOT NULL DEFAULT FALSE, + loop BOOLEAN NOT NULL DEFAULT FALSE, + muted BOOLEAN NOT NULL DEFAULT FALSE, + show_controls BOOLEAN NOT NULL DEFAULT TRUE, + pip BOOLEAN NOT NULL DEFAULT TRUE, + airplay BOOLEAN NOT NULL DEFAULT TRUE, + chromecast BOOLEAN NOT NULL DEFAULT TRUE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP(3) WITHOUT TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) WITHOUT TIME ZONE NOT NULL, + version BIGINT NOT NULL DEFAULT 1 +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_player_configs_user_id ON player_configs(user_id); +CREATE INDEX IF NOT EXISTS idx_player_configs_is_default ON player_configs(is_default); +CREATE INDEX IF NOT EXISTS idx_player_configs_user_default ON player_configs(user_id, is_default); + +-- Trigger to auto-update updated_at and version +CREATE OR REPLACE FUNCTION update_player_configs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + NEW.version = OLD.version + 1; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_update_player_configs ON player_configs; + +CREATE TRIGGER trg_update_player_configs +BEFORE UPDATE ON player_configs +FOR EACH ROW +EXECUTE FUNCTION update_player_configs_updated_at(); + +COMMIT; + +-- Verify installation +SELECT table_name, column_name, data_type +FROM information_schema.columns +WHERE table_name = 'player_configs' +ORDER BY ordinal_position; diff --git a/internal/api/admin/ad_templates.go b/internal/api/admin/ad_templates.go deleted file mode 100644 index dfa19e6..0000000 --- a/internal/api/admin/ad_templates.go +++ /dev/null @@ -1,390 +0,0 @@ -//go:build ignore -// +build ignore - -package admin - -import ( - "errors" - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" - - // "stream.api/internal/database/model" - "stream.api/internal/database/model" - "stream.api/pkg/response" -) - -type AdminAdTemplatePayload struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Name string `json:"name"` - Description string `json:"description"` - VastTagURL string `json:"vast_tag_url"` - AdFormat string `json:"ad_format"` - Duration *int64 `json:"duration,omitempty"` - IsActive bool `json:"is_active"` - IsDefault bool `json:"is_default"` - OwnerEmail string `json:"owner_email,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type SaveAdminAdTemplateRequest struct { - UserID string `json:"user_id" binding:"required"` - Name string `json:"name" binding:"required"` - Description string `json:"description"` - VASTTagURL string `json:"vast_tag_url" binding:"required"` - AdFormat string `json:"ad_format"` - Duration *int64 `json:"duration"` - IsActive *bool `json:"is_active"` - IsDefault *bool `json:"is_default"` -} - -func normalizeAdminAdFormat(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 unsetAdminDefaultTemplates(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 validateAdminAdTemplateRequest(req *SaveAdminAdTemplateRequest) string { - if strings.TrimSpace(req.UserID) == "" { - return "User ID is required" - } - if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.VASTTagURL) == "" { - return "Name and VAST URL are required" - } - format := normalizeAdminAdFormat(req.AdFormat) - if format == "mid-roll" && (req.Duration == nil || *req.Duration <= 0) { - return "Duration is required for mid-roll templates" - } - return "" -} - -func (h *Handler) buildAdminAdTemplatePayload(ctx *gin.Context, item model.AdTemplate, ownerEmail string) AdminAdTemplatePayload { - return AdminAdTemplatePayload{ - ID: item.ID, - UserID: item.UserID, - Name: item.Name, - Description: adminStringValue(item.Description), - VastTagURL: item.VastTagURL, - AdFormat: adminStringValue(item.AdFormat), - Duration: item.Duration, - IsActive: adminBoolValue(item.IsActive, true), - IsDefault: item.IsDefault, - OwnerEmail: ownerEmail, - CreatedAt: adminFormatTime(item.CreatedAt), - UpdatedAt: adminFormatTime(item.UpdatedAt), - } -} - -// @Summary List All Ad Templates -// @Description Get paginated list of all ad templates across users (admin only) -// @Tags admin -// @Produce json -// @Param page query int false "Page" default(1) -// @Param limit query int false "Limit" default(20) -// @Param user_id query string false "Filter by user ID" -// @Param search query string false "Search by name" -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 403 {object} response.Response -// @Router /admin/ad-templates [get] -// @Security BearerAuth -func (h *Handler) ListAdTemplates(c *gin.Context) { - ctx := c.Request.Context() - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - if page < 1 { - page = 1 - } - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - if limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - offset := (page - 1) * limit - - search := strings.TrimSpace(c.Query("search")) - userID := strings.TrimSpace(c.Query("user_id")) - - db := h.db.WithContext(ctx).Model(&model.AdTemplate{}) - if search != "" { - 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 { - response.Error(c, http.StatusInternalServerError, "Failed to list ad templates") - return - } - - var templates []model.AdTemplate - if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&templates).Error; err != nil { - h.logger.Error("Failed to list ad templates", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to list ad templates") - return - } - - ownerIDs := map[string]bool{} - for _, t := range templates { - ownerIDs[t.UserID] = true - } - ownerEmails := map[string]string{} - if len(ownerIDs) > 0 { - ids := make([]string, 0, len(ownerIDs)) - for id := range ownerIDs { - ids = append(ids, id) - } - var users []struct{ ID, Email string } - h.db.WithContext(ctx).Table("\"user\"").Select("id, email").Where("id IN ?", ids).Find(&users) - for _, u := range users { - ownerEmails[u.ID] = u.Email - } - } - - result := make([]AdminAdTemplatePayload, 0, len(templates)) - for _, t := range templates { - result = append(result, h.buildAdminAdTemplatePayload(c, t, ownerEmails[t.UserID])) - } - - response.Success(c, gin.H{ - "templates": result, - "total": total, - "page": page, - "limit": limit, - }) -} - -// @Summary Get Ad Template Detail -// @Description Get ad template detail (admin only) -// @Tags admin -// @Produce json -// @Param id path string true "Ad Template ID" -// @Success 200 {object} response.Response -// @Router /admin/ad-templates/{id} [get] -// @Security BearerAuth -func (h *Handler) GetAdTemplate(c *gin.Context) { - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Ad template not found") - return - } - - var item model.AdTemplate - if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&item).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusNotFound, "Ad template not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to load ad template") - return - } - - ownerEmail := "" - var user model.User - if err := h.db.WithContext(c.Request.Context()).Select("id, email").Where("id = ?", item.UserID).First(&user).Error; err == nil { - ownerEmail = user.Email - } - - response.Success(c, gin.H{"template": h.buildAdminAdTemplatePayload(c, item, ownerEmail)}) -} - -// @Summary Create Ad Template -// @Description Create an ad template for any user (admin only) -// @Tags admin -// @Accept json -// @Produce json -// @Param request body SaveAdminAdTemplateRequest true "Ad template payload" -// @Success 201 {object} response.Response -// @Router /admin/ad-templates [post] -// @Security BearerAuth -func (h *Handler) CreateAdTemplate(c *gin.Context) { - var req SaveAdminAdTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - if msg := validateAdminAdTemplateRequest(&req); msg != "" { - response.Error(c, http.StatusBadRequest, msg) - return - } - - ctx := c.Request.Context() - var user model.User - if err := h.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.UserID)).First(&user).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusBadRequest, "User not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to save ad template") - return - } - - item := &model.AdTemplate{ - ID: uuid.New().String(), - UserID: user.ID, - Name: strings.TrimSpace(req.Name), - Description: adminStringPtr(req.Description), - VastTagURL: strings.TrimSpace(req.VASTTagURL), - AdFormat: model.StringPtr(normalizeAdminAdFormat(req.AdFormat)), - Duration: req.Duration, - IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive), - IsDefault: req.IsDefault != nil && *req.IsDefault, - } - if !adminBoolValue(item.IsActive, true) { - item.IsDefault = false - } - - if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if item.IsDefault { - if err := unsetAdminDefaultTemplates(tx, item.UserID, ""); err != nil { - return err - } - } - return tx.Create(item).Error - }); err != nil { - h.logger.Error("Failed to create ad template", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to save ad template") - return - } - - response.Created(c, gin.H{"template": h.buildAdminAdTemplatePayload(c, *item, user.Email)}) -} - -// @Summary Update Ad Template -// @Description Update an ad template for any user (admin only) -// @Tags admin -// @Accept json -// @Produce json -// @Param id path string true "Ad Template ID" -// @Param request body SaveAdminAdTemplateRequest true "Ad template payload" -// @Success 200 {object} response.Response -// @Router /admin/ad-templates/{id} [put] -// @Security BearerAuth -func (h *Handler) UpdateAdTemplate(c *gin.Context) { - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Ad template not found") - return - } - - var req SaveAdminAdTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - if msg := validateAdminAdTemplateRequest(&req); msg != "" { - response.Error(c, http.StatusBadRequest, msg) - return - } - - ctx := c.Request.Context() - var user model.User - if err := h.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.UserID)).First(&user).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusBadRequest, "User not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to save ad template") - return - } - - var item model.AdTemplate - if err := h.db.WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusNotFound, "Ad template not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to save ad template") - return - } - - item.UserID = user.ID - item.Name = strings.TrimSpace(req.Name) - item.Description = adminStringPtr(req.Description) - item.VastTagURL = strings.TrimSpace(req.VASTTagURL) - item.AdFormat = model.StringPtr(normalizeAdminAdFormat(req.AdFormat)) - item.Duration = req.Duration - if req.IsActive != nil { - item.IsActive = model.BoolPtr(*req.IsActive) - } - if req.IsDefault != nil { - item.IsDefault = *req.IsDefault - } - if !adminBoolValue(item.IsActive, true) { - item.IsDefault = false - } - - if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if item.IsDefault { - if err := unsetAdminDefaultTemplates(tx, item.UserID, item.ID); err != nil { - return err - } - } - return tx.Save(&item).Error - }); err != nil { - h.logger.Error("Failed to update ad template", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to save ad template") - return - } - - response.Success(c, gin.H{"template": h.buildAdminAdTemplatePayload(c, item, user.Email)}) -} - -// @Summary Delete Ad Template (Admin) -// @Description Delete any ad template by ID (admin only) -// @Tags admin -// @Produce json -// @Param id path string true "Ad Template ID" -// @Success 200 {object} response.Response -// @Failure 404 {object} response.Response -// @Router /admin/ad-templates/{id} [delete] -// @Security BearerAuth -func (h *Handler) DeleteAdTemplate(c *gin.Context) { - id := c.Param("id") - - err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("ad_template_id = ?", id).Delete(&model.VideoAdConfig{}).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 == gorm.ErrRecordNotFound { - response.Error(c, http.StatusNotFound, "Ad template not found") - return - } - h.logger.Error("Failed to delete ad template", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to delete ad template") - return - } - - response.Success(c, gin.H{"message": "Ad template deleted"}) -} diff --git a/internal/api/admin/common.go b/internal/api/admin/common.go deleted file mode 100644 index fa9f893..0000000 --- a/internal/api/admin/common.go +++ /dev/null @@ -1,94 +0,0 @@ -//go:build ignore -// +build ignore - -package admin - -import ( - "fmt" - "strings" - "time" -) - -func adminFormatTime(value *time.Time) string { - if value == nil { - return "" - } - return value.UTC().Format(time.RFC3339) -} - -func adminFormatTimeValue(value time.Time) string { - if value.IsZero() { - return "" - } - return value.UTC().Format(time.RFC3339) -} - -func adminStringPtr(value string) *string { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - return nil - } - return &trimmed -} - -func adminStringValue(value *string) string { - if value == nil { - return "" - } - return strings.TrimSpace(*value) -} - -func adminInt64Ptr(value *int64) *int64 { - if value == nil { - return nil - } - return value -} - -func adminStringSlice(values []string) []string { - if len(values) == 0 { - return nil - } - - result := make([]string, 0, len(values)) - for _, value := range values { - trimmed := strings.TrimSpace(value) - if trimmed == "" { - continue - } - result = append(result, trimmed) - } - - if len(result) == 0 { - return nil - } - - return result -} - -func adminStringSliceValue(values []string) []string { - if len(values) == 0 { - return []string{} - } - - return append([]string(nil), values...) -} - -func adminBoolValue(value *bool, fallback bool) bool { - if value == nil { - return fallback - } - return *value -} - -func adminInvoiceID(id string) string { - trimmed := strings.ReplaceAll(strings.TrimSpace(id), "-", "") - if len(trimmed) > 12 { - trimmed = trimmed[:12] - } - return "INV-" + strings.ToUpper(trimmed) -} - -func adminTransactionID(prefix string) string { - return fmt.Sprintf("%s_%d", prefix, time.Now().UnixNano()) -} diff --git a/internal/api/admin/dashboard.go b/internal/api/admin/dashboard.go deleted file mode 100644 index b7148e6..0000000 --- a/internal/api/admin/dashboard.go +++ /dev/null @@ -1,68 +0,0 @@ -//go:build ignore -// +build ignore - -package admin - -import ( - "time" - - "github.com/gin-gonic/gin" - "stream.api/internal/database/model" - "stream.api/pkg/response" -) - -type DashboardPayload struct { - TotalUsers int64 `json:"total_users"` - TotalVideos int64 `json:"total_videos"` - TotalStorageUsed int64 `json:"total_storage_used"` - TotalPayments int64 `json:"total_payments"` - TotalRevenue float64 `json:"total_revenue"` - ActiveSubscriptions int64 `json:"active_subscriptions"` - TotalAdTemplates int64 `json:"total_ad_templates"` - NewUsersToday int64 `json:"new_users_today"` - NewVideosToday int64 `json:"new_videos_today"` -} - -// @Summary Admin Dashboard -// @Description Get system-wide statistics for the admin dashboard -// @Tags admin -// @Produce json -// @Success 200 {object} response.Response{data=DashboardPayload} -// @Failure 401 {object} response.Response -// @Failure 403 {object} response.Response -// @Router /admin/dashboard [get] -// @Security BearerAuth -func (h *Handler) Dashboard(c *gin.Context) { - ctx := c.Request.Context() - var payload DashboardPayload - - h.db.WithContext(ctx).Model(&model.User{}).Count(&payload.TotalUsers) - h.db.WithContext(ctx).Model(&model.Video{}).Count(&payload.TotalVideos) - - h.db.WithContext(ctx).Model(&model.User{}). - Select("COALESCE(SUM(storage_used), 0)"). - Row().Scan(&payload.TotalStorageUsed) - - h.db.WithContext(ctx).Model(&model.Payment{}).Count(&payload.TotalPayments) - - h.db.WithContext(ctx).Model(&model.Payment{}). - Where("status = ?", "SUCCESS"). - Select("COALESCE(SUM(amount), 0)"). - Row().Scan(&payload.TotalRevenue) - - h.db.WithContext(ctx).Model(&model.PlanSubscription{}). - Where("expires_at > ?", time.Now()). - Count(&payload.ActiveSubscriptions) - - h.db.WithContext(ctx).Model(&model.AdTemplate{}).Count(&payload.TotalAdTemplates) - - today := time.Now().Truncate(24 * time.Hour) - h.db.WithContext(ctx).Model(&model.User{}). - Where("created_at >= ?", today). - Count(&payload.NewUsersToday) - h.db.WithContext(ctx).Model(&model.Video{}). - Where("created_at >= ?", today). - Count(&payload.NewVideosToday) - - response.Success(c, payload) -} diff --git a/internal/api/admin/handler.go b/internal/api/admin/handler.go deleted file mode 100644 index 5616bcd..0000000 --- a/internal/api/admin/handler.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build ignore -// +build ignore - -package admin - -import ( - "gorm.io/gorm" - runtimegrpc "stream.api/internal/video/runtime/grpc" - "stream.api/internal/video/runtime/services" - "stream.api/pkg/logger" -) - -type RenderRuntime interface { - JobService() *services.JobService - AgentRuntime() *runtimegrpc.Server -} - -type Handler struct { - logger logger.Logger - db *gorm.DB - runtime RenderRuntime -} - -func NewHandler(l logger.Logger, db *gorm.DB, renderRuntime RenderRuntime) *Handler { - return &Handler{logger: l, db: db, runtime: renderRuntime} -} diff --git a/internal/api/admin/payments.go b/internal/api/admin/payments.go deleted file mode 100644 index d05f254..0000000 --- a/internal/api/admin/payments.go +++ /dev/null @@ -1,521 +0,0 @@ -//go:build ignore -// +build ignore - -package admin - -import ( - "errors" - "fmt" - "net/http" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" - "gorm.io/gorm/clause" - "stream.api/internal/database/model" - "stream.api/pkg/response" -) - -const ( - adminWalletTransactionTypeTopup = "topup" - adminWalletTransactionTypeSubscriptionDebit = "subscription_debit" - adminPaymentMethodWallet = "wallet" - adminPaymentMethodTopup = "topup" -) - -var adminAllowedTermMonths = map[int32]struct{}{ - 1: {}, 3: {}, 6: {}, 12: {}, -} - -type AdminPaymentPayload struct { - ID string `json:"id"` - UserID string `json:"user_id"` - PlanID *string `json:"plan_id,omitempty"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Status string `json:"status"` - Provider string `json:"provider"` - TransactionID string `json:"transaction_id,omitempty"` - UserEmail string `json:"user_email,omitempty"` - PlanName string `json:"plan_name,omitempty"` - InvoiceID string `json:"invoice_id"` - TermMonths *int32 `json:"term_months,omitempty"` - PaymentMethod *string `json:"payment_method,omitempty"` - ExpiresAt *string `json:"expires_at,omitempty"` - WalletAmount *float64 `json:"wallet_amount,omitempty"` - TopupAmount *float64 `json:"topup_amount,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type CreateAdminPaymentRequest struct { - UserID string `json:"user_id" binding:"required"` - PlanID string `json:"plan_id" binding:"required"` - TermMonths int32 `json:"term_months" binding:"required"` - PaymentMethod string `json:"payment_method" binding:"required"` - TopupAmount *float64 `json:"topup_amount,omitempty"` -} - -type UpdateAdminPaymentRequest struct { - Status string `json:"status" binding:"required"` -} - -func normalizeAdminPaymentMethod(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case adminPaymentMethodWallet: - return adminPaymentMethodWallet - case adminPaymentMethodTopup: - return adminPaymentMethodTopup - default: - return "" - } -} - -func normalizeAdminPaymentStatus(value string) string { - switch strings.ToUpper(strings.TrimSpace(value)) { - case "PENDING": - return "PENDING" - case "FAILED": - return "FAILED" - default: - return "SUCCESS" - } -} - -func adminIsAllowedTermMonths(value int32) bool { - _, ok := adminAllowedTermMonths[value] - return ok -} - -func (h *Handler) loadAdminPaymentPayload(ctx *gin.Context, payment model.Payment) (AdminPaymentPayload, error) { - payload := AdminPaymentPayload{ - ID: payment.ID, - UserID: payment.UserID, - PlanID: payment.PlanID, - Amount: payment.Amount, - Currency: strings.ToUpper(adminStringValue(payment.Currency)), - Status: normalizeAdminPaymentStatus(adminStringValue(payment.Status)), - Provider: strings.ToUpper(adminStringValue(payment.Provider)), - TransactionID: adminStringValue(payment.TransactionID), - InvoiceID: adminInvoiceID(payment.ID), - CreatedAt: adminFormatTime(payment.CreatedAt), - UpdatedAt: adminFormatTimeValue(payment.UpdatedAt), - } - if payload.Currency == "" { - payload.Currency = "USD" - } - - var user model.User - if err := h.db.WithContext(ctx.Request.Context()).Select("id, email").Where("id = ?", payment.UserID).First(&user).Error; err == nil { - payload.UserEmail = user.Email - } - - if payment.PlanID != nil && strings.TrimSpace(*payment.PlanID) != "" { - var plan model.Plan - if err := h.db.WithContext(ctx.Request.Context()).Where("id = ?", *payment.PlanID).First(&plan).Error; err == nil { - payload.PlanName = plan.Name - } - } - - var subscription model.PlanSubscription - if err := h.db.WithContext(ctx.Request.Context()).Where("payment_id = ?", payment.ID).Order("created_at DESC").First(&subscription).Error; err == nil { - payload.TermMonths = &subscription.TermMonths - method := subscription.PaymentMethod - payload.PaymentMethod = &method - expiresAt := subscription.ExpiresAt.UTC().Format(time.RFC3339) - payload.ExpiresAt = &expiresAt - payload.WalletAmount = &subscription.WalletAmount - payload.TopupAmount = &subscription.TopupAmount - } - - return payload, nil -} - -func (h *Handler) adminLockUserForUpdate(ctx *gin.Context, tx *gorm.DB, userID string) (*model.User, error) { - var user model.User - if err := tx.WithContext(ctx.Request.Context()).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", userID).First(&user).Error; err != nil { - return nil, err - } - return &user, nil -} - -func (h *Handler) adminCreateSubscriptionPayment(ctx *gin.Context, req CreateAdminPaymentRequest) (*model.Payment, *model.PlanSubscription, float64, error) { - planID := strings.TrimSpace(req.PlanID) - userID := strings.TrimSpace(req.UserID) - paymentMethod := normalizeAdminPaymentMethod(req.PaymentMethod) - if paymentMethod == "" { - return nil, nil, 0, errors.New("Payment method must be wallet or topup") - } - if !adminIsAllowedTermMonths(req.TermMonths) { - return nil, nil, 0, errors.New("Term months must be one of 1, 3, 6, or 12") - } - - var planRecord model.Plan - if err := h.db.WithContext(ctx.Request.Context()).Where("id = ?", planID).First(&planRecord).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil, 0, errors.New("Plan not found") - } - return nil, nil, 0, err - } - if !adminBoolValue(planRecord.IsActive, true) { - return nil, nil, 0, errors.New("Plan is not active") - } - - var user model.User - if err := h.db.WithContext(ctx.Request.Context()).Where("id = ?", userID).First(&user).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil, 0, errors.New("User not found") - } - return nil, nil, 0, err - } - - totalAmount := planRecord.Price * float64(req.TermMonths) - status := "SUCCESS" - provider := "INTERNAL" - currency := "USD" - transactionID := adminTransactionID("sub") - now := time.Now().UTC() - payment := &model.Payment{ - ID: uuid.New().String(), - UserID: user.ID, - PlanID: &planRecord.ID, - Amount: totalAmount, - Currency: ¤cy, - Status: &status, - Provider: &provider, - TransactionID: &transactionID, - } - - var subscription *model.PlanSubscription - var walletBalance float64 - err := h.db.WithContext(ctx.Request.Context()).Transaction(func(tx *gorm.DB) error { - if _, err := h.adminLockUserForUpdate(ctx, tx, user.ID); err != nil { - return err - } - - var currentSubscription model.PlanSubscription - hasCurrentSubscription := false - if err := tx.Where("user_id = ?", user.ID).Order("created_at DESC").First(¤tSubscription).Error; err == nil { - hasCurrentSubscription = true - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - baseExpiry := now - if hasCurrentSubscription && currentSubscription.ExpiresAt.After(baseExpiry) { - baseExpiry = currentSubscription.ExpiresAt.UTC() - } - newExpiry := baseExpiry.AddDate(0, int(req.TermMonths), 0) - - currentWalletBalance, err := model.GetWalletBalance(ctx.Request.Context(), tx, user.ID) - if err != nil { - return err - } - shortfall := totalAmount - currentWalletBalance - if shortfall < 0 { - shortfall = 0 - } - if paymentMethod == adminPaymentMethodWallet && shortfall > 0 { - return fmt.Errorf("Insufficient wallet balance") - } - - topupAmount := 0.0 - if paymentMethod == adminPaymentMethodTopup { - if req.TopupAmount == nil { - return fmt.Errorf("Top-up amount is required when payment method is topup") - } - topupAmount = *req.TopupAmount - if topupAmount <= 0 { - return fmt.Errorf("Top-up amount must be greater than 0") - } - if topupAmount < shortfall { - return fmt.Errorf("Top-up amount must be greater than or equal to the required shortfall") - } - } - - if err := tx.Create(payment).Error; err != nil { - return err - } - - if paymentMethod == adminPaymentMethodTopup { - topupTransaction := &model.WalletTransaction{ - ID: uuid.New().String(), - UserID: user.ID, - Type: adminWalletTransactionTypeTopup, - Amount: topupAmount, - Currency: model.StringPtr(currency), - Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", planRecord.Name, req.TermMonths)), - PaymentID: &payment.ID, - PlanID: &planRecord.ID, - TermMonths: &req.TermMonths, - } - if err := tx.Create(topupTransaction).Error; err != nil { - return err - } - } - - debitTransaction := &model.WalletTransaction{ - ID: uuid.New().String(), - UserID: user.ID, - Type: adminWalletTransactionTypeSubscriptionDebit, - Amount: -totalAmount, - Currency: model.StringPtr(currency), - Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", planRecord.Name, req.TermMonths)), - PaymentID: &payment.ID, - PlanID: &planRecord.ID, - TermMonths: &req.TermMonths, - } - if err := tx.Create(debitTransaction).Error; err != nil { - return err - } - - subscription = &model.PlanSubscription{ - ID: uuid.New().String(), - UserID: user.ID, - PaymentID: payment.ID, - PlanID: planRecord.ID, - TermMonths: req.TermMonths, - PaymentMethod: paymentMethod, - WalletAmount: totalAmount, - TopupAmount: topupAmount, - StartedAt: now, - ExpiresAt: newExpiry, - } - if err := tx.Create(subscription).Error; err != nil { - return err - } - - if err := tx.Model(&model.User{}).Where("id = ?", user.ID).Update("plan_id", planRecord.ID).Error; err != nil { - return err - } - - notification := &model.Notification{ - ID: uuid.New().String(), - UserID: user.ID, - 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("{}"), - } - if err := tx.Create(notification).Error; err != nil { - return err - } - - walletBalance, err = model.GetWalletBalance(ctx.Request.Context(), tx, user.ID) - if err != nil { - return err - } - return nil - }) - if err != nil { - return nil, nil, 0, err - } - return payment, subscription, walletBalance, nil -} - -// @Summary List All Payments -// @Description Get paginated list of all payments across users (admin only) -// @Tags admin -// @Produce json -// @Param page query int false "Page" default(1) -// @Param limit query int false "Limit" default(20) -// @Param user_id query string false "Filter by user ID" -// @Param status query string false "Filter by status" -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 403 {object} response.Response -// @Router /admin/payments [get] -// @Security BearerAuth -func (h *Handler) ListPayments(c *gin.Context) { - ctx := c.Request.Context() - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - if page < 1 { - page = 1 - } - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - if limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - offset := (page - 1) * limit - - userID := strings.TrimSpace(c.Query("user_id")) - status := strings.TrimSpace(c.Query("status")) - - db := h.db.WithContext(ctx).Model(&model.Payment{}) - if userID != "" { - db = db.Where("user_id = ?", userID) - } - if status != "" { - db = db.Where("UPPER(status) = ?", strings.ToUpper(status)) - } - - var total int64 - if err := db.Count(&total).Error; err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to list payments") - return - } - - var payments []model.Payment - if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&payments).Error; err != nil { - h.logger.Error("Failed to list payments", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to list payments") - return - } - - result := make([]AdminPaymentPayload, 0, len(payments)) - for _, p := range payments { - payload, err := h.loadAdminPaymentPayload(c, p) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to list payments") - return - } - result = append(result, payload) - } - - response.Success(c, gin.H{ - "payments": result, - "total": total, - "page": page, - "limit": limit, - }) -} - -// @Summary Get Payment Detail -// @Description Get payment detail (admin only) -// @Tags admin -// @Produce json -// @Param id path string true "Payment ID" -// @Success 200 {object} response.Response -// @Router /admin/payments/{id} [get] -// @Security BearerAuth -func (h *Handler) GetPayment(c *gin.Context) { - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Payment not found") - return - } - - var payment model.Payment - if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&payment).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusNotFound, "Payment not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to get payment") - return - } - - payload, err := h.loadAdminPaymentPayload(c, payment) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to get payment") - return - } - - response.Success(c, gin.H{"payment": payload}) -} - -// @Summary Create Payment -// @Description Create a model subscription charge for a user (admin only) -// @Tags admin -// @Accept json -// @Produce json -// @Param request body CreateAdminPaymentRequest true "Payment payload" -// @Success 201 {object} response.Response -// @Router /admin/payments [post] -// @Security BearerAuth -func (h *Handler) CreatePayment(c *gin.Context) { - var req CreateAdminPaymentRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - payment, subscription, walletBalance, err := h.adminCreateSubscriptionPayment(c, req) - if err != nil { - switch err.Error() { - case "User not found", "Plan not found", "Plan is not active", "Payment method must be wallet or topup", "Term months must be one of 1, 3, 6, or 12", "Insufficient wallet balance", "Top-up amount is required when payment method is topup", "Top-up amount must be greater than 0", "Top-up amount must be greater than or equal to the required shortfall": - response.Error(c, http.StatusBadRequest, err.Error()) - return - default: - h.logger.Error("Failed to create admin payment", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to create payment") - return - } - } - - payload, err := h.loadAdminPaymentPayload(c, *payment) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to create payment") - return - } - - response.Created(c, gin.H{ - "payment": payload, - "subscription": subscription, - "wallet_balance": walletBalance, - "invoice_id": adminInvoiceID(payment.ID), - }) -} - -// @Summary Update Payment -// @Description Update payment status safely without hard delete (admin only) -// @Tags admin -// @Accept json -// @Produce json -// @Param id path string true "Payment ID" -// @Param request body UpdateAdminPaymentRequest true "Payment update payload" -// @Success 200 {object} response.Response -// @Router /admin/payments/{id} [put] -// @Security BearerAuth -func (h *Handler) UpdatePayment(c *gin.Context) { - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Payment not found") - return - } - - var req UpdateAdminPaymentRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - newStatus := normalizeAdminPaymentStatus(req.Status) - var payment model.Payment - if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&payment).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusNotFound, "Payment not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to update payment") - return - } - - currentStatus := normalizeAdminPaymentStatus(adminStringValue(payment.Status)) - if currentStatus != newStatus { - if (currentStatus == "FAILED" || currentStatus == "PENDING") && newStatus == "SUCCESS" { - response.Error(c, http.StatusBadRequest, "Cannot transition payment to SUCCESS from admin update; recreate through the payment flow instead") - return - } - payment.Status = &newStatus - if err := h.db.WithContext(c.Request.Context()).Save(&payment).Error; err != nil { - h.logger.Error("Failed to update payment", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to update payment") - return - } - } - - payload, err := h.loadAdminPaymentPayload(c, payment) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to update payment") - return - } - - response.Success(c, gin.H{"payment": payload}) -} diff --git a/internal/api/admin/plans.go b/internal/api/admin/plans.go deleted file mode 100644 index 2505cb1..0000000 --- a/internal/api/admin/plans.go +++ /dev/null @@ -1,302 +0,0 @@ -//go:build ignore -// +build ignore - -package admin - -import ( - "errors" - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" - "stream.api/internal/database/model" - "stream.api/pkg/response" -) - -type AdminPlanPayload struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Features []string `json:"features,omitempty"` - Price float64 `json:"price"` - Cycle string `json:"cycle"` - StorageLimit int64 `json:"storage_limit"` - UploadLimit int32 `json:"upload_limit"` - DurationLimit int32 `json:"duration_limit"` - QualityLimit string `json:"quality_limit"` - IsActive bool `json:"is_active"` - UserCount int64 `json:"user_count"` - PaymentCount int64 `json:"payment_count"` - SubscriptionCount int64 `json:"subscription_count"` -} - -type SavePlanRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - Features []string `json:"features"` - Price float64 `json:"price" binding:"required"` - Cycle string `json:"cycle" binding:"required"` - StorageLimit int64 `json:"storage_limit" binding:"required"` - UploadLimit int32 `json:"upload_limit" binding:"required"` - IsActive *bool `json:"is_active"` -} - -func buildAdminPlanPayload(plan model.Plan, userCount, paymentCount, subscriptionCount int64) AdminPlanPayload { - return AdminPlanPayload{ - ID: plan.ID, - Name: plan.Name, - Description: adminStringValue(plan.Description), - Features: adminStringSliceValue(plan.Features), - Price: plan.Price, - Cycle: plan.Cycle, - StorageLimit: plan.StorageLimit, - UploadLimit: plan.UploadLimit, - DurationLimit: plan.DurationLimit, - QualityLimit: plan.QualityLimit, - IsActive: adminBoolValue(plan.IsActive, true), - UserCount: userCount, - PaymentCount: paymentCount, - SubscriptionCount: subscriptionCount, - } -} - -func (h *Handler) loadPlanUsageCounts(ctx *gin.Context, planID string) (int64, int64, int64, error) { - var userCount int64 - if err := h.db.WithContext(ctx.Request.Context()).Model(&model.User{}).Where("plan_id = ?", planID).Count(&userCount).Error; err != nil { - return 0, 0, 0, err - } - - var paymentCount int64 - if err := h.db.WithContext(ctx.Request.Context()).Model(&model.Payment{}).Where("plan_id = ?", planID).Count(&paymentCount).Error; err != nil { - return 0, 0, 0, err - } - - var subscriptionCount int64 - if err := h.db.WithContext(ctx.Request.Context()).Model(&model.PlanSubscription{}).Where("plan_id = ?", planID).Count(&subscriptionCount).Error; err != nil { - return 0, 0, 0, err - } - - return userCount, paymentCount, subscriptionCount, nil -} - -func validatePlanRequest(req *SavePlanRequest) string { - if strings.TrimSpace(req.Name) == "" { - return "Name is required" - } - if strings.TrimSpace(req.Cycle) == "" { - return "Cycle is required" - } - if req.Price < 0 { - return "Price must be greater than or equal to 0" - } - if req.StorageLimit <= 0 { - return "Storage limit must be greater than 0" - } - if req.UploadLimit <= 0 { - return "Upload limit must be greater than 0" - } - return "" -} - -// @Summary List Plans -// @Description Get all plans with usage counts (admin only) -// @Tags admin -// @Produce json -// @Success 200 {object} response.Response -// @Router /admin/plans [get] -// @Security BearerAuth -func (h *Handler) ListPlans(c *gin.Context) { - ctx := c.Request.Context() - var plans []model.Plan - if err := h.db.WithContext(ctx).Order("price ASC").Find(&plans).Error; err != nil { - h.logger.Error("Failed to list plans", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to list plans") - return - } - - result := make([]AdminPlanPayload, 0, len(plans)) - for _, plan := range plans { - userCount, paymentCount, subscriptionCount, err := h.loadPlanUsageCounts(c, plan.ID) - if err != nil { - h.logger.Error("Failed to load plan usage", "error", err, "plan_id", plan.ID) - response.Error(c, http.StatusInternalServerError, "Failed to list plans") - return - } - result = append(result, buildAdminPlanPayload(plan, userCount, paymentCount, subscriptionCount)) - } - - response.Success(c, gin.H{"plans": result}) -} - -// @Summary Create Plan -// @Description Create a plan (admin only) -// @Tags admin -// @Accept json -// @Produce json -// @Param request body SavePlanRequest true "Plan payload" -// @Success 201 {object} response.Response -// @Router /admin/plans [post] -// @Security BearerAuth -func (h *Handler) CreatePlan(c *gin.Context) { - var req SavePlanRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - if msg := validatePlanRequest(&req); msg != "" { - response.Error(c, http.StatusBadRequest, msg) - return - } - - plan := &model.Plan{ - ID: uuid.New().String(), - Name: strings.TrimSpace(req.Name), - Description: adminStringPtr(req.Description), - Features: adminStringSlice(req.Features), - Price: req.Price, - Cycle: strings.TrimSpace(req.Cycle), - StorageLimit: req.StorageLimit, - UploadLimit: req.UploadLimit, - DurationLimit: 0, - QualityLimit: "", - IsActive: func() *bool { - value := true - if req.IsActive != nil { - value = *req.IsActive - } - return &value - }(), - } - - if err := h.db.WithContext(c.Request.Context()).Create(plan).Error; err != nil { - h.logger.Error("Failed to create plan", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to create plan") - return - } - - response.Created(c, gin.H{"plan": buildAdminPlanPayload(*plan, 0, 0, 0)}) -} - -// @Summary Update Plan -// @Description Update a plan (admin only) -// @Tags admin -// @Accept json -// @Produce json -// @Param id path string true "Plan ID" -// @Param request body SavePlanRequest true "Plan payload" -// @Success 200 {object} response.Response -// @Router /admin/plans/{id} [put] -// @Security BearerAuth -func (h *Handler) UpdatePlan(c *gin.Context) { - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Plan not found") - return - } - - var req SavePlanRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - if msg := validatePlanRequest(&req); msg != "" { - response.Error(c, http.StatusBadRequest, msg) - return - } - - ctx := c.Request.Context() - var plan model.Plan - if err := h.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusNotFound, "Plan not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to update plan") - return - } - - plan.Name = strings.TrimSpace(req.Name) - plan.Description = adminStringPtr(req.Description) - plan.Features = adminStringSlice(req.Features) - plan.Price = req.Price - plan.Cycle = strings.TrimSpace(req.Cycle) - plan.StorageLimit = req.StorageLimit - plan.UploadLimit = req.UploadLimit - if req.IsActive != nil { - plan.IsActive = req.IsActive - } - - if err := h.db.WithContext(ctx).Save(&plan).Error; err != nil { - h.logger.Error("Failed to update plan", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to update plan") - return - } - - userCount, paymentCount, subscriptionCount, err := h.loadPlanUsageCounts(c, plan.ID) - if err != nil { - h.logger.Error("Failed to load plan usage", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to update plan") - return - } - - response.Success(c, gin.H{"plan": buildAdminPlanPayload(plan, userCount, paymentCount, subscriptionCount)}) -} - -// @Summary Delete Plan -// @Description Delete a plan, or deactivate it if already used (admin only) -// @Tags admin -// @Produce json -// @Param id path string true "Plan ID" -// @Success 200 {object} response.Response -// @Router /admin/plans/{id} [delete] -// @Security BearerAuth -func (h *Handler) DeletePlan(c *gin.Context) { - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Plan not found") - return - } - - ctx := c.Request.Context() - var plan model.Plan - if err := h.db.WithContext(ctx).Where("id = ?", id).First(&plan).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusNotFound, "Plan not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to delete plan") - return - } - - var paymentCount int64 - if err := h.db.WithContext(ctx).Model(&model.Payment{}).Where("plan_id = ?", id).Count(&paymentCount).Error; err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to delete plan") - return - } - var subscriptionCount int64 - if err := h.db.WithContext(ctx).Model(&model.PlanSubscription{}).Where("plan_id = ?", id).Count(&subscriptionCount).Error; err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to delete plan") - return - } - - if paymentCount > 0 || subscriptionCount > 0 { - inactive := false - if err := h.db.WithContext(ctx).Model(&model.Plan{}).Where("id = ?", id).Update("is_active", inactive).Error; err != nil { - h.logger.Error("Failed to deactivate plan", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to deactivate plan") - return - } - response.Success(c, gin.H{"message": "Plan deactivated", "mode": "deactivated"}) - return - } - - if err := h.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Plan{}).Error; err != nil { - h.logger.Error("Failed to delete plan", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to delete plan") - return - } - - response.Success(c, gin.H{"message": "Plan deleted", "mode": "deleted"}) -} diff --git a/internal/api/admin/render.go b/internal/api/admin/render.go deleted file mode 100644 index f59cc96..0000000 --- a/internal/api/admin/render.go +++ /dev/null @@ -1,218 +0,0 @@ -//go:build ignore -// +build ignore - -package admin - -import ( - "encoding/json" - "fmt" - "net/http" - - "github.com/gin-gonic/gin" - "stream.api/pkg/response" -) - -type createJobRequest struct { - Command string `json:"command"` - Image string `json:"image"` - Env map[string]string `json:"env"` - Priority int `json:"priority"` - UserID string `json:"user_id"` - Name string `json:"name"` - TimeLimit int64 `json:"time_limit"` -} - -// @Summary List render jobs -// @Description Returns paginated render jobs for admin management -// @Tags admin-render -// @Security BearerAuth -// @Produce json -// @Param offset query int false "Offset" -// @Param limit query int false "Limit" -// @Param agent_id query string false "Agent ID" -// @Success 200 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /admin/jobs [get] -func (h *Handler) ListJobs(c *gin.Context) { - offset := parseInt(c.Query("offset"), 0) - limit := parseInt(c.Query("limit"), 20) - if agentID := c.Query("agent_id"); agentID != "" { - items, err := h.runtime.JobService().ListJobsByAgent(c.Request.Context(), agentID, offset, limit) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to list jobs") - return - } - response.Success(c, items) - return - } - items, err := h.runtime.JobService().ListJobs(c.Request.Context(), offset, limit) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to list jobs") - return - } - response.Success(c, items) -} - -// @Summary Get render job detail -// @Description Returns a render job by ID -// @Tags admin-render -// @Security BearerAuth -// @Produce json -// @Param id path string true "Job ID" -// @Success 200 {object} response.Response -// @Failure 404 {object} response.Response -// @Router /admin/jobs/{id} [get] -func (h *Handler) GetJob(c *gin.Context) { - job, err := h.runtime.JobService().GetJob(c.Request.Context(), c.Param("id")) - if err != nil { - response.Error(c, http.StatusNotFound, "Job not found") - return - } - response.Success(c, gin.H{"job": job}) -} - -// @Summary Get render job logs -// @Description Returns plain text logs for a render job -// @Tags admin-render -// @Security BearerAuth -// @Produce plain -// @Param id path string true "Job ID" -// @Success 200 {string} string -// @Failure 404 {object} response.Response -// @Router /admin/jobs/{id}/logs [get] -func (h *Handler) GetJobLogs(c *gin.Context) { - job, err := h.runtime.JobService().GetJob(c.Request.Context(), c.Param("id")) - if err != nil { - response.Error(c, http.StatusNotFound, "Job not found") - return - } - c.String(http.StatusOK, job.Logs) -} - -// @Summary Create render job -// @Description Queues a new render job for agents -// @Tags admin-render -// @Security BearerAuth -// @Accept json -// @Produce json -// @Param payload body createJobRequest true "Job payload" -// @Success 201 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /admin/jobs [post] -func (h *Handler) CreateJob(c *gin.Context) { - var req createJobRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - if req.Command == "" { - response.Error(c, http.StatusBadRequest, "Command is required") - return - } - if req.Image == "" { - req.Image = "alpine" - } - if req.Name == "" { - req.Name = req.Command - } - payload, _ := json.Marshal(map[string]interface{}{"image": req.Image, "commands": []string{req.Command}, "environment": req.Env}) - job, err := h.runtime.JobService().CreateJob(c.Request.Context(), req.UserID, req.Name, payload, req.Priority, req.TimeLimit) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to create job") - return - } - response.Created(c, gin.H{"job": job}) -} - -// @Summary Cancel render job -// @Description Cancels a pending or running render job -// @Tags admin-render -// @Security BearerAuth -// @Produce json -// @Param id path string true "Job ID" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Router /admin/jobs/{id}/cancel [post] -func (h *Handler) CancelJob(c *gin.Context) { - if err := h.runtime.JobService().CancelJob(c.Request.Context(), c.Param("id")); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - response.Success(c, gin.H{"status": "cancelled", "job_id": c.Param("id")}) -} - -// @Summary Retry render job -// @Description Retries a failed or cancelled render job -// @Tags admin-render -// @Security BearerAuth -// @Produce json -// @Param id path string true "Job ID" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Router /admin/jobs/{id}/retry [post] -func (h *Handler) RetryJob(c *gin.Context) { - job, err := h.runtime.JobService().RetryJob(c.Request.Context(), c.Param("id")) - if err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - response.Success(c, gin.H{"job": job}) -} - -// @Summary List connected render agents -// @Description Returns currently connected render agents and current runtime stats -// @Tags admin-render -// @Security BearerAuth -// @Produce json -// @Success 200 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /admin/agents [get] -func (h *Handler) ListAgents(c *gin.Context) { - response.Success(c, gin.H{"agents": h.runtime.AgentRuntime().ListAgentsWithStats()}) -} - -// @Summary Restart connected render agent -// @Description Sends a restart command to a currently connected render agent -// @Tags admin-render -// @Security BearerAuth -// @Produce json -// @Param id path string true "Agent ID" -// @Success 200 {object} response.Response -// @Failure 503 {object} response.Response -// @Router /admin/agents/{id}/restart [post] -func (h *Handler) RestartAgent(c *gin.Context) { - if ok := h.runtime.AgentRuntime().SendCommand(c.Param("id"), "restart"); !ok { - response.Error(c, http.StatusServiceUnavailable, "Agent not active or command channel full") - return - } - response.Success(c, gin.H{"status": "restart command sent"}) -} - -// @Summary Update connected render agent -// @Description Sends an update command to a currently connected render agent -// @Tags admin-render -// @Security BearerAuth -// @Produce json -// @Param id path string true "Agent ID" -// @Success 200 {object} response.Response -// @Failure 503 {object} response.Response -// @Router /admin/agents/{id}/update [post] -func (h *Handler) UpdateAgent(c *gin.Context) { - if ok := h.runtime.AgentRuntime().SendCommand(c.Param("id"), "update"); !ok { - response.Error(c, http.StatusServiceUnavailable, "Agent not active or command channel full") - return - } - response.Success(c, gin.H{"status": "update command sent"}) -} - -func parseInt(value string, fallback int) int { - if value == "" { - return fallback - } - var result int - if _, err := fmt.Sscanf(value, "%d", &result); err != nil { - return fallback - } - return result -} diff --git a/internal/api/admin/users.go b/internal/api/admin/users.go deleted file mode 100644 index 67d12da..0000000 --- a/internal/api/admin/users.go +++ /dev/null @@ -1,522 +0,0 @@ -//go:build ignore -// +build ignore - -package admin - -import ( - "errors" - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "golang.org/x/crypto/bcrypt" - "gorm.io/gorm" - "stream.api/internal/database/model" - "stream.api/pkg/response" -) - -type AdminUserPayload struct { - ID string `json:"id"` - Email string `json:"email"` - Username *string `json:"username"` - Avatar *string `json:"avatar"` - Role *string `json:"role"` - PlanID *string `json:"plan_id"` - PlanName string `json:"plan_name,omitempty"` - StorageUsed int64 `json:"storage_used"` - VideoCount int64 `json:"video_count"` - WalletBalance float64 `json:"wallet_balance"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type CreateAdminUserRequest struct { - Email string `json:"email" binding:"required,email"` - Username string `json:"username"` - Password string `json:"password" binding:"required,min=6"` - Role string `json:"role"` - PlanID *string `json:"plan_id"` -} - -type UpdateAdminUserRequest struct { - Email *string `json:"email"` - Username *string `json:"username"` - Password *string `json:"password"` - Role *string `json:"role"` - PlanID *string `json:"plan_id"` -} - -type UpdateUserRoleRequest struct { - Role string `json:"role" binding:"required"` -} - -func normalizeAdminRole(value string) string { - role := strings.ToUpper(strings.TrimSpace(value)) - if role == "" { - return "USER" - } - return role -} - -func isValidAdminRole(role string) bool { - switch normalizeAdminRole(role) { - case "USER", "ADMIN", "BLOCK": - return true - default: - return false - } -} - -func (h *Handler) ensurePlanExists(ctx *gin.Context, planID *string) error { - if planID == nil { - return nil - } - trimmed := strings.TrimSpace(*planID) - if trimmed == "" { - return nil - } - var count int64 - if err := h.db.WithContext(ctx.Request.Context()).Model(&model.Plan{}).Where("id = ?", trimmed).Count(&count).Error; err != nil { - return err - } - if count == 0 { - return gorm.ErrRecordNotFound - } - return nil -} - -// @Summary List Users -// @Description Get paginated list of all users (admin only) -// @Tags admin -// @Produce json -// @Param page query int false "Page" default(1) -// @Param limit query int false "Limit" default(20) -// @Param search query string false "Search by email or username" -// @Param role query string false "Filter by role" -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 403 {object} response.Response -// @Router /admin/users [get] -// @Security BearerAuth -func (h *Handler) ListUsers(c *gin.Context) { - ctx := c.Request.Context() - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - if page < 1 { - page = 1 - } - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - if limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - offset := (page - 1) * limit - - search := strings.TrimSpace(c.Query("search")) - role := strings.TrimSpace(c.Query("role")) - - db := h.db.WithContext(ctx).Model(&model.User{}) - if search != "" { - 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 { - h.logger.Error("Failed to count users", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to list users") - return - } - - var users []model.User - if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&users).Error; err != nil { - h.logger.Error("Failed to list users", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to list users") - return - } - - planIDs := map[string]bool{} - for _, u := range users { - if u.PlanID != nil && strings.TrimSpace(*u.PlanID) != "" { - planIDs[*u.PlanID] = true - } - } - planNames := map[string]string{} - if len(planIDs) > 0 { - ids := make([]string, 0, len(planIDs)) - for id := range planIDs { - ids = append(ids, id) - } - var plans []model.Plan - h.db.WithContext(ctx).Where("id IN ?", ids).Find(&plans) - for _, p := range plans { - planNames[p.ID] = p.Name - } - } - - result := make([]AdminUserPayload, 0, len(users)) - for _, u := range users { - payload := AdminUserPayload{ - ID: u.ID, - Email: u.Email, - Username: u.Username, - Avatar: u.Avatar, - Role: u.Role, - PlanID: u.PlanID, - StorageUsed: u.StorageUsed, - CreatedAt: adminFormatTime(u.CreatedAt), - UpdatedAt: adminFormatTimeValue(u.UpdatedAt), - } - if u.PlanID != nil { - payload.PlanName = planNames[*u.PlanID] - } - h.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", u.ID).Count(&payload.VideoCount) - payload.WalletBalance, _ = model.GetWalletBalance(ctx, h.db, u.ID) - result = append(result, payload) - } - - response.Success(c, gin.H{ - "users": result, - "total": total, - "page": page, - "limit": limit, - }) -} - -// @Summary Create User -// @Description Create a user from admin panel (admin only) -// @Tags admin -// @Accept json -// @Produce json -// @Param request body CreateAdminUserRequest true "User payload" -// @Success 201 {object} response.Response -// @Router /admin/users [post] -// @Security BearerAuth -func (h *Handler) CreateUser(c *gin.Context) { - var req CreateAdminUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - role := normalizeAdminRole(req.Role) - if !isValidAdminRole(role) { - response.Error(c, http.StatusBadRequest, "Invalid role. Must be USER, ADMIN, or BLOCK") - return - } - if err := h.ensurePlanExists(c, req.PlanID); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusBadRequest, "Plan not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to create user") - return - } - - email := strings.TrimSpace(req.Email) - username := strings.TrimSpace(req.Username) - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to hash password") - return - } - password := string(hashedPassword) - - user := &model.User{ - ID: uuid.New().String(), - Email: email, - Password: &password, - Username: adminStringPtr(username), - Role: &role, - PlanID: nil, - } - if req.PlanID != nil && strings.TrimSpace(*req.PlanID) != "" { - planID := strings.TrimSpace(*req.PlanID) - user.PlanID = &planID - } - - if err := h.db.WithContext(c.Request.Context()).Create(user).Error; err != nil { - if errors.Is(err, gorm.ErrDuplicatedKey) { - response.Error(c, http.StatusBadRequest, "Email already registered") - return - } - h.logger.Error("Failed to create user", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to create user") - return - } - - response.Created(c, gin.H{"user": user}) -} - -// @Summary Get User Detail -// @Description Get detailed info about a single user (admin only) -// @Tags admin -// @Produce json -// @Param id path string true "User ID" -// @Success 200 {object} response.Response -// @Failure 404 {object} response.Response -// @Router /admin/users/{id} [get] -// @Security BearerAuth -func (h *Handler) GetUser(c *gin.Context) { - ctx := c.Request.Context() - id := c.Param("id") - - var user model.User - if err := h.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil { - if err == gorm.ErrRecordNotFound { - response.Error(c, http.StatusNotFound, "User not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to get user") - return - } - - var videoCount int64 - h.db.WithContext(ctx).Model(&model.Video{}).Where("user_id = ?", id).Count(&videoCount) - balance, _ := model.GetWalletBalance(ctx, h.db, id) - - planName := "" - if user.PlanID != nil { - var plan model.Plan - if err := h.db.WithContext(ctx).Where("id = ?", *user.PlanID).First(&plan).Error; err == nil { - planName = plan.Name - } - } - - var subscription *model.PlanSubscription - var sub model.PlanSubscription - if err := h.db.WithContext(ctx).Where("user_id = ?", id).Order("created_at DESC").First(&sub).Error; err == nil { - subscription = &sub - } - - response.Success(c, gin.H{ - "user": gin.H{ - "id": user.ID, - "email": user.Email, - "username": user.Username, - "avatar": user.Avatar, - "role": user.Role, - "plan_id": user.PlanID, - "plan_name": planName, - "storage_used": user.StorageUsed, - "created_at": user.CreatedAt, - "updated_at": user.UpdatedAt, - }, - "video_count": videoCount, - "wallet_balance": balance, - "subscription": subscription, - }) -} - -// @Summary Update User -// @Description Update a user from admin panel (admin only) -// @Tags admin -// @Accept json -// @Produce json -// @Param id path string true "User ID" -// @Param request body UpdateAdminUserRequest true "User payload" -// @Success 200 {object} response.Response -// @Router /admin/users/{id} [put] -// @Security BearerAuth -func (h *Handler) UpdateUser(c *gin.Context) { - id := c.Param("id") - currentUserID := c.GetString("userID") - - var req UpdateAdminUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - updates := map[string]interface{}{} - if req.Email != nil { - email := strings.TrimSpace(*req.Email) - if email == "" { - response.Error(c, http.StatusBadRequest, "Email is required") - return - } - updates["email"] = email - } - if req.Username != nil { - updates["username"] = strings.TrimSpace(*req.Username) - } - if req.Role != nil { - role := normalizeAdminRole(*req.Role) - if !isValidAdminRole(role) { - response.Error(c, http.StatusBadRequest, "Invalid role. Must be USER, ADMIN, or BLOCK") - return - } - if id == currentUserID && role != "ADMIN" { - response.Error(c, http.StatusBadRequest, "Cannot change your own role") - return - } - updates["role"] = role - } - if req.PlanID != nil { - if err := h.ensurePlanExists(c, req.PlanID); err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusBadRequest, "Plan not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to update user") - return - } - trimmed := strings.TrimSpace(*req.PlanID) - if trimmed == "" { - updates["plan_id"] = nil - } else { - updates["plan_id"] = trimmed - } - } - if req.Password != nil { - if strings.TrimSpace(*req.Password) == "" { - response.Error(c, http.StatusBadRequest, "Password must not be empty") - return - } - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to hash password") - return - } - updates["password"] = string(hashedPassword) - } - - if len(updates) == 0 { - response.Success(c, gin.H{"message": "No changes provided"}) - return - } - - result := h.db.WithContext(c.Request.Context()).Model(&model.User{}).Where("id = ?", id).Updates(updates) - if result.Error != nil { - if errors.Is(result.Error, gorm.ErrDuplicatedKey) { - response.Error(c, http.StatusBadRequest, "Email already registered") - return - } - h.logger.Error("Failed to update user", "error", result.Error) - response.Error(c, http.StatusInternalServerError, "Failed to update user") - return - } - if result.RowsAffected == 0 { - response.Error(c, http.StatusNotFound, "User not found") - return - } - - var user model.User - if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&user).Error; err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to reload user") - return - } - - response.Success(c, gin.H{"user": user}) -} - -// @Summary Update User Role -// @Description Change user role (admin only). Valid: USER, ADMIN, BLOCK -// @Tags admin -// @Accept json -// @Produce json -// @Param id path string true "User ID" -// @Param request body UpdateUserRoleRequest true "Role payload" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 404 {object} response.Response -// @Router /admin/users/{id}/role [put] -// @Security BearerAuth -func (h *Handler) UpdateUserRole(c *gin.Context) { - id := c.Param("id") - currentUserID := c.GetString("userID") - if id == currentUserID { - response.Error(c, http.StatusBadRequest, "Cannot change your own role") - return - } - - var req UpdateUserRoleRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - role := normalizeAdminRole(req.Role) - if !isValidAdminRole(role) { - response.Error(c, http.StatusBadRequest, "Invalid role. Must be USER, ADMIN, or BLOCK") - return - } - - result := h.db.WithContext(c.Request.Context()).Model(&model.User{}).Where("id = ?", id).Update("role", role) - if result.Error != nil { - h.logger.Error("Failed to update user role", "error", result.Error) - response.Error(c, http.StatusInternalServerError, "Failed to update role") - return - } - if result.RowsAffected == 0 { - response.Error(c, http.StatusNotFound, "User not found") - return - } - - response.Success(c, gin.H{"message": "Role updated", "role": role}) -} - -// @Summary Delete User -// @Description Delete a user and their data (admin only) -// @Tags admin -// @Produce json -// @Param id path string true "User ID" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 404 {object} response.Response -// @Router /admin/users/{id} [delete] -// @Security BearerAuth -func (h *Handler) DeleteUser(c *gin.Context) { - id := c.Param("id") - currentUserID := c.GetString("userID") - if id == currentUserID { - response.Error(c, http.StatusBadRequest, "Cannot delete your own account") - return - } - - var user model.User - if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&user).Error; err != nil { - if err == gorm.ErrRecordNotFound { - response.Error(c, http.StatusNotFound, "User not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to find user") - return - } - - err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { - tables := []struct { - model interface{} - where string - }{ - {&model.VideoAdConfig{}, "user_id = ?"}, - {&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 _, t := range tables { - if err := tx.Where(t.where, id).Delete(t.model).Error; err != nil { - return err - } - } - return tx.Where("id = ?", id).Delete(&model.User{}).Error - }) - if err != nil { - h.logger.Error("Failed to delete user", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to delete user") - return - } - - response.Success(c, gin.H{"message": "User deleted"}) -} diff --git a/internal/api/admin/videos.go b/internal/api/admin/videos.go deleted file mode 100644 index 868b823..0000000 --- a/internal/api/admin/videos.go +++ /dev/null @@ -1,477 +0,0 @@ -//go:build ignore -// +build ignore - -package admin - -import ( - "errors" - "net/http" - "strconv" - "strings" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" - - "stream.api/internal/database/model" - "stream.api/pkg/response" -) - -type AdminVideoPayload struct { - ID string `json:"id"` - UserID string `json:"user_id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - URL string `json:"url"` - Status string `json:"status"` - Size int64 `json:"size"` - Duration int32 `json:"duration"` - Format string `json:"format"` - OwnerEmail string `json:"owner_email,omitempty"` - AdTemplateID *string `json:"ad_template_id,omitempty"` - AdTemplateName string `json:"ad_template_name,omitempty"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` -} - -type SaveAdminVideoRequest struct { - UserID string `json:"user_id" binding:"required"` - Title string `json:"title" binding:"required"` - Description string `json:"description"` - URL string `json:"url" binding:"required"` - Size int64 `json:"size" binding:"required"` - Duration int32 `json:"duration"` - Format string `json:"format"` - Status string `json:"status"` - AdTemplateID *string `json:"ad_template_id,omitempty"` -} - -func normalizeAdminVideoStatus(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "processing", "pending": - return "processing" - case "failed", "error": - return "failed" - default: - return "ready" - } -} - -func (h *Handler) loadAdminVideoPayload(ctx *gin.Context, video model.Video) (AdminVideoPayload, error) { - payload := AdminVideoPayload{ - ID: video.ID, - UserID: video.UserID, - Title: video.Title, - Description: adminStringValue(video.Description), - URL: video.URL, - Status: adminStringValue(video.Status), - Size: video.Size, - Duration: video.Duration, - Format: video.Format, - CreatedAt: adminFormatTime(video.CreatedAt), - UpdatedAt: adminFormatTimeValue(video.UpdatedAt), - } - - var user model.User - if err := h.db.WithContext(ctx.Request.Context()).Select("id, email").Where("id = ?", video.UserID).First(&user).Error; err == nil { - payload.OwnerEmail = user.Email - } - - var adConfig model.VideoAdConfig - if err := h.db.WithContext(ctx.Request.Context()).Where("video_id = ?", video.ID).First(&adConfig).Error; err == nil { - payload.AdTemplateID = &adConfig.AdTemplateID - var template model.AdTemplate - if err := h.db.WithContext(ctx.Request.Context()).Where("id = ?", adConfig.AdTemplateID).First(&template).Error; err == nil { - payload.AdTemplateName = template.Name - } - } - - return payload, nil -} - -func (h *Handler) saveAdminVideoAdConfig(tx *gorm.DB, videoID, userID string, adTemplateID *string) error { - if adTemplateID == nil { - return nil - } - trimmed := strings.TrimSpace(*adTemplateID) - if trimmed == "" { - return tx.Where("video_id = ? AND user_id = ?", videoID, userID).Delete(&model.VideoAdConfig{}).Error - } - - var template model.AdTemplate - if err := tx.Where("id = ? AND user_id = ?", trimmed, userID).First(&template).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return &gin.Error{Err: errors.New("Ad template not found"), Type: gin.ErrorTypeBind} - } - return err - } - - var existing model.VideoAdConfig - if err := tx.Where("video_id = ? AND user_id = ?", videoID, userID).First(&existing).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return tx.Create(&model.VideoAdConfig{ - VideoID: videoID, - UserID: userID, - AdTemplateID: template.ID, - VastTagURL: template.VastTagURL, - AdFormat: template.AdFormat, - Duration: template.Duration, - }).Error - } - return err - } - - existing.AdTemplateID = template.ID - existing.VastTagURL = template.VastTagURL - existing.AdFormat = template.AdFormat - existing.Duration = template.Duration - return tx.Save(&existing).Error -} - -// @Summary List All Videos -// @Description Get paginated list of all videos across users (admin only) -// @Tags admin -// @Produce json -// @Param page query int false "Page" default(1) -// @Param limit query int false "Limit" default(20) -// @Param search query string false "Search by title" -// @Param user_id query string false "Filter by user ID" -// @Param status query string false "Filter by status" -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 403 {object} response.Response -// @Router /admin/videos [get] -// @Security BearerAuth -func (h *Handler) ListVideos(c *gin.Context) { - ctx := c.Request.Context() - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - if page < 1 { - page = 1 - } - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) - if limit <= 0 { - limit = 20 - } - if limit > 100 { - limit = 100 - } - offset := (page - 1) * limit - - search := strings.TrimSpace(c.Query("search")) - userID := strings.TrimSpace(c.Query("user_id")) - status := strings.TrimSpace(c.Query("status")) - - db := h.db.WithContext(ctx).Model(&model.Video{}) - if search != "" { - like := "%" + search + "%" - db = db.Where("title ILIKE ?", like) - } - if userID != "" { - db = db.Where("user_id = ?", userID) - } - if status != "" && !strings.EqualFold(status, "all") { - db = db.Where("status = ?", normalizeAdminVideoStatus(status)) - } - - var total int64 - if err := db.Count(&total).Error; err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to list videos") - return - } - - var videos []model.Video - if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&videos).Error; err != nil { - h.logger.Error("Failed to list videos", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to list videos") - return - } - - result := make([]AdminVideoPayload, 0, len(videos)) - for _, v := range videos { - payload, err := h.loadAdminVideoPayload(c, v) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to list videos") - return - } - result = append(result, payload) - } - - response.Success(c, gin.H{ - "videos": result, - "total": total, - "page": page, - "limit": limit, - }) -} - -// @Summary Get Video Detail -// @Description Get video detail by ID (admin only) -// @Tags admin -// @Produce json -// @Param id path string true "Video ID" -// @Success 200 {object} response.Response -// @Router /admin/videos/{id} [get] -// @Security BearerAuth -func (h *Handler) GetVideo(c *gin.Context) { - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Video not found") - return - } - - var video model.Video - if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&video).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusNotFound, "Video not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to get video") - return - } - - payload, err := h.loadAdminVideoPayload(c, video) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to get video") - return - } - - response.Success(c, gin.H{"video": payload}) -} - -// @Summary Create Video -// @Description Create a model video record for a user (admin only) -// @Tags admin -// @Accept json -// @Produce json -// @Param request body SaveAdminVideoRequest true "Video payload" -// @Success 201 {object} response.Response -// @Router /admin/videos [post] -// @Security BearerAuth -func (h *Handler) CreateVideo(c *gin.Context) { - var req SaveAdminVideoRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - if strings.TrimSpace(req.UserID) == "" || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.URL) == "" { - response.Error(c, http.StatusBadRequest, "User ID, title, and URL are required") - return - } - if req.Size < 0 { - response.Error(c, http.StatusBadRequest, "Size must be greater than or equal to 0") - return - } - - ctx := c.Request.Context() - var user model.User - if err := h.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.UserID)).First(&user).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusBadRequest, "User not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to create video") - return - } - - status := normalizeAdminVideoStatus(req.Status) - processingStatus := strings.ToUpper(status) - storageType := "WORKER" - video := &model.Video{ - ID: uuid.New().String(), - UserID: user.ID, - Name: strings.TrimSpace(req.Title), - Title: strings.TrimSpace(req.Title), - Description: adminStringPtr(req.Description), - URL: strings.TrimSpace(req.URL), - Size: req.Size, - Duration: req.Duration, - Format: strings.TrimSpace(req.Format), - Status: &status, - ProcessingStatus: &processingStatus, - StorageType: &storageType, - } - - if err := h.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 - } - if err := h.saveAdminVideoAdConfig(tx, video.ID, user.ID, req.AdTemplateID); err != nil { - return err - } - return nil - }); err != nil { - if strings.Contains(err.Error(), "Ad template not found") { - response.Error(c, http.StatusBadRequest, "Ad template not found") - return - } - h.logger.Error("Failed to create video", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to create video") - return - } - - payload, err := h.loadAdminVideoPayload(c, *video) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to create video") - return - } - - response.Created(c, gin.H{"video": payload}) -} - -// @Summary Update Video -// @Description Update video metadata and status (admin only) -// @Tags admin -// @Accept json -// @Produce json -// @Param id path string true "Video ID" -// @Param request body SaveAdminVideoRequest true "Video payload" -// @Success 200 {object} response.Response -// @Router /admin/videos/{id} [put] -// @Security BearerAuth -func (h *Handler) UpdateVideo(c *gin.Context) { - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Video not found") - return - } - - var req SaveAdminVideoRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - if strings.TrimSpace(req.UserID) == "" || strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.URL) == "" { - response.Error(c, http.StatusBadRequest, "User ID, title, and URL are required") - return - } - if req.Size < 0 { - response.Error(c, http.StatusBadRequest, "Size must be greater than or equal to 0") - return - } - - ctx := c.Request.Context() - var video model.Video - if err := h.db.WithContext(ctx).Where("id = ?", id).First(&video).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusNotFound, "Video not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to update video") - return - } - - var user model.User - if err := h.db.WithContext(ctx).Where("id = ?", strings.TrimSpace(req.UserID)).First(&user).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusBadRequest, "User not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to update video") - return - } - - oldSize := video.Size - oldUserID := video.UserID - status := normalizeAdminVideoStatus(req.Status) - processingStatus := strings.ToUpper(status) - video.UserID = user.ID - video.Name = strings.TrimSpace(req.Title) - video.Title = strings.TrimSpace(req.Title) - video.Description = adminStringPtr(req.Description) - video.URL = strings.TrimSpace(req.URL) - video.Size = req.Size - video.Duration = req.Duration - video.Format = strings.TrimSpace(req.Format) - video.Status = &status - video.ProcessingStatus = &processingStatus - - if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - 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 - } - } - if oldUserID != user.ID { - if err := tx.Model(&model.VideoAdConfig{}).Where("video_id = ?", video.ID).Update("user_id", user.ID).Error; err != nil { - return err - } - } - if err := h.saveAdminVideoAdConfig(tx, video.ID, user.ID, req.AdTemplateID); err != nil { - return err - } - return nil - }); err != nil { - if strings.Contains(err.Error(), "Ad template not found") { - response.Error(c, http.StatusBadRequest, "Ad template not found") - return - } - h.logger.Error("Failed to update video", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to update video") - return - } - - payload, err := h.loadAdminVideoPayload(c, video) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to update video") - return - } - - response.Success(c, gin.H{"video": payload}) -} - -// @Summary Delete Video (Admin) -// @Description Delete any video by ID (admin only) -// @Tags admin -// @Produce json -// @Param id path string true "Video ID" -// @Success 200 {object} response.Response -// @Failure 404 {object} response.Response -// @Router /admin/videos/{id} [delete] -// @Security BearerAuth -func (h *Handler) DeleteVideo(c *gin.Context) { - id := c.Param("id") - - var video model.Video - if err := h.db.WithContext(c.Request.Context()).Where("id = ?", id).First(&video).Error; err != nil { - if err == gorm.ErrRecordNotFound { - response.Error(c, http.StatusNotFound, "Video not found") - return - } - response.Error(c, http.StatusInternalServerError, "Failed to find video") - return - } - - err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("video_id = ?", video.ID).Delete(&model.VideoAdConfig{}).Error; err != nil { - return err - } - 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 { - h.logger.Error("Failed to delete video", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to delete video") - return - } - - response.Success(c, gin.H{"message": "Video deleted"}) -} diff --git a/internal/api/adtemplates/handler.go b/internal/api/adtemplates/handler.go deleted file mode 100644 index 11112bc..0000000 --- a/internal/api/adtemplates/handler.go +++ /dev/null @@ -1,338 +0,0 @@ -//go:build ignore -// +build ignore - -package adtemplates - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" - "stream.api/internal/database/model" - "stream.api/pkg/logger" - "stream.api/pkg/response" -) - -const upgradeRequiredMessage = "Upgrade required to manage Ads & VAST" - -type Handler struct { - logger logger.Logger - db *gorm.DB -} - -type SaveAdTemplateRequest struct { - Name string `json:"name" binding:"required"` - Description string `json:"description"` - VASTTagURL string `json:"vast_tag_url" binding:"required"` - AdFormat string `json:"ad_format"` - Duration *int `json:"duration"` - IsActive *bool `json:"is_active"` - IsDefault *bool `json:"is_default"` -} - -type TemplatePayload struct { - Template *model.AdTemplate `json:"template"` -} - -type TemplateListPayload struct { - Templates []model.AdTemplate `json:"templates"` -} - -func NewHandler(l logger.Logger, db *gorm.DB) *Handler { - return &Handler{logger: l, db: db} -} - -// @Summary List Ad Templates -// @Description Get all VAST ad templates for the current user -// @Tags ad-templates -// @Produce json -// @Success 200 {object} response.Response{data=TemplateListPayload} -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /ad-templates [get] -// @Security BearerAuth -func (h *Handler) ListTemplates(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - var items []model.AdTemplate - if err := h.db.WithContext(c.Request.Context()). - Where("user_id = ?", userID). - Order("is_default DESC"). - Order("created_at DESC"). - Find(&items).Error; err != nil { - h.logger.Error("Failed to list ad templates", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to load ad templates") - return - } - - response.Success(c, gin.H{"templates": items}) -} - -// @Summary Create Ad Template -// @Description Create a VAST ad template for the current user -// @Tags ad-templates -// @Accept json -// @Produce json -// @Param request body SaveAdTemplateRequest true "Ad template payload" -// @Success 201 {object} response.Response{data=TemplatePayload} -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 403 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /ad-templates [post] -// @Security BearerAuth -func (h *Handler) CreateTemplate(c *gin.Context) { - h.saveTemplate(c, true) -} - -// @Summary Update Ad Template -// @Description Update a VAST ad template for the current user -// @Tags ad-templates -// @Accept json -// @Produce json -// @Param id path string true "Ad Template ID" -// @Param request body SaveAdTemplateRequest true "Ad template payload" -// @Success 200 {object} response.Response{data=TemplatePayload} -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 403 {object} response.Response -// @Failure 404 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /ad-templates/{id} [put] -// @Security BearerAuth -func (h *Handler) UpdateTemplate(c *gin.Context) { - h.saveTemplate(c, false) -} - -// @Summary Delete Ad Template -// @Description Delete a VAST ad template for the current user -// @Tags ad-templates -// @Produce json -// @Param id path string true "Ad Template ID" -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 403 {object} response.Response -// @Failure 404 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /ad-templates/{id} [delete] -// @Security BearerAuth -func (h *Handler) DeleteTemplate(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - if !requirePaidPlan(c) { - return - } - - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Ad template not found") - return - } - - result := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("ad_template_id = ? AND user_id = ?", id, userID). - Delete(&model.VideoAdConfig{}).Error; err != nil { - return err - } - - res := tx.Where("id = ? AND user_id = ?", id, userID).Delete(&model.AdTemplate{}) - if res.Error != nil { - return res.Error - } - if res.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - return nil - }) - if result != nil { - if result == gorm.ErrRecordNotFound { - response.Error(c, http.StatusNotFound, "Ad template not found") - return - } - h.logger.Error("Failed to delete ad template", "error", result) - response.Error(c, http.StatusInternalServerError, "Failed to delete ad template") - return - } - - response.Success(c, gin.H{"message": "Ad template deleted"}) -} - -func (h *Handler) saveTemplate(c *gin.Context, create bool) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - if !requirePaidPlan(c) { - return - } - - var req SaveAdTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - name := strings.TrimSpace(req.Name) - vastURL := strings.TrimSpace(req.VASTTagURL) - if name == "" || vastURL == "" { - response.Error(c, http.StatusBadRequest, "Name and VAST URL are required") - return - } - - format := normalizeAdFormat(req.AdFormat) - if format == "mid-roll" && (req.Duration == nil || *req.Duration <= 0) { - response.Error(c, http.StatusBadRequest, "Duration is required for mid-roll templates") - return - } - - ctx := c.Request.Context() - if create { - item := &model.AdTemplate{ - ID: uuid.New().String(), - UserID: userID, - Name: name, - Description: stringPointer(strings.TrimSpace(req.Description)), - VastTagURL: vastURL, - AdFormat: model.StringPtr(format), - Duration: intPtrToInt64Ptr(req.Duration), - IsActive: model.BoolPtr(req.IsActive == nil || *req.IsActive), - IsDefault: req.IsDefault != nil && *req.IsDefault, - } - if !adTemplateIsActive(item.IsActive) { - item.IsDefault = false - } - - if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if item.IsDefault { - if err := unsetDefaultTemplates(tx, userID, ""); err != nil { - return err - } - } - - return tx.Create(item).Error - }); err != nil { - h.logger.Error("Failed to create ad template", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to save ad template") - return - } - - response.Created(c, gin.H{"template": item}) - return - } - - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Ad template not found") - return - } - - var item model.AdTemplate - if err := h.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).First(&item).Error; err != nil { - if err == gorm.ErrRecordNotFound { - response.Error(c, http.StatusNotFound, "Ad template not found") - return - } - h.logger.Error("Failed to load ad template", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to save ad template") - return - } - - item.Name = name - item.Description = stringPointer(strings.TrimSpace(req.Description)) - item.VastTagURL = vastURL - item.AdFormat = model.StringPtr(format) - item.Duration = intPtrToInt64Ptr(req.Duration) - if req.IsActive != nil { - item.IsActive = model.BoolPtr(*req.IsActive) - } - if req.IsDefault != nil { - item.IsDefault = *req.IsDefault - } - if !adTemplateIsActive(item.IsActive) { - item.IsDefault = false - } - - if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if item.IsDefault { - if err := unsetDefaultTemplates(tx, userID, item.ID); err != nil { - return err - } - } - - return tx.Save(&item).Error - }); err != nil { - h.logger.Error("Failed to update ad template", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to save ad template") - return - } - - response.Success(c, gin.H{"template": item}) -} - -func requirePaidPlan(c *gin.Context) bool { - userValue, exists := c.Get("user") - if !exists { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return false - } - - user, ok := userValue.(*model.User) - if !ok || user == nil { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return false - } - - if user.PlanID == nil || strings.TrimSpace(*user.PlanID) == "" { - response.Error(c, http.StatusForbidden, upgradeRequiredMessage) - return false - } - - return true -} - -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 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 stringPointer(value string) *string { - if value == "" { - return nil - } - return &value -} - -func intPtrToInt64Ptr(value *int) *int64 { - if value == nil { - return nil - } - converted := int64(*value) - return &converted -} - -func adTemplateIsActive(value *bool) bool { - return value == nil || *value -} diff --git a/internal/api/auth/auth.go b/internal/api/auth/auth.go deleted file mode 100644 index 7a3ebf2..0000000 --- a/internal/api/auth/auth.go +++ /dev/null @@ -1,745 +0,0 @@ -//go:build ignore -// +build ignore - -package auth - -import ( - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "net/http" - "net/url" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "golang.org/x/crypto/bcrypt" - "golang.org/x/oauth2" - "golang.org/x/oauth2/google" - "gorm.io/gorm" - "stream.api/internal/config" - "stream.api/internal/database/model" - "stream.api/internal/database/query" - "stream.api/pkg/cache" - "stream.api/pkg/logger" - "stream.api/pkg/response" - "stream.api/pkg/token" -) - -type handler struct { - cache cache.Cache - token token.Provider - logger logger.Logger - db *gorm.DB - googleOauth *oauth2.Config - googleStateTTL time.Duration - frontendBaseURL string - googleFinalizePath string -} - -// NewHandler creates a new instance of Handler -func NewHandler(c cache.Cache, t token.Provider, l logger.Logger, cfg *config.Config, db *gorm.DB) AuthHandler { - stateTTL := time.Duration(cfg.Google.StateTTLMinute) * time.Minute - if stateTTL <= 0 { - stateTTL = 10 * time.Minute - } - - return &handler{ - cache: c, - token: t, - logger: l, - db: db, - googleStateTTL: stateTTL, - frontendBaseURL: strings.TrimRight(cfg.Frontend.BaseURL, "/"), - googleFinalizePath: cfg.Frontend.GoogleAuthFinalizePath, - googleOauth: &oauth2.Config{ - ClientID: cfg.Google.ClientID, - ClientSecret: cfg.Google.ClientSecret, - RedirectURL: cfg.Google.RedirectURL, - Scopes: []string{ - "https://www.googleapis.com/auth/userinfo.email", - "https://www.googleapis.com/auth/userinfo.profile", - }, - Endpoint: google.Endpoint, - }, - } -} - -// @Summary Login -// @Description Login with email and password -// @Tags auth -// @Accept json -// @Produce json -// @Param request body LoginRequest true "Login payload" -// @Success 200 {object} response.Response{data=UserPayload} -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Router /auth/login [post] -func (h *handler) Login(c *gin.Context) { - var req LoginRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - u := query.User - user, err := u.WithContext(c.Request.Context()).Where(u.Email.Eq(req.Email)).First() - if err != nil { - response.Error(c, http.StatusUnauthorized, "Invalid credentials") - return - } - - // Verify password (if user has password, google users might not) - if user.Password == nil || *user.Password == "" { - response.Error(c, http.StatusUnauthorized, "Please login with Google") - return - } - - if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(req.Password)); err != nil { - response.Error(c, http.StatusUnauthorized, "Invalid credentials") - return - } - - if err := h.generateAndSetTokens(c, user.ID, user.Email, safeRole(user.Role)); err != nil { - return - } - h.respondWithUserPayload(c, user) -} - -// @Summary Logout -// @Description Logout user and clear cookies -// @Tags auth -// @Produce json -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Router /auth/logout [post] -// @Security BearerAuth -func (h *handler) Logout(c *gin.Context) { - refreshToken, err := c.Cookie("refresh_token") - if err == nil { - claims, err := h.token.ParseMapToken(refreshToken) - if err == nil { - if refreshUUID, ok := claims["refresh_uuid"].(string); ok { - h.cache.Del(c.Request.Context(), "refresh_uuid:"+refreshUUID) - } - } - } - - c.SetCookie("access_token", "", -1, "/", "", false, true) - c.SetCookie("refresh_token", "", -1, "/", "", false, true) - response.Success(c, "Logged out") -} - -// @Summary Register -// @Description Register a new user -// @Tags auth -// @Accept json -// @Produce json -// @Param request body RegisterRequest true "Registration payload" -// @Success 201 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /auth/register [post] -func (h *handler) Register(c *gin.Context) { - var req RegisterRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - u := query.User - // Check existing - count, _ := u.WithContext(c.Request.Context()).Where(u.Email.Eq(req.Email)).Count() - if count > 0 { - response.Error(c, http.StatusBadRequest, "Email already registered") - return - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) - if err != nil { - response.Fail(c, "Failed to hash password") - return - } - - password := string(hashedPassword) - role := "USER" - newUser := &model.User{ - ID: uuid.New().String(), - Email: req.Email, - Password: &password, - Username: &req.Username, - Role: &role, - } - - if err := u.WithContext(c.Request.Context()).Create(newUser); err != nil { - response.Fail(c, "Failed to create user") - return - } - - response.Created(c, "User registered") -} - -// @Summary Forgot Password -// @Description Request password reset link -// @Tags auth -// @Accept json -// @Produce json -// @Param request body ForgotPasswordRequest true "Forgot password payload" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /auth/forgot-password [post] -func (h *handler) ForgotPassword(c *gin.Context) { - var req ForgotPasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - u := query.User - user, err := u.WithContext(c.Request.Context()).Where(u.Email.Eq(req.Email)).First() - if err != nil { - // Do not reveal - response.Success(c, "If email exists, a reset link has been sent") - return - } - - tokenID := uuid.New().String() - err = h.cache.Set(c.Request.Context(), "reset_pw:"+tokenID, user.ID, 15*time.Minute) - if err != nil { - h.logger.Error("Failed to set reset token", "error", err) - response.Fail(c, "Try again later") - return - } - - // log.Printf replaced with logger - h.logger.Info("Sending Password Reset Email", "email", req.Email, "token", tokenID) - response.Success(c, gin.H{"message": "If email exists, a reset link has been sent", "debug_token": tokenID}) -} - -// @Summary Reset Password -// @Description Reset password using token -// @Tags auth -// @Accept json -// @Produce json -// @Param request body ResetPasswordRequest true "Reset password payload" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /auth/reset-password [post] -func (h *handler) ResetPassword(c *gin.Context) { - var req ResetPasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - userID, err := h.cache.Get(c.Request.Context(), "reset_pw:"+req.Token) - if err != nil { - // Cache interface should likely separate "Not Found" vs "Error" or return error compatible with checking - // If implementation returns redis.Nil equivalent. - // Our Cache interface Get returns (string, error). - // Redis implementation returns redis.Nil which is an error. - // We'll need to check if generic cache supports "not found" check. - // For now, simple error check. - response.Error(c, http.StatusBadRequest, "Invalid or expired token") - return - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) - if err != nil { - response.Fail(c, "Internal Error") - return - } - - u := query.User - _, err = u.WithContext(c.Request.Context()).Where(u.ID.Eq(userID)).Update(u.Password, string(hashedPassword)) - if err != nil { - response.Fail(c, "Failed to update password") - return - } - - h.cache.Del(c.Request.Context(), "reset_pw:"+req.Token) - response.Success(c, "Password reset successfully") -} - -// @Summary Google Login -// @Description Redirect to Google for Login -// @Tags auth -// @Router /auth/google/login [get] -func (h *handler) LoginGoogle(c *gin.Context) { - state, err := generateOAuthState() - if err != nil { - h.logger.Error("Failed to generate Google OAuth state", "error", err) - response.Fail(c, "Failed to start Google login") - return - } - - if err := h.cache.Set(c.Request.Context(), googleOAuthStateCacheKey(state), "1", h.googleStateTTL); err != nil { - h.logger.Error("Failed to persist Google OAuth state", "error", err) - response.Fail(c, "Failed to start Google login") - return - } - - url := h.googleOauth.AuthCodeURL(state, oauth2.AccessTypeOffline) - c.Redirect(http.StatusTemporaryRedirect, url) -} - -// @Summary Google Callback -// @Description Callback for Google Login -// @Tags auth -// @Success 307 -// @Failure 400 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /auth/google/callback [get] -func (h *handler) GoogleCallback(c *gin.Context) { - if oauthError := strings.TrimSpace(c.Query("error")); oauthError != "" { - h.redirectToGoogleFinalize(c, "error", oauthError) - return - } - - state := strings.TrimSpace(c.Query("state")) - if state == "" { - h.redirectToGoogleFinalize(c, "error", "missing_state") - return - } - - cachedState, err := h.cache.Get(c.Request.Context(), googleOAuthStateCacheKey(state)) - if err != nil || cachedState == "" { - h.redirectToGoogleFinalize(c, "error", "invalid_state") - return - } - _ = h.cache.Del(c.Request.Context(), googleOAuthStateCacheKey(state)) - - code := strings.TrimSpace(c.Query("code")) - if code == "" { - h.redirectToGoogleFinalize(c, "error", "missing_code") - return - } - - tokenResp, err := h.googleOauth.Exchange(c.Request.Context(), code) - if err != nil { - h.logger.Error("Failed to exchange Google OAuth token", "error", err) - h.redirectToGoogleFinalize(c, "error", "exchange_failed") - return - } - - client := h.googleOauth.Client(c.Request.Context(), tokenResp) - resp, err := client.Get("https://www.googleapis.com/oauth2/v2/userinfo") - if err != nil { - h.logger.Error("Failed to fetch Google user info", "error", err) - h.redirectToGoogleFinalize(c, "error", "userinfo_failed") - return - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - h.logger.Error("Google user info returned non-200", "status", resp.StatusCode) - h.redirectToGoogleFinalize(c, "error", "userinfo_failed") - return - } - - var googleUser struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Picture string `json:"picture"` - } - if err := json.NewDecoder(resp.Body).Decode(&googleUser); err != nil { - h.logger.Error("Failed to decode Google user info", "error", err) - h.redirectToGoogleFinalize(c, "error", "userinfo_parse_failed") - return - } - - if strings.TrimSpace(googleUser.Email) == "" { - h.redirectToGoogleFinalize(c, "error", "missing_email") - return - } - - u := query.User - user, err := u.WithContext(c.Request.Context()).Where(u.Email.Eq(googleUser.Email)).First() - if err != nil { - role := "USER" - user = &model.User{ - ID: uuid.New().String(), - Email: googleUser.Email, - Username: stringPointerOrNil(googleUser.Name), - GoogleID: stringPointerOrNil(googleUser.ID), - Avatar: stringPointerOrNil(googleUser.Picture), - Role: &role, - } - if err := u.WithContext(c.Request.Context()).Create(user); err != nil { - h.logger.Error("Failed to create Google user", "error", err) - h.redirectToGoogleFinalize(c, "error", "create_user_failed") - return - } - } else { - updates := map[string]interface{}{} - if user.GoogleID == nil || strings.TrimSpace(*user.GoogleID) == "" { - updates["google_id"] = googleUser.ID - } - if user.Avatar == nil || strings.TrimSpace(*user.Avatar) == "" { - updates["avatar"] = googleUser.Picture - } - if user.Username == nil || strings.TrimSpace(*user.Username) == "" { - updates["username"] = googleUser.Name - } - if len(updates) > 0 { - if err := h.db.WithContext(c.Request.Context()).Model(&model.User{}).Where("id = ?", user.ID).Updates(updates).Error; err != nil { - h.logger.Error("Failed to update Google user", "error", err) - h.redirectToGoogleFinalize(c, "error", "update_user_failed") - return - } - user, err = u.WithContext(c.Request.Context()).Where(u.ID.Eq(user.ID)).First() - if err != nil { - h.logger.Error("Failed to reload Google user", "error", err) - h.redirectToGoogleFinalize(c, "error", "reload_user_failed") - return - } - } - } - - if err := h.generateAndSetTokens(c, user.ID, user.Email, safeRole(user.Role)); err != nil { - h.redirectToGoogleFinalize(c, "error", "session_failed") - return - } - - if h.frontendBaseURL == "" { - h.respondWithUserPayload(c, user) - return - } - - h.redirectToGoogleFinalize(c, "success", "") -} - -// @Summary Get Current User -// @Description Get the authenticated user's profile payload -// @Tags auth -// @Produce json -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Router /me [get] -// @Security BearerAuth -func (h *handler) GetMe(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - u := query.User - user, err := u.WithContext(c.Request.Context()).Where(u.ID.Eq(userID)).First() - if err != nil { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - h.respondWithUserPayload(c, user) -} - -// @Summary Update Current User -// @Description Update the authenticated user's profile information -// @Tags auth -// @Accept json -// @Produce json -// @Param request body UpdateMeRequest true "Profile payload" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /me [put] -// @Security BearerAuth -func (h *handler) UpdateMe(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - var req UpdateMeRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - user, err := UpdateUserProfile(c.Request.Context(), h.db, h.logger, userID, UpdateProfileInput{ - Username: req.Username, - Email: req.Email, - Language: req.Language, - Locale: req.Locale, - }) - if err != nil { - switch { - case errors.Is(err, ErrEmailRequired): - response.Error(c, http.StatusBadRequest, err.Error()) - case errors.Is(err, ErrEmailAlreadyRegistered): - response.Error(c, http.StatusBadRequest, err.Error()) - default: - response.Fail(c, "Failed to update profile") - } - return - } - - h.respondWithUserPayload(c, user) -} - -// @Summary Change Password -// @Description Change the authenticated user's local password -// @Tags auth -// @Accept json -// @Produce json -// @Param request body ChangePasswordRequest true "Password payload" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /auth/change-password [post] -// @Security BearerAuth -func (h *handler) ChangePassword(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - var req ChangePasswordRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - u := query.User - user, err := u.WithContext(c.Request.Context()).Where(u.ID.Eq(userID)).First() - if err != nil { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - if user.Password == nil || strings.TrimSpace(*user.Password) == "" { - response.Error(c, http.StatusBadRequest, "This account does not have a local password") - return - } - - if err := bcrypt.CompareHashAndPassword([]byte(*user.Password), []byte(req.CurrentPassword)); err != nil { - response.Error(c, http.StatusBadRequest, "Current password is incorrect") - return - } - - if req.CurrentPassword == req.NewPassword { - response.Error(c, http.StatusBadRequest, "New password must be different") - return - } - - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) - if err != nil { - response.Fail(c, "Failed to hash password") - return - } - - if _, err := u.WithContext(c.Request.Context()).Where(u.ID.Eq(userID)).Update(u.Password, string(hashedPassword)); err != nil { - h.logger.Error("Failed to change password", "error", err) - response.Fail(c, "Failed to change password") - return - } - - response.Success(c, gin.H{"message": "Password changed successfully"}) -} - -// @Summary Clear My Data -// @Description Remove videos and settings-related resources for the authenticated user -// @Tags auth -// @Produce json -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /me/clear-data [post] -// @Security BearerAuth -func (h *handler) ClearMyData(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - ctx := c.Request.Context() - if err := h.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.VideoAdConfig{}).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 { - h.logger.Error("Failed to clear user data", "error", err) - response.Fail(c, "Failed to clear data") - return - } - - response.Success(c, gin.H{"message": "Data cleared successfully"}) -} - -// @Summary Delete My Account -// @Description Permanently delete the authenticated user's account and related data -// @Tags auth -// @Produce json -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /me [delete] -// @Security BearerAuth -func (h *handler) DeleteMe(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - ctx := c.Request.Context() - if err := h.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.VideoAdConfig{}).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 { - h.logger.Error("Failed to delete user", "error", err) - response.Fail(c, "Failed to delete account") - return - } - - c.SetCookie("access_token", "", -1, "/", "", false, true) - c.SetCookie("refresh_token", "", -1, "/", "", false, true) - response.Success(c, gin.H{"message": "Account deleted successfully"}) -} - -func (h *handler) generateAndSetTokens(c *gin.Context, userID, email, role string) error { - td, err := h.token.GenerateTokenPair(userID, email, role) - if err != nil { - h.logger.Error("Token generation failed", "error", err) - response.Fail(c, "Error generating tokens") - return err - } - - // Store Refresh UUID in Redis - err = h.cache.Set(c.Request.Context(), "refresh_uuid:"+td.RefreshUUID, userID, time.Until(time.Unix(td.RtExpires, 0))) - if err != nil { - h.logger.Error("Session storage failed", "error", err) - response.Fail(c, "Error storing session") - return err - } - - c.SetCookie("access_token", td.AccessToken, int(td.AtExpires-time.Now().Unix()), "/", "", false, true) - c.SetCookie("refresh_token", td.RefreshToken, int(td.RtExpires-time.Now().Unix()), "/", "", false, true) - return nil -} - -func (h *handler) respondWithUserPayload(c *gin.Context, user *model.User) { - payload, err := BuildUserPayload(c.Request.Context(), h.db, user) - if err != nil { - h.logger.Error("Failed to build user payload", "error", err) - response.Fail(c, "Failed to build user payload") - return - } - - response.Success(c, gin.H{"user": payload}) -} - -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 (h *handler) redirectToGoogleFinalize(c *gin.Context, status, reason string) { - finalizeURL := h.googleFinalizeURL(status, reason) - if finalizeURL == "" { - response.Error(c, http.StatusBadRequest, reason) - return - } - c.Redirect(http.StatusTemporaryRedirect, finalizeURL) -} - -func (h *handler) googleFinalizeURL(status, reason string) string { - if h.frontendBaseURL == "" { - return "" - } - - finalizePath := h.googleFinalizePath - if strings.TrimSpace(finalizePath) == "" { - finalizePath = "/auth/google/finalize" - } - if !strings.HasPrefix(finalizePath, "/") { - finalizePath = "/" + finalizePath - } - - values := url.Values{} - values.Set("status", status) - if strings.TrimSpace(reason) != "" { - values.Set("reason", reason) - } - - return fmt.Sprintf("%s%s?%s", h.frontendBaseURL, finalizePath, values.Encode()) -} diff --git a/internal/api/auth/interface.go b/internal/api/auth/interface.go deleted file mode 100644 index 771f428..0000000 --- a/internal/api/auth/interface.go +++ /dev/null @@ -1,58 +0,0 @@ -//go:build ignore -// +build ignore - -package auth - -import "github.com/gin-gonic/gin" - -// AuthHandler defines the interface for authentication operations -type AuthHandler interface { - Login(c *gin.Context) - Logout(c *gin.Context) - Register(c *gin.Context) - ForgotPassword(c *gin.Context) - ResetPassword(c *gin.Context) - LoginGoogle(c *gin.Context) - GoogleCallback(c *gin.Context) - GetMe(c *gin.Context) - UpdateMe(c *gin.Context) - ChangePassword(c *gin.Context) - DeleteMe(c *gin.Context) - ClearMyData(c *gin.Context) -} - -// LoginRequest defines the payload for login -type LoginRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required"` -} - -// RegisterRequest defines the payload for registration -type RegisterRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=6"` - Username string `json:"username" binding:"required"` -} - -// ForgotPasswordRequest defines the payload for requesting a password reset -type ForgotPasswordRequest struct { - Email string `json:"email" binding:"required,email"` -} - -// ResetPasswordRequest defines the payload for resetting the password -type ResetPasswordRequest struct { - Token string `json:"token" binding:"required"` - NewPassword string `json:"new_password" binding:"required,min=6"` -} - -type UpdateMeRequest struct { - Username *string `json:"username"` - Email *string `json:"email"` - Language *string `json:"language"` - Locale *string `json:"locale"` -} - -type ChangePasswordRequest struct { - CurrentPassword string `json:"current_password" binding:"required"` - NewPassword string `json:"new_password" binding:"required,min=6"` -} diff --git a/internal/api/auth/types.go b/internal/api/auth/types.go deleted file mode 100644 index 9379111..0000000 --- a/internal/api/auth/types.go +++ /dev/null @@ -1,33 +0,0 @@ -package auth - -type LoginRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required"` -} - -type RegisterRequest struct { - Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=6"` - Username string `json:"username" binding:"required"` -} - -type ForgotPasswordRequest struct { - Email string `json:"email" binding:"required,email"` -} - -type ResetPasswordRequest struct { - Token string `json:"token" binding:"required"` - NewPassword string `json:"new_password" binding:"required,min=6"` -} - -type UpdateMeRequest struct { - Username *string `json:"username"` - Email *string `json:"email"` - Language *string `json:"language"` - Locale *string `json:"locale"` -} - -type ChangePasswordRequest struct { - CurrentPassword string `json:"current_password" binding:"required"` - NewPassword string `json:"new_password" binding:"required,min=6"` -} diff --git a/internal/api/domains/handler.go b/internal/api/domains/handler.go deleted file mode 100644 index 48e97f4..0000000 --- a/internal/api/domains/handler.go +++ /dev/null @@ -1,166 +0,0 @@ -//go:build ignore -// +build ignore - -package domains - -import ( - "net/http" - "strings" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" - "stream.api/internal/database/model" - "stream.api/pkg/logger" - "stream.api/pkg/response" -) - -type Handler struct { - logger logger.Logger - db *gorm.DB -} - -type CreateDomainRequest struct { - Name string `json:"name" binding:"required"` -} - -func NewHandler(l logger.Logger, db *gorm.DB) *Handler { - return &Handler{logger: l, db: db} -} - -// @Summary List Domains -// @Description Get all whitelisted domains for the current user -// @Tags domains -// @Produce json -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /domains [get] -// @Security BearerAuth -func (h *Handler) ListDomains(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - var items []model.Domain - if err := h.db.WithContext(c.Request.Context()). - Where("user_id = ?", userID). - Order("created_at DESC"). - Find(&items).Error; err != nil { - h.logger.Error("Failed to list domains", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to load domains") - return - } - - response.Success(c, gin.H{"domains": items}) -} - -// @Summary Create Domain -// @Description Add a domain to the current user's whitelist -// @Tags domains -// @Accept json -// @Produce json -// @Param request body CreateDomainRequest true "Domain payload" -// @Success 201 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /domains [post] -// @Security BearerAuth -func (h *Handler) CreateDomain(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - var req CreateDomainRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - name := normalizeDomain(req.Name) - if name == "" || !strings.Contains(name, ".") || strings.ContainsAny(name, "/ ") { - response.Error(c, http.StatusBadRequest, "Invalid domain") - return - } - - var count int64 - if err := h.db.WithContext(c.Request.Context()). - Model(&model.Domain{}). - Where("user_id = ? AND name = ?", userID, name). - Count(&count).Error; err != nil { - h.logger.Error("Failed to validate domain", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to create domain") - return - } - if count > 0 { - response.Error(c, http.StatusBadRequest, "Domain already exists") - return - } - - item := &model.Domain{ - ID: uuid.New().String(), - UserID: userID, - Name: name, - } - if err := h.db.WithContext(c.Request.Context()).Create(item).Error; err != nil { - h.logger.Error("Failed to create domain", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to create domain") - return - } - - response.Created(c, gin.H{"domain": item}) -} - -// @Summary Delete Domain -// @Description Remove a domain from the current user's whitelist -// @Tags domains -// @Produce json -// @Param id path string true "Domain ID" -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 404 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /domains/{id} [delete] -// @Security BearerAuth -func (h *Handler) DeleteDomain(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Domain not found") - return - } - - result := h.db.WithContext(c.Request.Context()). - Where("id = ? AND user_id = ?", id, userID). - Delete(&model.Domain{}) - if result.Error != nil { - h.logger.Error("Failed to delete domain", "error", result.Error) - response.Error(c, http.StatusInternalServerError, "Failed to delete domain") - return - } - if result.RowsAffected == 0 { - response.Error(c, http.StatusNotFound, "Domain not found") - return - } - - response.Success(c, gin.H{"message": "Domain deleted"}) -} - -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 -} diff --git a/internal/api/notifications/handler.go b/internal/api/notifications/handler.go deleted file mode 100644 index 6090616..0000000 --- a/internal/api/notifications/handler.go +++ /dev/null @@ -1,246 +0,0 @@ -//go:build ignore -// +build ignore - -package notifications - -import ( - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - "stream.api/internal/database/model" - "stream.api/pkg/logger" - "stream.api/pkg/response" -) - -type Handler struct { - logger logger.Logger - db *gorm.DB -} - -type NotificationItem struct { - ID string `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - Message string `json:"message"` - Read bool `json:"read"` - ActionURL string `json:"actionUrl,omitempty"` - ActionLabel string `json:"actionLabel,omitempty"` - CreatedAt time.Time `json:"created_at"` -} - -func NewHandler(l logger.Logger, db *gorm.DB) *Handler { - return &Handler{logger: l, db: db} -} - -// @Summary List Notifications -// @Description Get notifications for the current user -// @Tags notifications -// @Produce json -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /notifications [get] -// @Security BearerAuth -func (h *Handler) ListNotifications(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - var rows []model.Notification - if err := h.db.WithContext(c.Request.Context()). - Where("user_id = ?", userID). - Order("created_at DESC"). - Find(&rows).Error; err != nil { - h.logger.Error("Failed to list notifications", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to load notifications") - return - } - - items := make([]NotificationItem, 0, len(rows)) - for _, row := range rows { - items = append(items, mapNotification(row)) - } - - response.Success(c, gin.H{"notifications": items}) -} - -// @Summary Mark Notification Read -// @Description Mark a single notification as read for the current user -// @Tags notifications -// @Produce json -// @Param id path string true "Notification ID" -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 404 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /notifications/{id}/read [post] -// @Security BearerAuth -func (h *Handler) MarkRead(c *gin.Context) { - h.updateReadState(c, true, false) -} - -// @Summary Mark All Notifications Read -// @Description Mark all notifications as read for the current user -// @Tags notifications -// @Produce json -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /notifications/read-all [post] -// @Security BearerAuth -func (h *Handler) MarkAllRead(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - if err := h.db.WithContext(c.Request.Context()). - Model(&model.Notification{}). - Where("user_id = ? AND is_read = ?", userID, false). - Update("is_read", true).Error; err != nil { - h.logger.Error("Failed to mark all notifications as read", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to update notifications") - return - } - - response.Success(c, gin.H{"message": "All notifications marked as read"}) -} - -// @Summary Delete Notification -// @Description Delete a single notification for the current user -// @Tags notifications -// @Produce json -// @Param id path string true "Notification ID" -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 404 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /notifications/{id} [delete] -// @Security BearerAuth -func (h *Handler) DeleteNotification(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Notification not found") - return - } - - result := h.db.WithContext(c.Request.Context()). - Where("id = ? AND user_id = ?", id, userID). - Delete(&model.Notification{}) - if result.Error != nil { - h.logger.Error("Failed to delete notification", "error", result.Error) - response.Error(c, http.StatusInternalServerError, "Failed to delete notification") - return - } - if result.RowsAffected == 0 { - response.Error(c, http.StatusNotFound, "Notification not found") - return - } - - response.Success(c, gin.H{"message": "Notification deleted"}) -} - -// @Summary Clear Notifications -// @Description Delete all notifications for the current user -// @Tags notifications -// @Produce json -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /notifications [delete] -// @Security BearerAuth -func (h *Handler) ClearNotifications(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - if err := h.db.WithContext(c.Request.Context()).Where("user_id = ?", userID).Delete(&model.Notification{}).Error; err != nil { - h.logger.Error("Failed to clear notifications", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to clear notifications") - return - } - - response.Success(c, gin.H{"message": "All notifications deleted"}) -} - -func (h *Handler) updateReadState(c *gin.Context, value bool, silentNotFound bool) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Notification not found") - return - } - - result := h.db.WithContext(c.Request.Context()). - Model(&model.Notification{}). - Where("id = ? AND user_id = ?", id, userID). - Update("is_read", value) - if result.Error != nil { - h.logger.Error("Failed to update notification", "error", result.Error) - response.Error(c, http.StatusInternalServerError, "Failed to update notification") - return - } - if result.RowsAffected == 0 && !silentNotFound { - response.Error(c, http.StatusNotFound, "Notification not found") - return - } - - response.Success(c, gin.H{"message": "Notification updated"}) -} - -func mapNotification(item model.Notification) NotificationItem { - createdAt := time.Time{} - if item.CreatedAt != nil { - createdAt = item.CreatedAt.UTC() - } - - return NotificationItem{ - ID: item.ID, - Type: normalizeType(item.Type), - Title: item.Title, - Message: item.Message, - Read: item.IsRead, - ActionURL: model.StringValue(item.ActionURL), - ActionLabel: model.StringValue(item.ActionLabel), - CreatedAt: createdAt, - } -} - -func normalizeType(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" - } -} diff --git a/internal/api/payment/handler.go b/internal/api/payment/handler.go deleted file mode 100644 index 38fd1ae..0000000 --- a/internal/api/payment/handler.go +++ /dev/null @@ -1,806 +0,0 @@ -//go:build ignore -// +build ignore - -package payment - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "net/http" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" - "gorm.io/gorm/clause" - "stream.api/internal/config" - "stream.api/internal/database/model" - "stream.api/internal/database/query" - "stream.api/pkg/logger" - "stream.api/pkg/response" -) - -const ( - walletTransactionTypeTopup = "topup" - walletTransactionTypeSubscriptionDebit = "subscription_debit" - paymentMethodWallet = "wallet" - paymentMethodTopup = "topup" - paymentKindSubscription = "subscription" - paymentKindWalletTopup = "wallet_topup" -) - -var allowedTermMonths = map[int32]struct{}{ - 1: {}, - 3: {}, - 6: {}, - 12: {}, -} - -type Handler struct { - logger logger.Logger - cfg *config.Config - db *gorm.DB -} - -type paymentRow 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"` - 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 paymentInvoiceDetails struct { - PlanName string - TermMonths *int32 - PaymentMethod string - ExpiresAt *time.Time - WalletAmount float64 - TopupAmount float64 -} - -type paymentError struct { - Code int - Message string - Data interface{} -} - -func (e *paymentError) Error() string { - return e.Message -} - -func NewHandler(l logger.Logger, cfg *config.Config, db *gorm.DB) PaymentHandler { - return &Handler{ - logger: l, - cfg: cfg, - db: db, - } -} - -// @Summary Create Payment -// @Description Create a new payment for buying or renewing a plan -// @Tags payment -// @Accept json -// @Produce json -// @Param request body CreatePaymentRequest true "Payment Info" -// @Success 201 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 404 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /payments [post] -// @Security BearerAuth -func (h *Handler) CreatePayment(c *gin.Context) { - var req CreatePaymentRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - planID := strings.TrimSpace(req.PlanID) - if planID == "" { - response.Error(c, http.StatusBadRequest, "Plan ID is required") - return - } - if !isAllowedTermMonths(req.TermMonths) { - response.Error(c, http.StatusBadRequest, "Term months must be one of 1, 3, 6, or 12") - return - } - - paymentMethod := normalizePaymentMethod(req.PaymentMethod) - if paymentMethod == "" { - response.Error(c, http.StatusBadRequest, "Payment method must be wallet or topup") - return - } - - ctx := c.Request.Context() - var planRecord model.Plan - if err := h.db.WithContext(ctx).Where("id = ?", planID).First(&planRecord).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusNotFound, "Plan not found") - return - } - h.logger.Error("Failed to load plan", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to create payment") - return - } - - if planRecord.IsActive == nil || !*planRecord.IsActive { - response.Error(c, http.StatusBadRequest, "Plan is not active") - return - } - - totalAmount := planRecord.Price * float64(req.TermMonths) - if totalAmount < 0 { - response.Error(c, http.StatusBadRequest, "Amount must be greater than or equal to 0") - return - } - - status := "SUCCESS" - provider := "INTERNAL" - currency := normalizeCurrency(nil) - transactionID := buildTransactionID("sub") - now := time.Now().UTC() - - payment := &model.Payment{ - ID: uuid.New().String(), - UserID: userID, - PlanID: &planRecord.ID, - Amount: totalAmount, - Currency: ¤cy, - Status: &status, - Provider: &provider, - TransactionID: &transactionID, - } - - invoiceID := buildInvoiceID(payment.ID) - var subscription *model.PlanSubscription - var walletBalance float64 - - err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if _, err := lockUserForUpdate(ctx, tx, userID); err != nil { - return err - } - - currentSubscription, err := model.GetLatestPlanSubscription(ctx, tx, userID) - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - - baseExpiry := now - if currentSubscription != nil && currentSubscription.ExpiresAt.After(baseExpiry) { - baseExpiry = currentSubscription.ExpiresAt.UTC() - } - newExpiry := baseExpiry.AddDate(0, int(req.TermMonths), 0) - - currentWalletBalance, err := model.GetWalletBalance(ctx, tx, userID) - if err != nil { - return err - } - shortfall := maxFloat(totalAmount-currentWalletBalance, 0) - - if paymentMethod == paymentMethodWallet && shortfall > 0 { - return &paymentError{ - Code: http.StatusBadRequest, - Message: "Insufficient wallet balance", - Data: gin.H{ - "payment_method": paymentMethod, - "wallet_balance": currentWalletBalance, - "total_amount": totalAmount, - "shortfall": shortfall, - }, - } - } - - topupAmount := 0.0 - if paymentMethod == paymentMethodTopup { - if req.TopupAmount == nil { - return &paymentError{ - Code: http.StatusBadRequest, - Message: "Top-up amount is required when payment method is topup", - Data: gin.H{ - "payment_method": paymentMethod, - "wallet_balance": currentWalletBalance, - "total_amount": totalAmount, - "shortfall": shortfall, - }, - } - } - - topupAmount = maxFloat(*req.TopupAmount, 0) - if topupAmount <= 0 { - return &paymentError{ - Code: http.StatusBadRequest, - Message: "Top-up amount must be greater than 0", - Data: gin.H{ - "payment_method": paymentMethod, - "wallet_balance": currentWalletBalance, - "total_amount": totalAmount, - "shortfall": shortfall, - }, - } - } - if topupAmount < shortfall { - return &paymentError{ - Code: http.StatusBadRequest, - Message: "Top-up amount must be greater than or equal to the required shortfall", - Data: gin.H{ - "payment_method": paymentMethod, - "wallet_balance": currentWalletBalance, - "total_amount": totalAmount, - "shortfall": shortfall, - "topup_amount": topupAmount, - }, - } - } - } - - if err := tx.Create(payment).Error; err != nil { - return err - } - - walletUsedAmount := totalAmount - - if paymentMethod == paymentMethodTopup { - topupTransaction := &model.WalletTransaction{ - ID: uuid.New().String(), - UserID: userID, - Type: walletTransactionTypeTopup, - Amount: topupAmount, - Currency: model.StringPtr(currency), - Note: model.StringPtr(fmt.Sprintf("Wallet top-up for %s (%d months)", planRecord.Name, req.TermMonths)), - PaymentID: &payment.ID, - PlanID: &planRecord.ID, - TermMonths: &req.TermMonths, - } - if err := tx.Create(topupTransaction).Error; err != nil { - return err - } - } - - debitTransaction := &model.WalletTransaction{ - ID: uuid.New().String(), - UserID: userID, - Type: walletTransactionTypeSubscriptionDebit, - Amount: -totalAmount, - Currency: model.StringPtr(currency), - Note: model.StringPtr(fmt.Sprintf("Subscription payment for %s (%d months)", planRecord.Name, req.TermMonths)), - PaymentID: &payment.ID, - PlanID: &planRecord.ID, - TermMonths: &req.TermMonths, - } - if err := tx.Create(debitTransaction).Error; err != nil { - return err - } - - subscription = &model.PlanSubscription{ - ID: uuid.New().String(), - UserID: userID, - PaymentID: payment.ID, - PlanID: planRecord.ID, - TermMonths: req.TermMonths, - PaymentMethod: paymentMethod, - WalletAmount: walletUsedAmount, - TopupAmount: topupAmount, - StartedAt: now, - ExpiresAt: newExpiry, - } - if err := tx.Create(subscription).Error; err != nil { - return err - } - - if err := tx.Model(&model.User{}). - Where("id = ?", userID). - Update("plan_id", planRecord.ID).Error; err != nil { - return err - } - - notification := buildSubscriptionNotification(userID, payment.ID, invoiceID, &planRecord, subscription) - if err := tx.Create(notification).Error; err != nil { - return err - } - - walletBalance, err = model.GetWalletBalance(ctx, tx, userID) - if err != nil { - return err - } - - return nil - }) - if err != nil { - var paymentErr *paymentError - if errors.As(err, &paymentErr) { - c.AbortWithStatusJSON(paymentErr.Code, response.Response{ - Code: paymentErr.Code, - Message: paymentErr.Message, - Data: paymentErr.Data, - }) - return - } - - h.logger.Error("Failed to create payment", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to create payment") - return - } - - response.Created(c, gin.H{ - "payment": payment, - "subscription": subscription, - "wallet_balance": walletBalance, - "invoice_id": invoiceID, - "message": "Payment completed successfully", - }) -} - -// @Summary List Payment History -// @Description Get payment history for the current user -// @Tags payment -// @Produce json -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /payments/history [get] -// @Security BearerAuth -func (h *Handler) ListPaymentHistory(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - var rows []paymentRow - if err := h.db.WithContext(c.Request.Context()). - Table("payment AS p"). - Select("p.id, p.amount, p.currency, p.status, p.plan_id, pl.name AS plan_name, ps.term_months, ps.payment_method, ps.expires_at, p.created_at"). - Joins("LEFT JOIN plan AS pl ON pl.id = p.plan_id"). - Joins("LEFT JOIN plan_subscriptions AS ps ON ps.payment_id = p.id"). - Where("p.user_id = ?", userID). - Order("p.created_at DESC"). - Scan(&rows).Error; err != nil { - h.logger.Error("Failed to fetch payment history", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to fetch payment history") - return - } - - items := make([]PaymentHistoryItem, 0, len(rows)) - for _, row := range rows { - items = append(items, PaymentHistoryItem{ - ID: row.ID, - Amount: row.Amount, - Currency: normalizeCurrency(row.Currency), - Status: normalizePaymentStatus(row.Status), - PlanID: row.PlanID, - PlanName: row.PlanName, - InvoiceID: buildInvoiceID(row.ID), - Kind: paymentKindSubscription, - TermMonths: row.TermMonths, - PaymentMethod: normalizeOptionalPaymentMethod(row.PaymentMethod), - ExpiresAt: row.ExpiresAt, - CreatedAt: row.CreatedAt, - }) - } - - var topups []model.WalletTransaction - if err := h.db.WithContext(c.Request.Context()). - Where("user_id = ? AND type = ? AND payment_id IS NULL", userID, walletTransactionTypeTopup). - Order("created_at DESC"). - Find(&topups).Error; err != nil { - h.logger.Error("Failed to fetch wallet topups", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to fetch payment history") - return - } - - for _, topup := range topups { - createdAt := topup.CreatedAt - items = append(items, PaymentHistoryItem{ - ID: topup.ID, - Amount: topup.Amount, - Currency: normalizeCurrency(topup.Currency), - Status: "success", - InvoiceID: buildInvoiceID(topup.ID), - Kind: paymentKindWalletTopup, - CreatedAt: createdAt, - }) - } - - sortPaymentHistory(items) - response.Success(c, gin.H{"payments": items}) -} - -// @Summary Top Up Wallet -// @Description Add funds to wallet balance for the current user -// @Tags payment -// @Accept json -// @Produce json -// @Param request body TopupWalletRequest true "Topup Info" -// @Success 201 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /wallet/topups [post] -// @Security BearerAuth -func (h *Handler) TopupWallet(c *gin.Context) { - var req TopupWalletRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - amount := req.Amount - if amount < 1 { - response.Error(c, http.StatusBadRequest, "Amount must be at least 1") - return - } - - transaction := &model.WalletTransaction{ - ID: uuid.New().String(), - UserID: userID, - Type: walletTransactionTypeTopup, - Amount: amount, - Currency: model.StringPtr("USD"), - Note: model.StringPtr(fmt.Sprintf("Wallet top-up of %.2f USD", amount)), - } - - notification := &model.Notification{ - ID: uuid.New().String(), - UserID: userID, - Type: "billing.topup", - Title: "Wallet credited", - Message: fmt.Sprintf("Your wallet has been credited with %.2f USD.", amount), - Metadata: model.StringPtr(mustMarshalJSON(gin.H{ - "wallet_transaction_id": transaction.ID, - "invoice_id": buildInvoiceID(transaction.ID), - })), - } - - if err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { - if _, err := lockUserForUpdate(c.Request.Context(), tx, 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 { - h.logger.Error("Failed to top up wallet", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to top up wallet") - return - } - - balance, err := model.GetWalletBalance(c.Request.Context(), h.db, userID) - if err != nil { - h.logger.Error("Failed to calculate wallet balance", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to top up wallet") - return - } - - response.Created(c, gin.H{ - "wallet_transaction": transaction, - "wallet_balance": balance, - "invoice_id": buildInvoiceID(transaction.ID), - }) -} - -// @Summary Download Invoice -// @Description Download invoice text for a payment or wallet top-up -// @Tags payment -// @Produce plain -// @Param id path string true "Payment ID" -// @Success 200 {string} string -// @Failure 401 {object} response.Response -// @Failure 404 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /payments/{id}/invoice [get] -// @Security BearerAuth -func (h *Handler) DownloadInvoice(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - id := strings.TrimSpace(c.Param("id")) - if id == "" { - response.Error(c, http.StatusNotFound, "Invoice not found") - return - } - - ctx := c.Request.Context() - paymentRecord, err := query.Payment.WithContext(ctx). - Where(query.Payment.ID.Eq(id), query.Payment.UserID.Eq(userID)). - First() - if err == nil { - invoiceText, filename, buildErr := h.buildPaymentInvoice(ctx, paymentRecord) - if buildErr != nil { - h.logger.Error("Failed to build payment invoice", "error", buildErr) - response.Error(c, http.StatusInternalServerError, "Failed to download invoice") - return - } - serveInvoiceText(c, filename, invoiceText) - return - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - h.logger.Error("Failed to load payment invoice", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to download invoice") - return - } - - var topup model.WalletTransaction - if err := h.db.WithContext(ctx). - Where("id = ? AND user_id = ? AND type = ? AND payment_id IS NULL", id, userID, walletTransactionTypeTopup). - First(&topup).Error; err == nil { - invoiceText := buildTopupInvoice(&topup) - serveInvoiceText(c, buildInvoiceFilename(topup.ID), invoiceText) - return - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - h.logger.Error("Failed to load topup invoice", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to download invoice") - return - } - - response.Error(c, http.StatusNotFound, "Invoice not found") -} - -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) -} - -func stringValue(value *string) string { - if value == nil { - return "" - } - return *value -} - -func sortPaymentHistory(items []PaymentHistoryItem) { - for i := 0; i < len(items); i++ { - for j := i + 1; j < len(items); j++ { - left := time.Time{} - right := time.Time{} - if items[i].CreatedAt != nil { - left = *items[i].CreatedAt - } - if items[j].CreatedAt != nil { - right = *items[j].CreatedAt - } - if right.After(left) { - items[i], items[j] = items[j], items[i] - } - } - } -} - -func serveInvoiceText(c *gin.Context, filename string, content string) { - c.Header("Content-Type", "text/plain; charset=utf-8") - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename)) - c.String(http.StatusOK, content) -} - -func (h *Handler) buildPaymentInvoice(ctx context.Context, paymentRecord *model.Payment) (string, string, error) { - details, err := h.loadPaymentInvoiceDetails(ctx, paymentRecord) - if err != nil { - return "", "", err - } - - createdAt := formatOptionalTimestamp(paymentRecord.CreatedAt) - lines := []string{ - "Stream API Invoice", - fmt.Sprintf("Invoice ID: %s", buildInvoiceID(paymentRecord.ID)), - fmt.Sprintf("Payment ID: %s", paymentRecord.ID), - fmt.Sprintf("User ID: %s", paymentRecord.UserID), - fmt.Sprintf("Plan: %s", details.PlanName), - fmt.Sprintf("Amount: %.2f %s", paymentRecord.Amount, normalizeCurrency(paymentRecord.Currency)), - fmt.Sprintf("Status: %s", strings.ToUpper(normalizePaymentStatus(paymentRecord.Status))), - fmt.Sprintf("Provider: %s", strings.ToUpper(stringValue(paymentRecord.Provider))), - fmt.Sprintf("Payment Method: %s", strings.ToUpper(details.PaymentMethod)), - fmt.Sprintf("Transaction ID: %s", stringValue(paymentRecord.TransactionID)), - } - - if details.TermMonths != nil { - lines = append(lines, fmt.Sprintf("Term: %d month(s)", *details.TermMonths)) - } - if details.ExpiresAt != nil { - lines = append(lines, fmt.Sprintf("Valid Until: %s", details.ExpiresAt.UTC().Format(time.RFC3339))) - } - if details.WalletAmount > 0 { - lines = append(lines, fmt.Sprintf("Wallet Applied: %.2f %s", details.WalletAmount, normalizeCurrency(paymentRecord.Currency))) - } - if details.TopupAmount > 0 { - lines = append(lines, fmt.Sprintf("Top-up Added: %.2f %s", details.TopupAmount, normalizeCurrency(paymentRecord.Currency))) - } - lines = append(lines, fmt.Sprintf("Created At: %s", createdAt)) - - return strings.Join(lines, "\n"), buildInvoiceFilename(paymentRecord.ID), nil -} - -func buildTopupInvoice(transaction *model.WalletTransaction) string { - createdAt := formatOptionalTimestamp(transaction.CreatedAt) - return strings.Join([]string{ - "Stream API Wallet Top-up Invoice", - fmt.Sprintf("Invoice ID: %s", buildInvoiceID(transaction.ID)), - fmt.Sprintf("Wallet Transaction ID: %s", transaction.ID), - fmt.Sprintf("User ID: %s", transaction.UserID), - fmt.Sprintf("Amount: %.2f %s", transaction.Amount, normalizeCurrency(transaction.Currency)), - "Status: SUCCESS", - fmt.Sprintf("Type: %s", strings.ToUpper(transaction.Type)), - fmt.Sprintf("Note: %s", model.StringValue(transaction.Note)), - fmt.Sprintf("Created At: %s", createdAt), - }, "\n") -} - -func (h *Handler) loadPaymentInvoiceDetails(ctx context.Context, paymentRecord *model.Payment) (*paymentInvoiceDetails, error) { - details := &paymentInvoiceDetails{ - PlanName: "Unknown plan", - PaymentMethod: paymentMethodWallet, - } - - if paymentRecord.PlanID != nil && strings.TrimSpace(*paymentRecord.PlanID) != "" { - var planRecord model.Plan - if err := h.db.WithContext(ctx).Where("id = ?", *paymentRecord.PlanID).First(&planRecord).Error; err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - } else { - details.PlanName = planRecord.Name - } - } - - var subscription model.PlanSubscription - if err := h.db.WithContext(ctx). - Where("payment_id = ?", paymentRecord.ID). - Order("created_at DESC"). - First(&subscription).Error; err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - return details, nil - } - - details.TermMonths = &subscription.TermMonths - details.PaymentMethod = normalizePaymentMethod(subscription.PaymentMethod) - if details.PaymentMethod == "" { - details.PaymentMethod = paymentMethodWallet - } - details.ExpiresAt = &subscription.ExpiresAt - details.WalletAmount = subscription.WalletAmount - details.TopupAmount = subscription.TopupAmount - - return details, nil -} - -func buildSubscriptionNotification(userID string, paymentID string, 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(gin.H{ - "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 isAllowedTermMonths(value int32) bool { - _, ok := allowedTermMonths[value] - return ok -} - -func lockUserForUpdate(ctx context.Context, tx *gorm.DB, userID string) (*model.User, error) { - 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 float64, right float64) float64 { - if left > right { - return left - } - return right -} - -func formatOptionalTimestamp(value *time.Time) string { - if value == nil { - return "" - } - return value.UTC().Format(time.RFC3339) -} - -func mustMarshalJSON(value interface{}) string { - encoded, err := json.Marshal(value) - if err != nil { - return "{}" - } - return string(encoded) -} diff --git a/internal/api/payment/http_types.go b/internal/api/payment/http_types.go deleted file mode 100644 index 3c49f9d..0000000 --- a/internal/api/payment/http_types.go +++ /dev/null @@ -1,12 +0,0 @@ -package payment - -type CreatePaymentRequest struct { - PlanID string `json:"plan_id" binding:"required"` - TermMonths int32 `json:"term_months" binding:"required"` - PaymentMethod string `json:"payment_method" binding:"required"` - TopupAmount *float64 `json:"topup_amount,omitempty"` -} - -type TopupWalletRequest struct { - Amount float64 `json:"amount" binding:"required"` -} diff --git a/internal/api/payment/interface.go b/internal/api/payment/interface.go deleted file mode 100644 index 953f2d5..0000000 --- a/internal/api/payment/interface.go +++ /dev/null @@ -1,26 +0,0 @@ -//go:build ignore -// +build ignore - -package payment - -import "github.com/gin-gonic/gin" - -// PaymentHandler defines the interface for payment operations -type PaymentHandler interface { - CreatePayment(c *gin.Context) - ListPaymentHistory(c *gin.Context) - TopupWallet(c *gin.Context) - DownloadInvoice(c *gin.Context) -} - -// CreatePaymentRequest defines the payload for creating a payment -type CreatePaymentRequest struct { - PlanID string `json:"plan_id" binding:"required"` - TermMonths int32 `json:"term_months" binding:"required"` - PaymentMethod string `json:"payment_method" binding:"required"` - TopupAmount *float64 `json:"topup_amount,omitempty"` -} - -type TopupWalletRequest struct { - Amount float64 `json:"amount" binding:"required"` -} diff --git a/internal/api/payment/types.go b/internal/api/payment/types.go deleted file mode 100644 index 5f719c9..0000000 --- a/internal/api/payment/types.go +++ /dev/null @@ -1,18 +0,0 @@ -package payment - -import "time" - -type PaymentHistoryItem struct { - ID string `json:"id"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Status string `json:"status"` - PlanID *string `json:"plan_id,omitempty"` - PlanName *string `json:"plan_name,omitempty"` - InvoiceID string `json:"invoice_id"` - Kind string `json:"kind"` - TermMonths *int32 `json:"term_months,omitempty"` - PaymentMethod *string `json:"payment_method,omitempty"` - ExpiresAt *time.Time `json:"expires_at,omitempty"` - CreatedAt *time.Time `json:"created_at,omitempty"` -} diff --git a/internal/api/plan/handler.go b/internal/api/plan/handler.go deleted file mode 100644 index 671974a..0000000 --- a/internal/api/plan/handler.go +++ /dev/null @@ -1,48 +0,0 @@ -//go:build ignore -// +build ignore - -package plan - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - "stream.api/internal/config" - "stream.api/internal/database/model" - "stream.api/pkg/logger" - "stream.api/pkg/response" -) - -type Handler struct { - logger logger.Logger - cfg *config.Config - db *gorm.DB -} - -func NewHandler(l logger.Logger, cfg *config.Config, db *gorm.DB) PlanHandler { - return &Handler{ - logger: l, - cfg: cfg, - db: db, - } -} - -// @Summary List Plans -// @Description Get all active plans -// @Tags plan -// @Produce json -// @Success 200 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /plans [get] -// @Security BearerAuth -func (h *Handler) ListPlans(c *gin.Context) { - var plans []model.Plan - if err := h.db.WithContext(c.Request.Context()).Where("is_active = ?", true).Find(&plans).Error; err != nil { - h.logger.Error("Failed to fetch plans", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to fetch plans") - return - } - - response.Success(c, gin.H{"plans": plans}) -} diff --git a/internal/api/plan/interface.go b/internal/api/plan/interface.go deleted file mode 100644 index 7e7fc4f..0000000 --- a/internal/api/plan/interface.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build ignore -// +build ignore - -package plan - -import "github.com/gin-gonic/gin" - -// PlanHandler defines the interface for plan operations -type PlanHandler interface { - ListPlans(c *gin.Context) -} diff --git a/internal/api/preferences/handler.go b/internal/api/preferences/handler.go deleted file mode 100644 index b7edc5c..0000000 --- a/internal/api/preferences/handler.go +++ /dev/null @@ -1,112 +0,0 @@ -//go:build ignore -// +build ignore - -package preferences - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - "stream.api/pkg/logger" - "stream.api/pkg/response" -) - -type Handler struct { - logger logger.Logger - db *gorm.DB -} - -type SettingsPreferencesRequest struct { - EmailNotifications *bool `json:"email_notifications"` - PushNotifications *bool `json:"push_notifications"` - MarketingNotifications *bool `json:"marketing_notifications"` - TelegramNotifications *bool `json:"telegram_notifications"` - Autoplay *bool `json:"autoplay"` - Loop *bool `json:"loop"` - Muted *bool `json:"muted"` - ShowControls *bool `json:"show_controls"` - Pip *bool `json:"pip"` - Airplay *bool `json:"airplay"` - Chromecast *bool `json:"chromecast"` - Language *string `json:"language"` - Locale *string `json:"locale"` -} - -func NewHandler(l logger.Logger, db *gorm.DB) *Handler { - return &Handler{logger: l, db: db} -} - -// @Summary Get Preferences -// @Description Get notification, player, and locale preferences for the current user -// @Tags settings -// @Produce json -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /settings/preferences [get] -// @Security BearerAuth -func (h *Handler) GetPreferences(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - pref, err := LoadUserPreferences(c.Request.Context(), h.db, userID) - if err != nil { - h.logger.Error("Failed to load preferences", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to load preferences") - return - } - - response.Success(c, gin.H{"preferences": pref}) -} - -// @Summary Update Preferences -// @Description Update notification, player, and locale preferences for the current user -// @Tags settings -// @Accept json -// @Produce json -// @Param request body SettingsPreferencesRequest true "Preferences payload" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /settings/preferences [put] -// @Security BearerAuth -func (h *Handler) UpdatePreferences(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - var req SettingsPreferencesRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - pref, err := UpdateUserPreferences(c.Request.Context(), h.db, h.logger, userID, UpdateInput{ - EmailNotifications: req.EmailNotifications, - PushNotifications: req.PushNotifications, - MarketingNotifications: req.MarketingNotifications, - TelegramNotifications: req.TelegramNotifications, - Autoplay: req.Autoplay, - Loop: req.Loop, - Muted: req.Muted, - ShowControls: req.ShowControls, - Pip: req.Pip, - Airplay: req.Airplay, - Chromecast: req.Chromecast, - Language: req.Language, - Locale: req.Locale, - }) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to save preferences") - return - } - - response.Success(c, gin.H{"preferences": pref}) -} diff --git a/internal/api/usage/handler.go b/internal/api/usage/handler.go deleted file mode 100644 index 09186a4..0000000 --- a/internal/api/usage/handler.go +++ /dev/null @@ -1,63 +0,0 @@ -//go:build ignore -// +build ignore - -package usage - -import ( - "net/http" - - "github.com/gin-gonic/gin" - "gorm.io/gorm" - "stream.api/internal/database/model" - "stream.api/pkg/logger" - "stream.api/pkg/response" -) - -type Handler struct { - logger logger.Logger - db *gorm.DB -} - -func NewHandler(l logger.Logger, db *gorm.DB) UsageHandler { - return &Handler{ - logger: l, - db: db, - } -} - -// @Summary Get Usage -// @Description Get the authenticated user's total video count and total storage usage -// @Tags usage -// @Produce json -// @Success 200 {object} response.Response{data=UsagePayload} -// @Failure 401 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /usage [get] -// @Security BearerAuth -func (h *Handler) GetUsage(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - user, ok := c.Get("user") - if !ok { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - currentUser, ok := user.(*model.User) - if !ok || currentUser == nil || currentUser.ID != userID { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - payload, err := LoadUsage(c.Request.Context(), h.db, h.logger, currentUser) - if err != nil { - response.Error(c, http.StatusInternalServerError, "Failed to load usage") - return - } - - response.Success(c, payload) -} diff --git a/internal/api/usage/interface.go b/internal/api/usage/interface.go deleted file mode 100644 index a060142..0000000 --- a/internal/api/usage/interface.go +++ /dev/null @@ -1,11 +0,0 @@ -//go:build ignore -// +build ignore - -package usage - -import "github.com/gin-gonic/gin" - -// UsageHandler defines the interface for usage operations -type UsageHandler interface { - GetUsage(c *gin.Context) -} diff --git a/internal/api/usage/types.go b/internal/api/usage/types.go deleted file mode 100644 index b5c2e2d..0000000 --- a/internal/api/usage/types.go +++ /dev/null @@ -1,7 +0,0 @@ -package usage - -type UsagePayload struct { - UserID string `json:"user_id"` - TotalVideos int64 `json:"total_videos"` - TotalStorage int64 `json:"total_storage"` -} diff --git a/internal/api/video/handler.go b/internal/api/video/handler.go deleted file mode 100644 index c8e98db..0000000 --- a/internal/api/video/handler.go +++ /dev/null @@ -1,583 +0,0 @@ -//go:build ignore -// +build ignore - -package video - -import ( - "errors" - "fmt" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/google/uuid" - "gorm.io/gorm" - "stream.api/internal/config" - "stream.api/internal/database/model" - "stream.api/pkg/logger" - "stream.api/pkg/response" - "stream.api/pkg/storage" -) - -type Handler struct { - logger logger.Logger - cfg *config.Config - db *gorm.DB - storage storage.Provider -} - -type videoError struct { - Code int - Message string -} - -func (e *videoError) Error() string { return e.Message } - -func NewHandler(l logger.Logger, cfg *config.Config, db *gorm.DB, s storage.Provider) VideoHandler { - return &Handler{ - logger: l, - cfg: cfg, - db: db, - storage: s, - } -} - -// @Summary Get Upload URL -// @Description Generate presigned URL for video upload -// @Tags video -// @Accept json -// @Produce json -// @Param request body UploadURLRequest true "File Info" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /videos/upload-url [post] -// @Security BearerAuth -func (h *Handler) GetUploadURL(c *gin.Context) { - var req UploadURLRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - userID := c.GetString("userID") - fileID := uuid.New().String() - key := fmt.Sprintf("videos/%s/%s-%s", userID, fileID, req.Filename) - - url, err := h.storage.GeneratePresignedURL(key, 15*time.Minute) - if err != nil { - h.logger.Error("Failed to generate presigned URL", "error", err) - response.Error(c, http.StatusInternalServerError, "Storage error") - return - } - - response.Success(c, gin.H{ - "upload_url": url, - "key": key, - "file_id": fileID, - }) -} - -// @Summary Create Video -// @Description Create video record after upload -// @Tags video -// @Accept json -// @Produce json -// @Param request body CreateVideoRequest true "Video Info" -// @Success 201 {object} response.Response{data=model.Video} -// @Failure 400 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /videos [post] -// @Security BearerAuth -func (h *Handler) CreateVideo(c *gin.Context) { - var req CreateVideoRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - title := strings.TrimSpace(req.Title) - if title == "" { - response.Error(c, http.StatusBadRequest, "Title is required") - return - } - - videoURL := strings.TrimSpace(req.URL) - if videoURL == "" { - response.Error(c, http.StatusBadRequest, "URL is required") - return - } - - status := "ready" - processingStatus := "READY" - storageType := detectStorageType(videoURL) - description := strings.TrimSpace(req.Description) - format := strings.TrimSpace(req.Format) - - video := &model.Video{ - ID: uuid.New().String(), - UserID: userID, - Name: title, - Title: title, - Description: stringPointer(description), - URL: videoURL, - Size: req.Size, - Duration: req.Duration, - Format: format, - Status: &status, - ProcessingStatus: &processingStatus, - StorageType: &storageType, - } - - err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { - var defaultTemplate model.AdTemplate - hasDefaultTemplate := false - - if err := tx.Where("user_id = ? AND is_default = ? AND is_active = ?", userID, true, true). - Order("updated_at DESC"). - First(&defaultTemplate).Error; err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - } else { - hasDefaultTemplate = true - } - - if err := tx.Create(video).Error; err != nil { - return err - } - - if err := tx.Model(&model.User{}). - Where("id = ?", userID). - UpdateColumn("storage_used", gorm.Expr("storage_used + ?", video.Size)).Error; err != nil { - return err - } - - if hasDefaultTemplate { - videoAdConfig := &model.VideoAdConfig{ - VideoID: video.ID, - UserID: userID, - AdTemplateID: defaultTemplate.ID, - VastTagURL: defaultTemplate.VastTagURL, - AdFormat: defaultTemplate.AdFormat, - Duration: defaultTemplate.Duration, - } - - if err := tx.Create(videoAdConfig).Error; err != nil { - return err - } - } - - return nil - }) - - if err != nil { - h.logger.Error("Failed to create video record", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to create video") - return - } - - response.Created(c, gin.H{"video": video}) -} - -// @Summary List Videos -// @Description Get paginated videos -// @Tags video -// @Produce json -// @Param page query int false "Page number" default(1) -// @Param limit query int false "Page size" default(10) -// @Success 200 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /videos [get] -// @Security BearerAuth -func (h *Handler) ListVideos(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - if page < 1 { - page = 1 - } - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) - if limit <= 0 { - limit = 10 - } - if limit > 100 { - limit = 100 - } - offset := (page - 1) * limit - - search := strings.TrimSpace(c.Query("search")) - status := strings.TrimSpace(c.Query("status")) - - db := h.db.WithContext(c.Request.Context()).Model(&model.Video{}).Where("user_id = ?", userID) - if search != "" { - like := "%" + search + "%" - db = db.Where("title ILIKE ? OR description ILIKE ?", like, like) - } - if status != "" && !strings.EqualFold(status, "all") { - db = db.Where("status = ?", normalizeVideoStatus(status)) - } - - var total int64 - if err := db.Count(&total).Error; err != nil { - h.logger.Error("Failed to count videos", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to fetch videos") - return - } - - var videos []*model.Video - if err := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&videos).Error; err != nil { - h.logger.Error("Failed to fetch videos", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to fetch videos") - return - } - - response.Success(c, gin.H{ - "videos": videos, - "total": total, - "page": page, - "limit": limit, - }) -} - -// @Summary Get Video -// @Description Get video details by ID -// @Tags video -// @Produce json -// @Param id path string true "Video ID" -// @Success 200 {object} response.Response{data=model.Video} -// @Failure 404 {object} response.Response -// @Router /videos/{id} [get] -// @Security BearerAuth -func (h *Handler) GetVideo(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - id := c.Param("id") - - h.db.WithContext(c.Request.Context()).Model(&model.Video{}). - Where("id = ? AND user_id = ?", id, userID). - UpdateColumn("views", gorm.Expr("views + ?", 1)) - - var video model.Video - if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND user_id = ?", id, userID).First(&video).Error; err != nil { - if err == gorm.ErrRecordNotFound { - response.Error(c, http.StatusNotFound, "Video not found") - return - } - h.logger.Error("Failed to fetch video", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to fetch video") - return - } - - result := gin.H{"video": &video} - - var adConfig model.VideoAdConfig - if err := h.db.WithContext(c.Request.Context()). - Where("video_id = ? AND user_id = ?", id, userID). - First(&adConfig).Error; err == nil { - adPayload := VideoAdConfigPayload{ - AdTemplateID: adConfig.AdTemplateID, - VASTTagURL: adConfig.VastTagURL, - AdFormat: model.StringValue(adConfig.AdFormat), - Duration: int64PtrToIntPtr(adConfig.Duration), - } - - var template model.AdTemplate - if err := h.db.WithContext(c.Request.Context()). - Where("id = ? AND user_id = ?", adConfig.AdTemplateID, userID). - First(&template).Error; err == nil { - adPayload.TemplateName = template.Name - } - - result["ad_config"] = adPayload - } - - response.Success(c, result) -} - -// @Summary Update Video -// @Description Update title and description for a video owned by the current user -// @Tags video -// @Accept json -// @Produce json -// @Param id path string true "Video ID" -// @Param request body UpdateVideoRequest true "Video payload" -// @Success 200 {object} response.Response -// @Failure 400 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 404 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /videos/{id} [put] -// @Security BearerAuth -func (h *Handler) UpdateVideo(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - id := c.Param("id") - var req UpdateVideoRequest - if err := c.ShouldBindJSON(&req); err != nil { - response.Error(c, http.StatusBadRequest, err.Error()) - return - } - - title := strings.TrimSpace(req.Title) - if title == "" { - response.Error(c, http.StatusBadRequest, "Title is required") - return - } - description := strings.TrimSpace(req.Description) - ctx := c.Request.Context() - - err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - result := tx.Model(&model.Video{}). - Where("id = ? AND user_id = ?", id, userID). - Updates(map[string]interface{}{ - "name": title, - "title": title, - "description": stringPointer(description), - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return gorm.ErrRecordNotFound - } - - if req.AdTemplateID != nil { - templateID := strings.TrimSpace(*req.AdTemplateID) - - if templateID == "" { - tx.Where("video_id = ? AND user_id = ?", id, userID).Delete(&model.VideoAdConfig{}) - } else { - var template model.AdTemplate - if err := tx.Where("id = ? AND user_id = ?", templateID, userID). - First(&template).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return &videoError{Code: http.StatusBadRequest, Message: "Ad template not found"} - } - return err - } - - var existing model.VideoAdConfig - if err := tx.Where("video_id = ? AND user_id = ?", id, userID). - First(&existing).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - newConfig := &model.VideoAdConfig{ - VideoID: id, - UserID: userID, - AdTemplateID: template.ID, - VastTagURL: template.VastTagURL, - AdFormat: template.AdFormat, - Duration: template.Duration, - } - return tx.Create(newConfig).Error - } - return err - } - - existing.AdTemplateID = template.ID - existing.VastTagURL = template.VastTagURL - existing.AdFormat = template.AdFormat - existing.Duration = template.Duration - return tx.Save(&existing).Error - } - } - - return nil - }) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - response.Error(c, http.StatusNotFound, "Video not found") - return - } - var ve *videoError - if errors.As(err, &ve) { - response.Error(c, ve.Code, ve.Message) - return - } - h.logger.Error("Failed to update video", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to update video") - return - } - - var video model.Video - if err := h.db.WithContext(ctx).Where("id = ? AND user_id = ?", id, userID).First(&video).Error; err != nil { - h.logger.Error("Failed to reload video", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to update video") - return - } - - resp := gin.H{"video": &video} - - var adConfig model.VideoAdConfig - if err := h.db.WithContext(ctx). - Where("video_id = ? AND user_id = ?", id, userID). - First(&adConfig).Error; err == nil { - adPayload := VideoAdConfigPayload{ - AdTemplateID: adConfig.AdTemplateID, - VASTTagURL: adConfig.VastTagURL, - AdFormat: model.StringValue(adConfig.AdFormat), - Duration: int64PtrToIntPtr(adConfig.Duration), - } - - var template model.AdTemplate - if err := h.db.WithContext(ctx). - Where("id = ? AND user_id = ?", adConfig.AdTemplateID, userID). - First(&template).Error; err == nil { - adPayload.TemplateName = template.Name - } - - resp["ad_config"] = adPayload - } - - response.Success(c, resp) -} - -// @Summary Delete Video -// @Description Delete a video owned by the current user -// @Tags video -// @Produce json -// @Param id path string true "Video ID" -// @Success 200 {object} response.Response -// @Failure 401 {object} response.Response -// @Failure 404 {object} response.Response -// @Failure 500 {object} response.Response -// @Router /videos/{id} [delete] -// @Security BearerAuth -func (h *Handler) DeleteVideo(c *gin.Context) { - userID := c.GetString("userID") - if userID == "" { - response.Error(c, http.StatusUnauthorized, "Unauthorized") - return - } - - id := c.Param("id") - var video model.Video - if err := h.db.WithContext(c.Request.Context()).Where("id = ? AND user_id = ?", id, userID).First(&video).Error; err != nil { - if err == gorm.ErrRecordNotFound { - response.Error(c, http.StatusNotFound, "Video not found") - return - } - h.logger.Error("Failed to load video for deletion", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to delete video") - return - } - - if h.storage != nil && shouldDeleteStoredObject(video.URL) { - if err := h.storage.Delete(video.URL); err != nil { - parsedKey := extractObjectKey(video.URL) - if parsedKey != "" && parsedKey != video.URL { - if deleteErr := h.storage.Delete(parsedKey); deleteErr != nil { - h.logger.Error("Failed to delete video object", "error", deleteErr, "video_id", video.ID) - response.Error(c, http.StatusInternalServerError, "Failed to delete video") - return - } - } else { - h.logger.Error("Failed to delete video object", "error", err, "video_id", video.ID) - response.Error(c, http.StatusInternalServerError, "Failed to delete video") - return - } - } - } - - if err := h.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error { - if err := tx.Where("video_id = ? AND user_id = ?", video.ID, userID).Delete(&model.VideoAdConfig{}).Error; err != nil { - return err - } - if err := tx.Where("id = ? AND user_id = ?", video.ID, userID).Delete(&model.Video{}).Error; err != nil { - return err - } - if err := tx.Model(&model.User{}). - Where("id = ?", userID). - UpdateColumn("storage_used", gorm.Expr("storage_used - ?", video.Size)).Error; err != nil { - return err - } - return nil - }); err != nil { - h.logger.Error("Failed to delete video record", "error", err) - response.Error(c, http.StatusInternalServerError, "Failed to delete video") - return - } - - response.Success(c, gin.H{"message": "Video deleted successfully"}) -} - -func normalizeVideoStatus(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 - } - if parsed.Scheme == "" && parsed.Host == "" { - return strings.TrimPrefix(parsed.Path, "/") - } - return strings.TrimPrefix(parsed.Path, "/") -} - -func stringPointer(value string) *string { - if value == "" { - return nil - } - return &value -} - -func int64PtrToIntPtr(value *int64) *int { - if value == nil { - return nil - } - converted := int(*value) - return &converted -} diff --git a/internal/api/video/interface.go b/internal/api/video/interface.go deleted file mode 100644 index 4de5f16..0000000 --- a/internal/api/video/interface.go +++ /dev/null @@ -1,47 +0,0 @@ -//go:build ignore -// +build ignore - -package video - -import "github.com/gin-gonic/gin" - -// VideoHandler defines the interface for video operations -type VideoHandler interface { - GetUploadURL(c *gin.Context) - CreateVideo(c *gin.Context) - ListVideos(c *gin.Context) - GetVideo(c *gin.Context) - UpdateVideo(c *gin.Context) - DeleteVideo(c *gin.Context) -} - -// UploadURLRequest defines the payload for requesting an upload URL -type UploadURLRequest struct { - Filename string `json:"filename" binding:"required"` - ContentType string `json:"content_type" binding:"required"` - Size int64 `json:"size" binding:"required"` -} - -// CreateVideoRequest defines the payload for creating a video metadata record -type CreateVideoRequest struct { - Title string `json:"title" binding:"required"` - Description string `json:"description"` - URL string `json:"url" binding:"required"` // The S3 Key or Full URL - Size int64 `json:"size" binding:"required"` - Duration int32 `json:"duration"` // Maybe client knows, or we process later - Format string `json:"format"` -} - -type UpdateVideoRequest struct { - Title string `json:"title" binding:"required"` - Description string `json:"description"` - AdTemplateID *string `json:"ad_template_id,omitempty"` -} - -type VideoAdConfigPayload struct { - AdTemplateID string `json:"ad_template_id"` - TemplateName string `json:"template_name,omitempty"` - VASTTagURL string `json:"vast_tag_url,omitempty"` - AdFormat string `json:"ad_format,omitempty"` - Duration *int `json:"duration,omitempty"` -} diff --git a/internal/app/app.go b/internal/app/app.go deleted file mode 100644 index 3bb22ab..0000000 --- a/internal/app/app.go +++ /dev/null @@ -1,219 +0,0 @@ -//go:build ignore -// +build ignore - -package app - -import ( - "context" - "net/http" - - "github.com/gin-contrib/cors" - "github.com/gin-gonic/gin" - "gorm.io/gorm" - "stream.api/internal/api/admin" - "stream.api/internal/api/adtemplates" - "stream.api/internal/api/auth" - "stream.api/internal/api/domains" - "stream.api/internal/api/notifications" - "stream.api/internal/api/payment" - "stream.api/internal/api/plan" - "stream.api/internal/api/preferences" - "stream.api/internal/api/usage" - "stream.api/internal/api/video" - "stream.api/internal/config" - "stream.api/internal/middleware" - videoruntime "stream.api/internal/video/runtime" - "stream.api/pkg/cache" - "stream.api/pkg/logger" - "stream.api/pkg/storage" - "stream.api/pkg/token" - - swaggerFiles "github.com/swaggo/files" - ginSwagger "github.com/swaggo/gin-swagger" -) - -func SetupRouter(cfg *config.Config, db *gorm.DB, c cache.Cache, t token.Provider, l logger.Logger) (*gin.Engine, *videoruntime.Module, error) { - if cfg.Server.Mode == "release" { - gin.SetMode(gin.ReleaseMode) - } - - r := gin.New() - - // Global Middleware - r.Use(gin.Logger()) - r.Use(middleware.Recovery()) // Custom Recovery with JSON response - r.Use(middleware.ErrorHandler()) // Handle c.Errors - // CORS Middleware - r.Use(cors.New(cors.Config{ - AllowOrigins: cfg.CORS.AllowOrigins, - AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Origin", "Authorization", "Content-Type"}, - ExposeHeaders: []string{"Content-Length"}, - AllowCredentials: true, - })) - // Only enable Swagger in non-release mode - if cfg.Server.Mode != "release" { - r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) - } - - // Global Middleware (Logger, Recovery are default) - - // Health check - r.GET("/health", func(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ - "status": "up", - }) - }) - - // Auth Handler - authHandler := auth.NewHandler(c, t, l, cfg, db) - // api := r.Group("/v") - authGroup := r.Group("/auth") - { - authGroup.POST("/login", authHandler.Login) - authGroup.POST("/register", authHandler.Register) - authGroup.POST("/forgot-password", authHandler.ForgotPassword) - authGroup.POST("/reset-password", authHandler.ResetPassword) - authGroup.GET("/google/login", authHandler.LoginGoogle) - authGroup.GET("/google/callback", authHandler.GoogleCallback) - } - - // Auth Middleware - authMiddleware := middleware.NewAuthMiddleware(c, t, cfg, db, l) - - // Init Storage Provider (S3) - s3Provider, err := storage.NewS3Provider(cfg) - if err != nil { - l.Error("Failed to initialize S3 provider", "error", err) - // We might want to panic or continue with warning depending on criticality. - // For now, let's log and proceed, but video uploads will fail. - } - - // Handlers - planHandler := plan.NewHandler(l, cfg, db) - paymentHandler := payment.NewHandler(l, cfg, db) - usageHandler := usage.NewHandler(l, db) - videoHandler := video.NewHandler(l, cfg, db, s3Provider) - preferencesHandler := preferences.NewHandler(l, db) - notificationHandler := notifications.NewHandler(l, db) - domainHandler := domains.NewHandler(l, db) - adTemplateHandler := adtemplates.NewHandler(l, db) - - // Example protected group - protected := r.Group("") - protected.Use(authMiddleware.Handle()) - { - protected.GET("/me", authHandler.GetMe) - protected.PUT("/me", authHandler.UpdateMe) - protected.DELETE("/me", authHandler.DeleteMe) - protected.POST("/me/clear-data", authHandler.ClearMyData) - protected.POST("/auth/logout", authHandler.Logout) - protected.POST("/auth/change-password", authHandler.ChangePassword) - - preferences := protected.Group("/settings/preferences") - preferences.GET("", preferencesHandler.GetPreferences) - preferences.PUT("", preferencesHandler.UpdatePreferences) - - notifications := protected.Group("/notifications") - notifications.GET("", notificationHandler.ListNotifications) - notifications.POST("/:id/read", notificationHandler.MarkRead) - notifications.POST("/read-all", notificationHandler.MarkAllRead) - notifications.DELETE("/:id", notificationHandler.DeleteNotification) - notifications.DELETE("", notificationHandler.ClearNotifications) - - domains := protected.Group("/domains") - domains.GET("", domainHandler.ListDomains) - domains.POST("", domainHandler.CreateDomain) - domains.DELETE("/:id", domainHandler.DeleteDomain) - - adTemplates := protected.Group("/ad-templates") - adTemplates.GET("", adTemplateHandler.ListTemplates) - adTemplates.POST("", adTemplateHandler.CreateTemplate) - adTemplates.PUT("/:id", adTemplateHandler.UpdateTemplate) - adTemplates.DELETE("/:id", adTemplateHandler.DeleteTemplate) - - // Plans - plans := protected.Group("/plans") - plans.GET("", planHandler.ListPlans) - - // Payments - payments := protected.Group("/payments") - payments.POST("", paymentHandler.CreatePayment) - payments.GET("/history", paymentHandler.ListPaymentHistory) - payments.GET("/:id/invoice", paymentHandler.DownloadInvoice) - wallet := protected.Group("/wallet") - wallet.POST("/topups", paymentHandler.TopupWallet) - - protected.GET("/usage", usageHandler.GetUsage) - - // Videos - video := protected.Group("/videos") - video.POST("/upload-url", videoHandler.GetUploadURL) - video.POST("", videoHandler.CreateVideo) - video.GET("", videoHandler.ListVideos) - video.GET("/:id", videoHandler.GetVideo) - video.PUT("/:id", videoHandler.UpdateVideo) - video.DELETE("/:id", videoHandler.DeleteVideo) - } - - renderModule, err := videoruntime.NewModule(context.Background(), cfg, db, c, t, l) - if err != nil { - return nil, nil, err - } - - r.Use(videoruntime.MetricsMiddleware()) - r.GET("/health/live", renderModule.HandleLive) - r.GET("/health/ready", renderModule.HandleReady) - r.GET("/health/detailed", renderModule.HandleDetailed) - r.GET("/metrics", renderModule.MetricsHandler()) - - // Admin routes — require auth + admin role - adminHandler := admin.NewHandler(l, db, renderModule) - adminGroup := r.Group("/admin") - adminGroup.Use(authMiddleware.Handle()) - adminGroup.Use(middleware.RequireAdmin()) - { - adminGroup.GET("/dashboard", adminHandler.Dashboard) - - adminGroup.GET("/users", adminHandler.ListUsers) - adminGroup.POST("/users", adminHandler.CreateUser) - adminGroup.GET("/users/:id", adminHandler.GetUser) - adminGroup.PUT("/users/:id", adminHandler.UpdateUser) - adminGroup.PUT("/users/:id/role", adminHandler.UpdateUserRole) - adminGroup.DELETE("/users/:id", adminHandler.DeleteUser) - - adminGroup.GET("/videos", adminHandler.ListVideos) - adminGroup.POST("/videos", adminHandler.CreateVideo) - adminGroup.GET("/videos/:id", adminHandler.GetVideo) - adminGroup.PUT("/videos/:id", adminHandler.UpdateVideo) - adminGroup.DELETE("/videos/:id", adminHandler.DeleteVideo) - - adminGroup.GET("/payments", adminHandler.ListPayments) - adminGroup.POST("/payments", adminHandler.CreatePayment) - adminGroup.GET("/payments/:id", adminHandler.GetPayment) - adminGroup.PUT("/payments/:id", adminHandler.UpdatePayment) - - adminGroup.GET("/plans", adminHandler.ListPlans) - adminGroup.POST("/plans", adminHandler.CreatePlan) - adminGroup.PUT("/plans/:id", adminHandler.UpdatePlan) - adminGroup.DELETE("/plans/:id", adminHandler.DeletePlan) - - adminGroup.GET("/ad-templates", adminHandler.ListAdTemplates) - adminGroup.POST("/ad-templates", adminHandler.CreateAdTemplate) - adminGroup.GET("/ad-templates/:id", adminHandler.GetAdTemplate) - adminGroup.PUT("/ad-templates/:id", adminHandler.UpdateAdTemplate) - adminGroup.DELETE("/ad-templates/:id", adminHandler.DeleteAdTemplate) - - adminGroup.GET("/jobs", adminHandler.ListJobs) - adminGroup.POST("/jobs", adminHandler.CreateJob) - adminGroup.GET("/jobs/:id", adminHandler.GetJob) - adminGroup.GET("/jobs/:id/logs", adminHandler.GetJobLogs) - adminGroup.POST("/jobs/:id/cancel", adminHandler.CancelJob) - adminGroup.POST("/jobs/:id/retry", adminHandler.RetryJob) - adminGroup.GET("/agents", adminHandler.ListAgents) - adminGroup.POST("/agents/:id/restart", adminHandler.RestartAgent) - adminGroup.POST("/agents/:id/update", adminHandler.UpdateAgent) - } - - return r, renderModule, nil -} diff --git a/internal/app/grpc.go b/internal/app/grpc.go new file mode 100644 index 0000000..005609e --- /dev/null +++ b/internal/app/grpc.go @@ -0,0 +1,76 @@ +package app + +import ( + "context" + "net" + + grpcpkg "google.golang.org/grpc" + "gorm.io/gorm" + "stream.api/internal/config" + apprpc "stream.api/internal/rpc/app" + "stream.api/internal/video" + runtime "stream.api/internal/video/runtime" + redisadapter "stream.api/internal/video/runtime/adapters/queue/redis" + runtimegrpc "stream.api/internal/video/runtime/grpc" + "stream.api/internal/video/runtime/services" + "stream.api/pkg/cache" + "stream.api/pkg/logger" + "stream.api/pkg/token" +) + +type GRPCModule struct { + jobService *services.JobService + healthService *services.HealthService + agentRuntime *runtimegrpc.Server + mqttPublisher *runtime.MQTTBootstrap + grpcServer *grpcpkg.Server + cfg *config.Config +} + +func NewGRPCModule(ctx context.Context, cfg *config.Config, db *gorm.DB, cacheClient cache.Cache, tokenProvider token.Provider, appLogger logger.Logger) (*GRPCModule, error) { + adapter, err := redisadapter.NewAdapter(cfg.Redis.Addr, cfg.Redis.Password, cfg.Redis.DB) + if err != nil { + return nil, err + } + jobService := services.NewJobService(adapter, adapter) + healthService := services.NewHealthService(db, adapter.Client(), cfg.Render.ServiceName) + agentRuntime := runtimegrpc.NewServer(jobService, cfg.Render.AgentSecret) + videoService := video.NewService(db, jobService) + grpcServer := grpcpkg.NewServer() + + module := &GRPCModule{ + jobService: jobService, + healthService: healthService, + agentRuntime: agentRuntime, + grpcServer: grpcServer, + cfg: cfg, + } + + if publisher, err := runtime.NewMQTTBootstrap(jobService, agentRuntime, appLogger); err != nil { + appLogger.Error("Failed to initialize MQTT publisher", "error", err) + } else { + module.mqttPublisher = publisher + agentRuntime.SetAgentEventHandler(func(eventType string, agent *services.AgentWithStats) { + runtime.PublishAgentMQTTEvent(publisher.Client(), appLogger, eventType, agent) + }) + } + + agentRuntime.Register(grpcServer) + apprpc.Register(grpcServer, apprpc.NewServices(cacheClient, tokenProvider, db, appLogger, cfg, videoService, agentRuntime)) + if module.mqttPublisher != nil { + module.mqttPublisher.Start(ctx) + } + + return module, nil +} + +func (m *GRPCModule) JobService() *services.JobService { return m.jobService } +func (m *GRPCModule) AgentRuntime() *runtimegrpc.Server { return m.agentRuntime } +func (m *GRPCModule) GRPCServer() *grpcpkg.Server { return m.grpcServer } +func (m *GRPCModule) GRPCAddress() string { return ":" + m.cfg.Server.GRPCPort } +func (m *GRPCModule) ServeGRPC(listener net.Listener) error { return m.grpcServer.Serve(listener) } +func (m *GRPCModule) Shutdown() { + if m.grpcServer != nil { + m.grpcServer.GracefulStop() + } +} diff --git a/internal/database/model/common.go b/internal/database/model/common.go new file mode 100644 index 0000000..113dfab --- /dev/null +++ b/internal/database/model/common.go @@ -0,0 +1,110 @@ +package model + +import ( + "context" + "strings" + "time" + + "gorm.io/gorm" +) + +// BoolPtr returns a pointer to the given bool value +func BoolPtr(b bool) *bool { + return &b +} + +// StringPtr returns a pointer to the given string value +func StringPtr(s string) *string { + return &s +} + +// StringValue returns the string value or empty string if nil +func StringValue(s *string) string { + if s == nil { + return "" + } + return *s +} + +// BoolValue returns the bool value or false if nil +func BoolValue(b *bool) bool { + if b == nil { + return false + } + return *b +} + +// Int64Ptr returns a pointer to the given int64 value +func Int64Ptr(i int64) *int64 { + return &i +} + +// Float64Ptr returns a pointer to the given float64 value +func Float64Ptr(f float64) *float64 { + return &f +} + +// TimePtr returns a pointer to the given time.Time value +func TimePtr(t time.Time) *time.Time { + return &t +} + +// FindOrCreateUserPreference finds or creates a user preference record +func FindOrCreateUserPreference(ctx context.Context, db *gorm.DB, userID string) (*UserPreference, error) { + pref := &UserPreference{} + err := db.WithContext(ctx). + Where("user_id = ?", userID). + Attrs(&UserPreference{ + Language: StringPtr("en"), + Locale: StringPtr("en"), + EmailNotifications: BoolPtr(true), + PushNotifications: BoolPtr(true), + MarketingNotifications: false, + TelegramNotifications: false, + }). + FirstOrCreate(pref).Error + + // Handle race condition: if duplicate key error, fetch the existing record + if err != nil && strings.Contains(err.Error(), "duplicate key") { + err = db.WithContext(ctx).Where("user_id = ?", userID).First(pref).Error + } + + return pref, err +} + +// GetWalletBalance calculates the current wallet balance for a user +func GetWalletBalance(ctx context.Context, db *gorm.DB, userID string) (float64, error) { + var balance float64 + if err := db.WithContext(ctx). + Model(&WalletTransaction{}). + Where("user_id = ?", userID). + Select("COALESCE(SUM(amount), 0)"). + Scan(&balance).Error; err != nil { + return 0, err + } + return balance, nil +} + +// GetLatestPlanSubscription finds the latest plan subscription for a user +func GetLatestPlanSubscription(ctx context.Context, db *gorm.DB, userID string) (*PlanSubscription, error) { + sub := &PlanSubscription{} + err := db.WithContext(ctx). + Where("user_id = ?", userID). + Order("expires_at DESC"). + First(sub).Error + if err != nil { + return nil, err + } + return sub, nil +} + +// IsSubscriptionExpiringSoon checks if subscription expires within threshold days +func IsSubscriptionExpiringSoon(sub *PlanSubscription, thresholdDays int) bool { + if sub == nil { + return false + } + now := time.Now() + hoursUntilExpiry := sub.ExpiresAt.Sub(now).Hours() + thresholdHours := float64(thresholdDays) * 24 + return hoursUntilExpiry > 0 && hoursUntilExpiry <= thresholdHours +} diff --git a/internal/database/model/helpers.go b/internal/database/model/helpers.go deleted file mode 100644 index 8815fa6..0000000 --- a/internal/database/model/helpers.go +++ /dev/null @@ -1,138 +0,0 @@ -package model - -import ( - "context" - "errors" - "strings" - "time" - - "gorm.io/gorm" -) - -const ( - defaultPreferenceLanguage = "en" - defaultPreferenceLocale = "en" -) - -func DefaultUserPreference(userID string) *UserPreference { - return &UserPreference{ - UserID: userID, - Language: StringPtr(defaultPreferenceLanguage), - Locale: StringPtr(defaultPreferenceLocale), - EmailNotifications: BoolPtr(true), - PushNotifications: BoolPtr(true), - MarketingNotifications: false, - TelegramNotifications: false, - Autoplay: false, - Loop: false, - Muted: false, - ShowControls: BoolPtr(true), - Pip: BoolPtr(true), - Airplay: BoolPtr(true), - Chromecast: BoolPtr(true), - } -} - -func FindOrCreateUserPreference(ctx context.Context, db *gorm.DB, userID string) (*UserPreference, error) { - var pref UserPreference - if err := db.WithContext(ctx).Where("user_id = ?", userID).First(&pref).Error; err == nil { - normalizeUserPreferenceDefaults(&pref) - return &pref, nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - - pref = *DefaultUserPreference(userID) - if err := db.WithContext(ctx).Create(&pref).Error; err != nil { - return nil, err - } - return &pref, nil -} - -func GetWalletBalance(ctx context.Context, db *gorm.DB, userID string) (float64, error) { - var balance float64 - if err := db.WithContext(ctx). - Model(&WalletTransaction{}). - Where("user_id = ?", userID). - Select("COALESCE(SUM(amount), 0)"). - Scan(&balance).Error; err != nil { - return 0, err - } - return balance, nil -} - -func GetLatestPlanSubscription(ctx context.Context, db *gorm.DB, userID string) (*PlanSubscription, error) { - userID = strings.TrimSpace(userID) - if userID == "" { - return nil, gorm.ErrRecordNotFound - } - - var subscription PlanSubscription - if err := db.WithContext(ctx). - Where("user_id = ?", userID). - Order("created_at DESC"). - Order("id DESC"). - First(&subscription).Error; err != nil { - return nil, err - } - - return &subscription, nil -} - -func IsSubscriptionExpiringSoon(expiresAt time.Time, now time.Time) bool { - if expiresAt.IsZero() || !expiresAt.After(now) { - return false - } - return expiresAt.Sub(now) <= 7*24*time.Hour -} - -func normalizeUserPreferenceDefaults(pref *UserPreference) { - if pref == nil { - return - } - if strings.TrimSpace(StringValue(pref.Language)) == "" { - pref.Language = StringPtr(defaultPreferenceLanguage) - } - if strings.TrimSpace(StringValue(pref.Locale)) == "" { - locale := StringValue(pref.Language) - if strings.TrimSpace(locale) == "" { - locale = defaultPreferenceLocale - } - pref.Locale = StringPtr(locale) - } - if pref.EmailNotifications == nil { - pref.EmailNotifications = BoolPtr(true) - } - if pref.PushNotifications == nil { - pref.PushNotifications = BoolPtr(true) - } - if pref.ShowControls == nil { - pref.ShowControls = BoolPtr(true) - } - if pref.Pip == nil { - pref.Pip = BoolPtr(true) - } - if pref.Airplay == nil { - pref.Airplay = BoolPtr(true) - } - if pref.Chromecast == nil { - pref.Chromecast = BoolPtr(true) - } -} - -func StringPtr(value string) *string { - v := value - return &v -} - -func BoolPtr(value bool) *bool { - v := value - return &v -} - -func StringValue(value *string) string { - if value == nil { - return "" - } - return *value -} diff --git a/internal/database/model/player_configs.gen.go b/internal/database/model/player_configs.gen.go new file mode 100644 index 0000000..6184078 --- /dev/null +++ b/internal/database/model/player_configs.gen.go @@ -0,0 +1,38 @@ +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. +// Code generated by gorm.io/gen. DO NOT EDIT. + +package model + +import ( + "time" +) + +const TableNamePlayerConfig = "player_configs" + +// PlayerConfig mapped from table +type PlayerConfig struct { + ID string `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + UserID string `gorm:"column:user_id;type:uuid;not null;uniqueIndex:idx_player_configs_one_default_per_user,priority:1;index:idx_player_configs_user_default,priority:2;index:idx_player_configs_user_id,priority:1" json:"user_id"` + Name string `gorm:"column:name;type:text;not null" json:"name"` + Description *string `gorm:"column:description;type:text" json:"description"` + Autoplay bool `gorm:"column:autoplay;type:boolean;not null" json:"autoplay"` + Loop bool `gorm:"column:loop;type:boolean;not null" json:"loop"` + Muted bool `gorm:"column:muted;type:boolean;not null" json:"muted"` + ShowControls *bool `gorm:"column:show_controls;type:boolean;not null;default:true" json:"show_controls"` + Pip *bool `gorm:"column:pip;type:boolean;not null;default:true" json:"pip"` + Airplay *bool `gorm:"column:airplay;type:boolean;not null;default:true" json:"airplay"` + Chromecast *bool `gorm:"column:chromecast;type:boolean;not null;default:true" json:"chromecast"` + IsActive *bool `gorm:"column:is_active;type:boolean;not null;default:true" json:"is_active"` + IsDefault bool `gorm:"column:is_default;type:boolean;not null;index:idx_player_configs_is_default,priority:1;index:idx_player_configs_user_default,priority:1" json:"is_default"` + CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"` + Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"` + EncrytionM3u8 *bool `gorm:"column:encrytion_m3u8;type:boolean;not null;default:true" json:"encrytion_m3u8"` + LogoURL *string `gorm:"column:logo_url;type:character varying(500)" json:"logo_url"` +} + +// TableName PlayerConfig's table name +func (*PlayerConfig) TableName() string { + return TableNamePlayerConfig +} diff --git a/internal/database/model/user.gen.go b/internal/database/model/user.gen.go index 7637603..fec9e77 100644 --- a/internal/database/model/user.gen.go +++ b/internal/database/model/user.gen.go @@ -12,18 +12,25 @@ const TableNameUser = "user" // User mapped from table type User struct { - ID string `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` - Email string `gorm:"column:email;type:text;not null;uniqueIndex:user_email_key,priority:1" json:"email"` - Password *string `gorm:"column:password;type:text" json:"-"` - Username *string `gorm:"column:username;type:text" json:"username"` - Avatar *string `gorm:"column:avatar;type:text" json:"avatar"` - Role *string `gorm:"column:role;type:character varying(20);not null;default:USER" json:"role"` - GoogleID *string `gorm:"column:google_id;type:text;uniqueIndex:user_google_id_key,priority:1" json:"google_id"` - StorageUsed int64 `gorm:"column:storage_used;type:bigint;not null" json:"storage_used"` - PlanID *string `gorm:"column:plan_id;type:uuid" json:"plan_id"` - CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"` - UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"` - Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"` + ID string `gorm:"column:id;type:uuid;primaryKey;default:gen_random_uuid()" json:"id"` + Email string `gorm:"column:email;type:text;not null;uniqueIndex:user_email_key,priority:1" json:"email"` + Password *string `gorm:"column:password;type:text" json:"-"` + Username *string `gorm:"column:username;type:text" json:"username"` + Avatar *string `gorm:"column:avatar;type:text" json:"avatar"` + Role *string `gorm:"column:role;type:character varying(20);not null;default:USER" json:"role"` + GoogleID *string `gorm:"column:google_id;type:text;uniqueIndex:user_google_id_key,priority:1" json:"google_id"` + StorageUsed int64 `gorm:"column:storage_used;type:bigint;not null" json:"storage_used"` + PlanID *string `gorm:"column:plan_id;type:uuid" json:"plan_id"` + ReferredByUserID *string `gorm:"column:referred_by_user_id;type:uuid;index:idx_user_referred_by_user_id,priority:1" json:"referred_by_user_id"` + ReferralEligible *bool `gorm:"column:referral_eligible;type:boolean;not null;default:true" json:"referral_eligible"` + ReferralRewardBps *int32 `gorm:"column:referral_reward_bps;type:integer" json:"referral_reward_bps"` + ReferralRewardGrantedAt *time.Time `gorm:"column:referral_reward_granted_at;type:timestamp with time zone" json:"referral_reward_granted_at"` + ReferralRewardPaymentID *string `gorm:"column:referral_reward_payment_id;type:uuid" json:"referral_reward_payment_id"` + ReferralRewardAmount *float64 `gorm:"column:referral_reward_amount;type:numeric(65,30)" json:"referral_reward_amount"` + CreatedAt *time.Time `gorm:"column:created_at;type:timestamp(3) without time zone;not null;default:CURRENT_TIMESTAMP" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamp(3) without time zone;not null" json:"updated_at"` + Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"` + TelegramID *string `gorm:"column:telegram_id;type:character varying" json:"telegram_id"` } // TableName User's table name diff --git a/internal/database/model/user_preferences.gen.go b/internal/database/model/user_preferences.gen.go index c2b5e2a..a301d56 100644 --- a/internal/database/model/user_preferences.gen.go +++ b/internal/database/model/user_preferences.gen.go @@ -19,16 +19,8 @@ type UserPreference struct { PushNotifications *bool `gorm:"column:push_notifications;type:boolean;not null;default:true" json:"push_notifications"` MarketingNotifications bool `gorm:"column:marketing_notifications;type:boolean;not null" json:"marketing_notifications"` TelegramNotifications bool `gorm:"column:telegram_notifications;type:boolean;not null" json:"telegram_notifications"` - Autoplay bool `gorm:"column:autoplay;type:boolean;not null" json:"autoplay"` - Loop bool `gorm:"column:loop;type:boolean;not null" json:"loop"` - Muted bool `gorm:"column:muted;type:boolean;not null" json:"muted"` - ShowControls *bool `gorm:"column:show_controls;type:boolean;not null;default:true" json:"show_controls"` - Pip *bool `gorm:"column:pip;type:boolean;not null;default:true" json:"pip"` - Airplay *bool `gorm:"column:airplay;type:boolean;not null;default:true" json:"airplay"` - Chromecast *bool `gorm:"column:chromecast;type:boolean;not null;default:true" json:"chromecast"` CreatedAt *time.Time `gorm:"column:created_at;type:timestamp with time zone" json:"created_at"` UpdatedAt *time.Time `gorm:"column:updated_at;type:timestamp with time zone" json:"updated_at"` - EncrytionM3u8 bool `gorm:"column:encrytion_m3u8;type:boolean;not null" json:"encrytion_m3u8"` Version *int64 `gorm:"column:version;type:bigint;not null;default:1;version" json:"-"` } diff --git a/internal/database/model/video.gen.go b/internal/database/model/video.gen.go index 542e7ec..05d2b55 100644 --- a/internal/database/model/video.gen.go +++ b/internal/database/model/video.gen.go @@ -6,31 +6,35 @@ package model import ( "time" + + "gorm.io/datatypes" ) const TableNameVideo = "video" // Video mapped from table