unified-video-framework 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +253 -0
- package/ANDROID_TV_IMPLEMENTATION.md +313 -0
- package/COMPLETION_STATUS.md +165 -0
- package/CONTRIBUTING.md +376 -0
- package/FINAL_STATUS_REPORT.md +170 -0
- package/FRAMEWORK_REVIEW.md +247 -0
- package/IMPROVEMENTS_SUMMARY.md +168 -0
- package/LICENSE +21 -0
- package/NATIVE_APP_INTEGRATION_GUIDE.md +903 -0
- package/PAYWALL_RENTAL_FLOW.md +499 -0
- package/PLATFORM_SETUP_GUIDE.md +1636 -0
- package/README.md +315 -0
- package/RUN_LOCALLY.md +151 -0
- package/apps/demo/cast-sender-min.html +173 -0
- package/apps/demo/custom-player.html +883 -0
- package/apps/demo/demo.html +990 -0
- package/apps/demo/enhanced-player.html +3556 -0
- package/apps/demo/index.html +159 -0
- package/apps/rental-api/.env.example +24 -0
- package/apps/rental-api/README.md +23 -0
- package/apps/rental-api/migrations/001_init.sql +35 -0
- package/apps/rental-api/migrations/002_videos.sql +10 -0
- package/apps/rental-api/migrations/003_add_gateway_subref.sql +4 -0
- package/apps/rental-api/migrations/004_update_gateways.sql +4 -0
- package/apps/rental-api/migrations/005_seed_demo_video.sql +5 -0
- package/apps/rental-api/package-lock.json +2045 -0
- package/apps/rental-api/package.json +33 -0
- package/apps/rental-api/scripts/run-migration.js +42 -0
- package/apps/rental-api/scripts/update-video-currency.js +21 -0
- package/apps/rental-api/scripts/update-video-price.js +19 -0
- package/apps/rental-api/src/config.ts +14 -0
- package/apps/rental-api/src/db.ts +10 -0
- package/apps/rental-api/src/routes/cashfree.ts +167 -0
- package/apps/rental-api/src/routes/pesapal.ts +92 -0
- package/apps/rental-api/src/routes/rentals.ts +242 -0
- package/apps/rental-api/src/routes/webhooks.ts +73 -0
- package/apps/rental-api/src/server.ts +41 -0
- package/apps/rental-api/src/services/entitlements.ts +45 -0
- package/apps/rental-api/src/services/payments.ts +22 -0
- package/apps/rental-api/tsconfig.json +17 -0
- package/check-urls.ps1 +74 -0
- package/comparison-report.md +181 -0
- package/docs/PAYWALL.md +95 -0
- package/docs/PLAYER_UI_VISIBILITY.md +431 -0
- package/docs/README.md +7 -0
- package/docs/SYSTEM_ARCHITECTURE.md +612 -0
- package/docs/VDOCIPHER_CLONE_REQUIREMENTS.md +403 -0
- package/examples/android/JavaSampleApp/MainActivity.java +641 -0
- package/examples/android/JavaSampleApp/activity_main.xml +226 -0
- package/examples/android/SampleApp/MainActivity.kt +430 -0
- package/examples/ios/SampleApp/ViewController.swift +337 -0
- package/examples/ios/SwiftUISampleApp/ContentView.swift +304 -0
- package/iOS_IMPLEMENTATION_OPTIONS.md +470 -0
- package/ios/UnifiedVideoPlayer/UnifiedVideoPlayer.podspec +33 -0
- package/jest.config.js +33 -0
- package/jitpack.yml +5 -0
- package/lerna.json +35 -0
- package/package.json +69 -0
- package/packages/PLATFORM_STATUS.md +163 -0
- package/packages/android/build.gradle +135 -0
- package/packages/android/src/main/AndroidManifest.xml +36 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/PlayerConfiguration.java +221 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.java +1037 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/UnifiedVideoPlayer.kt +707 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/analytics/AnalyticsProvider.java +9 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastManager.java +141 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/cast/CastOptionsProvider.java +29 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/overlay/WatermarkOverlayView.java +88 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/pip/PipActionReceiver.java +33 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/services/PlaybackService.java +110 -0
- package/packages/android/src/main/java/com/unifiedvideo/player/services/PlayerHolder.java +19 -0
- package/packages/core/package.json +34 -0
- package/packages/core/src/BasePlayer.ts +250 -0
- package/packages/core/src/VideoPlayer.ts +237 -0
- package/packages/core/src/VideoPlayerFactory.ts +145 -0
- package/packages/core/src/index.ts +20 -0
- package/packages/core/src/interfaces/IVideoPlayer.ts +184 -0
- package/packages/core/src/interfaces.ts +240 -0
- package/packages/core/src/utils/EventEmitter.ts +66 -0
- package/packages/core/src/utils/PlatformDetector.ts +300 -0
- package/packages/core/tsconfig.json +20 -0
- package/packages/enact/package.json +51 -0
- package/packages/enact/src/VideoPlayer.js +365 -0
- package/packages/enact/src/adapters/TizenAdapter.js +354 -0
- package/packages/enact/src/index.js +82 -0
- package/packages/ios/BUILD_INSTRUCTIONS.md +108 -0
- package/packages/ios/FIX_EMBED_ISSUE.md +142 -0
- package/packages/ios/GETTING_STARTED.md +100 -0
- package/packages/ios/Package.swift +35 -0
- package/packages/ios/README.md +84 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Analytics/AnalyticsEmitter.swift +26 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/DRM/FairPlayDRMManager.swift +102 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Info.plist +24 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Remote/RemoteCommandCenter.swift +109 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayer.swift +811 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/UnifiedVideoPlayerView.swift +640 -0
- package/packages/ios/Sources/UnifiedVideoPlayer/Utilities/Color+Hex.swift +36 -0
- package/packages/ios/UnifiedVideoPlayer.podspec +27 -0
- package/packages/ios/UnifiedVideoPlayer.xcodeproj/project.pbxproj +385 -0
- package/packages/ios/build_framework.sh +55 -0
- package/packages/react-native/android/src/main/java/com/unifiedvideo/UnifiedVideoPlayerModule.kt +482 -0
- package/packages/react-native/ios/UnifiedVideoPlayer.swift +436 -0
- package/packages/react-native/package.json +51 -0
- package/packages/react-native/src/ReactNativePlayer.tsx +423 -0
- package/packages/react-native/src/VideoPlayer.tsx +224 -0
- package/packages/react-native/src/index.ts +28 -0
- package/packages/react-native/src/utils/EventEmitter.ts +66 -0
- package/packages/react-native/tsconfig.json +31 -0
- package/packages/roku/components/UnifiedVideoPlayer.brs +400 -0
- package/packages/roku/package.json +44 -0
- package/packages/roku/source/VideoPlayer.brs +231 -0
- package/packages/roku/source/main.brs +28 -0
- package/packages/web/GETTING_STARTED.md +292 -0
- package/packages/web/jest.config.js +28 -0
- package/packages/web/jest.setup.ts +110 -0
- package/packages/web/package.json +50 -0
- package/packages/web/src/SecureVideoPlayer.ts +1164 -0
- package/packages/web/src/WebPlayer.ts +3110 -0
- package/packages/web/src/__tests__/WebPlayer.test.ts +314 -0
- package/packages/web/src/index.ts +14 -0
- package/packages/web/src/paywall/PaywallController.ts +215 -0
- package/packages/web/src/react/WebPlayerView.tsx +177 -0
- package/packages/web/tsconfig.json +23 -0
- package/packages/web/webpack.config.js +45 -0
- package/server.js +131 -0
- package/server.py +84 -0
- package/test-urls.ps1 +97 -0
- package/test-video-urls.ps1 +87 -0
- package/tsconfig.json +39 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@unified-video/rental-api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/server.js",
|
|
7
|
+
"types": "dist/types.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc -p tsconfig.json",
|
|
10
|
+
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
|
|
11
|
+
"start": "node dist/server.js",
|
|
12
|
+
"migrate": "node ./scripts/run-migration.js"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@types/cors": "^2.8.19",
|
|
16
|
+
"axios": "^1.7.2",
|
|
17
|
+
"body-parser": "^1.20.2",
|
|
18
|
+
"cors": "^2.8.5",
|
|
19
|
+
"dotenv": "^16.4.5",
|
|
20
|
+
"express": "^4.19.2",
|
|
21
|
+
"pg": "^8.11.3",
|
|
22
|
+
"stripe": "^16.6.0",
|
|
23
|
+
"unified-video-framework": "file:../.."
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/body-parser": "^1.19.5",
|
|
27
|
+
"@types/express": "^4.17.21",
|
|
28
|
+
"@types/node": "^20.12.12",
|
|
29
|
+
"@types/pg": "^8.15.5",
|
|
30
|
+
"ts-node-dev": "^2.0.0",
|
|
31
|
+
"typescript": "^5.4.5"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Simple migration runner: applies all SQL files in migrations/ in alphabetical order
|
|
2
|
+
import 'dotenv/config';
|
|
3
|
+
import { readdirSync, readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join, dirname, resolve } from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import { Pool } from 'pg';
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const dbUrl = process.env.DATABASE_URL;
|
|
10
|
+
if (!dbUrl) throw new Error('DATABASE_URL not set');
|
|
11
|
+
const pool = new Pool({ connectionString: dbUrl });
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const candidates = [
|
|
16
|
+
// Repo layout: apps/rental-api/scripts/run-migration.js -> migrations alongside ../migrations
|
|
17
|
+
resolve(__dirname, '..', 'migrations'),
|
|
18
|
+
// Executed from repo root
|
|
19
|
+
resolve(process.cwd(), 'apps', 'rental-api', 'migrations'),
|
|
20
|
+
// Executed from apps/rental-api
|
|
21
|
+
resolve(process.cwd(), 'migrations')
|
|
22
|
+
];
|
|
23
|
+
const dir = candidates.find(p => existsSync(p));
|
|
24
|
+
if (!dir) throw new Error('migrations directory not found');
|
|
25
|
+
|
|
26
|
+
const files = readdirSync(dir).filter(f => f.endsWith('.sql')).sort();
|
|
27
|
+
for (const f of files) {
|
|
28
|
+
const sql = readFileSync(join(dir, f), 'utf8');
|
|
29
|
+
process.stdout.write(`[migrate] applying ${f}...\n`);
|
|
30
|
+
await pool.query(sql);
|
|
31
|
+
}
|
|
32
|
+
process.stdout.write('[migrate] done\n');
|
|
33
|
+
} finally {
|
|
34
|
+
await pool.end();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
main().catch(err => {
|
|
39
|
+
console.error('[migrate] failed', err);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { Pool } from 'pg';
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
const url = process.env.DATABASE_URL;
|
|
6
|
+
if (!url) throw new Error('DATABASE_URL not set');
|
|
7
|
+
const pool = new Pool({ connectionString: url });
|
|
8
|
+
try {
|
|
9
|
+
const sql = `UPDATE videos SET currency=$1, price_cents=$2 WHERE video_id=$3`;
|
|
10
|
+
const res = await pool.query(sql, ['INR', 399, 'v1']);
|
|
11
|
+
console.log(`[update-video] rows affected: ${res.rowCount}`);
|
|
12
|
+
} finally {
|
|
13
|
+
await pool.end();
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
main().catch(err => {
|
|
18
|
+
console.error('[update-video] failed', err);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { Pool } from 'pg';
|
|
3
|
+
|
|
4
|
+
async function main() {
|
|
5
|
+
const url = process.env.DATABASE_URL;
|
|
6
|
+
if (!url) throw new Error('DATABASE_URL not set');
|
|
7
|
+
const pool = new Pool({ connectionString: url });
|
|
8
|
+
const videoId = process.env.VIDEO_ID || 'v1';
|
|
9
|
+
const cents = Number(process.env.NEW_PRICE_CENTS || 9900); // default ₹99.00
|
|
10
|
+
try {
|
|
11
|
+
const res = await pool.query('UPDATE videos SET price_cents=$1 WHERE video_id=$2', [cents, videoId]);
|
|
12
|
+
console.log(`[update-price] rows affected: ${res.rowCount}`);
|
|
13
|
+
} finally {
|
|
14
|
+
await pool.end();
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
main().catch(err => { console.error('[update-price] failed', err); process.exit(1); });
|
|
19
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
|
|
3
|
+
export const config = {
|
|
4
|
+
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
|
5
|
+
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
|
6
|
+
cashfree: {
|
|
7
|
+
appId: process.env.CASHFREE_APP_ID || '',
|
|
8
|
+
secretKey: process.env.CASHFREE_SECRET_KEY || '',
|
|
9
|
+
baseUrl: process.env.CASHFREE_BASE_URL || 'https://sandbox.cashfree.com'
|
|
10
|
+
},
|
|
11
|
+
appBaseUrl: process.env.APP_BASE_URL || 'http://localhost:3000',
|
|
12
|
+
dbUrl: process.env.DATABASE_URL || ''
|
|
13
|
+
};
|
|
14
|
+
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from 'express';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { db } from '../db.js';
|
|
5
|
+
import { upsertPayment } from '../services/payments.js';
|
|
6
|
+
import { issueRentalEntitlement } from '../services/entitlements.js';
|
|
7
|
+
import { randomUUID } from 'crypto';
|
|
8
|
+
|
|
9
|
+
export const cashfreeRouter = Router();
|
|
10
|
+
|
|
11
|
+
// POST /api/rentals/cashfree/order { userId, videoId, returnUrl }
|
|
12
|
+
cashfreeRouter.post('/cashfree/order', async (req: Request, res: Response) => {
|
|
13
|
+
const { userId, videoId, returnUrl, userEmail, userPhone, userName } = req.body || {};
|
|
14
|
+
if (!userId || !videoId || !returnUrl) return res.status(400).json({ error: 'userId, videoId, returnUrl required' });
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
let price_cents = 0;
|
|
18
|
+
let currency = 'INR';
|
|
19
|
+
let rental_duration_hours = 48;
|
|
20
|
+
let title = videoId;
|
|
21
|
+
try {
|
|
22
|
+
const { rows } = await db.query(`SELECT price_cents, currency, rental_duration_hours, title FROM videos WHERE video_id=$1`, [videoId]);
|
|
23
|
+
if (rows[0]) {
|
|
24
|
+
price_cents = Number(rows[0].price_cents || 0);
|
|
25
|
+
currency = String(rows[0].currency || 'INR');
|
|
26
|
+
rental_duration_hours = Number(rows[0].rental_duration_hours || 48);
|
|
27
|
+
title = String(rows[0].title || videoId);
|
|
28
|
+
} else {
|
|
29
|
+
// Fallback demo price when DB has no record
|
|
30
|
+
price_cents = 2500; // ₹25.00
|
|
31
|
+
currency = 'INR';
|
|
32
|
+
rental_duration_hours = 48;
|
|
33
|
+
title = videoId;
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
// Fallback when DB is disabled or not reachable
|
|
37
|
+
price_cents = 2500; // ₹25.00
|
|
38
|
+
currency = 'INR';
|
|
39
|
+
rental_duration_hours = 48;
|
|
40
|
+
title = videoId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// If Cashfree credentials are not configured, fallback to local mock checkout
|
|
44
|
+
if (!config.cashfree.appId || !config.cashfree.secretKey) {
|
|
45
|
+
const host = req.get('host') as string;
|
|
46
|
+
const apiBase = `${req.protocol}://${host}`.replace(/\/$/, '');
|
|
47
|
+
const orderId = 'cf_' + randomUUID();
|
|
48
|
+
const mockUrl = `${apiBase}/api/rentals/mock/checkout?gateway=cashfree&order_id=${orderId}`;
|
|
49
|
+
return res.json({ paymentLink: mockUrl, orderId, rentalDurationHours: Number(rental_duration_hours || 48), title: title || videoId });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const orderId = 'cf_' + randomUUID();
|
|
53
|
+
|
|
54
|
+
// Ensure HTTPS return_url for live Cashfree (production requires https)
|
|
55
|
+
let safeReturnUrl = String(returnUrl || '').trim();
|
|
56
|
+
try {
|
|
57
|
+
const u = new URL(safeReturnUrl || 'https://example.com/');
|
|
58
|
+
const isLive = /api\.cashfree\.com/i.test(config.cashfree.baseUrl || '');
|
|
59
|
+
if (isLive && u.protocol !== 'https:') {
|
|
60
|
+
u.protocol = 'https:';
|
|
61
|
+
}
|
|
62
|
+
safeReturnUrl = u.toString();
|
|
63
|
+
} catch {
|
|
64
|
+
// Fallback placeholder
|
|
65
|
+
safeReturnUrl = 'https://example.com/';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const body = {
|
|
69
|
+
order_id: orderId,
|
|
70
|
+
order_amount: (price_cents / 100).toFixed(2),
|
|
71
|
+
order_currency: (currency || 'INR').toUpperCase(),
|
|
72
|
+
customer_details: {
|
|
73
|
+
customer_id: String(userId),
|
|
74
|
+
customer_phone: String(userPhone || '9999999999'),
|
|
75
|
+
customer_email: String(userEmail || 'demo@example.com'),
|
|
76
|
+
customer_name: String(userName || 'Demo User')
|
|
77
|
+
},
|
|
78
|
+
order_meta: {
|
|
79
|
+
return_url: `${safeReturnUrl}${safeReturnUrl.includes('?') ? '&' : '?'}rental=success&popup=1&order_id=${orderId}`
|
|
80
|
+
}
|
|
81
|
+
} as any;
|
|
82
|
+
|
|
83
|
+
const base = config.cashfree.baseUrl.replace(/\/$/, '');
|
|
84
|
+
const url = `${base}/pg/orders`;
|
|
85
|
+
const headers = {
|
|
86
|
+
'x-client-id': config.cashfree.appId,
|
|
87
|
+
'x-client-secret': config.cashfree.secretKey,
|
|
88
|
+
'x-api-version': '2022-09-01',
|
|
89
|
+
'Content-Type': 'application/json'
|
|
90
|
+
} as any;
|
|
91
|
+
|
|
92
|
+
try { console.log('[cashfree/order] POST', url, JSON.stringify({ ...body, customer_details: { ...body.customer_details, customer_phone: '***' } })); } catch(_) {}
|
|
93
|
+
const resp = await axios.post(url, body, { headers });
|
|
94
|
+
try { console.log('[cashfree/order] resp keys', Object.keys(resp.data || {})); } catch(_) {}
|
|
95
|
+
let paymentLink = resp.data?.payment_link || resp.data?.payment_link_url || resp.data?.payment_url;
|
|
96
|
+
// New PG v2 API returns payment_session_id without a direct link; construct hosted checkout URL
|
|
97
|
+
if (!paymentLink && resp.data?.payment_session_id) {
|
|
98
|
+
const sessionIdRaw = resp.data.payment_session_id;
|
|
99
|
+
let sessionId = typeof sessionIdRaw === 'string' ? sessionIdRaw : String(sessionIdRaw?.id || sessionIdRaw);
|
|
100
|
+
// A few sandbox responses have been observed appending the word "payment" at the end; strip trailing repetitions safely
|
|
101
|
+
sessionId = sessionId.replace(/(payment)+$/i, '');
|
|
102
|
+
try { console.log('[cashfree/order] sessionId', sessionId); } catch(_) {}
|
|
103
|
+
const isLive = /api\.cashfree\.com/i.test(config.cashfree.baseUrl || '');
|
|
104
|
+
// Per Cashfree docs, the hosted page is served from payments.cashfree.com (live) or sandbox.cashfree.com
|
|
105
|
+
const host = isLive ? 'https://payments.cashfree.com' : 'https://sandbox.cashfree.com';
|
|
106
|
+
// Hosted checkout path per Cashfree PG v2 docs
|
|
107
|
+
paymentLink = `${host.replace(/\/$/, '')}/pg/view/checkout?payment_session_id=${encodeURIComponent(sessionId)}`;
|
|
108
|
+
}
|
|
109
|
+
if (!paymentLink) {
|
|
110
|
+
try { console.error('[cashfree/order] response missing payment link', typeof resp.data === 'object' ? JSON.stringify(resp.data) : String(resp.data)); } catch(_) {}
|
|
111
|
+
return res.status(500).json({ error: 'cashfree payment link not returned', details: resp.data });
|
|
112
|
+
}
|
|
113
|
+
// Also return sessionId to support JS SDK (Elements/Dropin) integration
|
|
114
|
+
const sessionIdOut = String(resp.data?.payment_session_id || '').replace(/(payment)+$/i, '');
|
|
115
|
+
return res.json({ paymentLink, orderId, sessionId: sessionIdOut, rentalDurationHours: Number(rental_duration_hours || 48), title: title || videoId });
|
|
116
|
+
} catch (err: any) {
|
|
117
|
+
const status = err?.response?.status;
|
|
118
|
+
const data = err?.response?.data;
|
|
119
|
+
try { console.error('[cashfree/order] error', status, typeof data === 'object' ? JSON.stringify(data) : String(data)); } catch (_) {}
|
|
120
|
+
return res.status(500).json({ error: 'failed to create cashfree order', details: { status, data } });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// GET /api/rentals/cashfree/verify?orderId=&userId=&videoId=&rentalDurationHours=
|
|
125
|
+
cashfreeRouter.get('/cashfree/verify', async (req: Request, res: Response) => {
|
|
126
|
+
const orderId = String(req.query.orderId || '');
|
|
127
|
+
const userId = String(req.query.userId || '');
|
|
128
|
+
const videoId = String(req.query.videoId || '');
|
|
129
|
+
const rentalDurationHours = Number(req.query.rentalDurationHours || 48);
|
|
130
|
+
if (!orderId || !userId || !videoId) return res.status(400).json({ error: 'orderId, userId, videoId required' });
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const base = config.cashfree.baseUrl.replace(/\/$/, '');
|
|
134
|
+
const url = `${base}/pg/orders/${encodeURIComponent(orderId)}`;
|
|
135
|
+
const headers = {
|
|
136
|
+
'x-client-id': config.cashfree.appId,
|
|
137
|
+
'x-client-secret': config.cashfree.secretKey,
|
|
138
|
+
'x-api-version': '2022-09-01'
|
|
139
|
+
} as any;
|
|
140
|
+
const resp = await axios.get(url, { headers });
|
|
141
|
+
const status = (resp.data?.order_status || '').toUpperCase();
|
|
142
|
+
const amount = Math.round(Number(resp.data?.order_amount || 0) * 100);
|
|
143
|
+
const currency = (resp.data?.order_currency || 'INR').toUpperCase();
|
|
144
|
+
|
|
145
|
+
if (status === 'PAID') {
|
|
146
|
+
const pay = await upsertPayment({
|
|
147
|
+
gateway: 'cashfree',
|
|
148
|
+
gatewayRef: orderId,
|
|
149
|
+
amountCents: amount,
|
|
150
|
+
currency,
|
|
151
|
+
status: 'succeeded',
|
|
152
|
+
rawPayload: resp.data,
|
|
153
|
+
userId,
|
|
154
|
+
videoId
|
|
155
|
+
});
|
|
156
|
+
await issueRentalEntitlement({ userId, videoId, paymentId: pay.id, rentalDurationHours });
|
|
157
|
+
return res.json({ ok: true, paid: true });
|
|
158
|
+
}
|
|
159
|
+
return res.json({ ok: true, paid: false, status });
|
|
160
|
+
} catch (err: any) {
|
|
161
|
+
const status = err?.response?.status;
|
|
162
|
+
const data = err?.response?.data;
|
|
163
|
+
try { console.error('[cashfree/verify] error', status, typeof data === 'object' ? JSON.stringify(data) : String(data)); } catch (_) {}
|
|
164
|
+
return res.status(500).json({ error: 'verify failed', details: { status, data } });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from 'express';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
// Pesapal integration removed in favor of Cashfree
|
|
4
|
+
import { config } from '../config.js';
|
|
5
|
+
import { db } from '../db.js';
|
|
6
|
+
import { upsertPayment } from '../services/payments.js';
|
|
7
|
+
import { issueRentalEntitlement } from '../services/entitlements.js';
|
|
8
|
+
import { randomUUID } from 'crypto';
|
|
9
|
+
|
|
10
|
+
// export const pesapalRouter = Router();
|
|
11
|
+
|
|
12
|
+
// POST /api/rentals/pesapal/order { userId, videoId, returnUrl }
|
|
13
|
+
/* pesapalRouter.post('/pesapal/order', async (req: Request, res: Response) => {
|
|
14
|
+
const { userId, videoId, returnUrl } = req.body || {};
|
|
15
|
+
if (!userId || !videoId || !returnUrl) return res.status(400).json({ error: 'userId, videoId, returnUrl required' });
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const token = await pesapalAuthToken();
|
|
19
|
+
|
|
20
|
+
const { rows } = await db.query(
|
|
21
|
+
`SELECT price_cents, currency, rental_duration_hours, title FROM videos WHERE video_id=$1`, [videoId]);
|
|
22
|
+
if (!rows[0]) return res.status(404).json({ error: 'Video not found' });
|
|
23
|
+
|
|
24
|
+
const { price_cents, currency, rental_duration_hours, title } = rows[0];
|
|
25
|
+
|
|
26
|
+
const orderReq = {
|
|
27
|
+
id: randomUUID(),
|
|
28
|
+
currency: (currency || 'USD').toUpperCase(),
|
|
29
|
+
amount: (price_cents / 100).toFixed(2),
|
|
30
|
+
description: `Rent: ${title || videoId}`,
|
|
31
|
+
callback_url: `${config.appBaseUrl}/api/ipn/pesapal/callback`,
|
|
32
|
+
notification_id: 'your-pesapal-ipn-ref',
|
|
33
|
+
billing_address: { email_address: 'customer@example.com' }
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const submit = await axios.post(`${config.pesapal.baseUrl}/api/Transactions/SubmitOrderRequest`,
|
|
37
|
+
orderReq, { headers: { Authorization: `Bearer ${token}` } });
|
|
38
|
+
|
|
39
|
+
return res.json({ redirectUrl: submit.data.redirect_url, orderTrackingId: submit.data.order_tracking_id });
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return res.status(500).json({ error: 'failed to create pesapal order' });
|
|
42
|
+
}
|
|
43
|
+
}); */
|
|
44
|
+
|
|
45
|
+
/* async function pesapalAuthToken(): Promise<string> {
|
|
46
|
+
const res = await axios.post(`${config.pesapal.baseUrl}/api/Auth/RequestToken`, {
|
|
47
|
+
consumer_key: config.pesapal.consumerKey,
|
|
48
|
+
consumer_secret: config.pesapal.consumerSecret
|
|
49
|
+
});
|
|
50
|
+
return res.data.token;
|
|
51
|
+
}
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
// Pesapal IPN target: POST /api/ipn/pesapal/callback
|
|
55
|
+
/* pesapalRouter.post('/pesapal/callback', async (req: Request, res: Response) => {
|
|
56
|
+
const { order_tracking_id, userId, videoId, rentalDurationHours } = req.body || {};
|
|
57
|
+
if (!order_tracking_id || !userId || !videoId) return res.status(400).send('Bad IPN payload');
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const token = await pesapalAuthToken();
|
|
61
|
+
const statusRes = await axios.get(
|
|
62
|
+
`${config.pesapal.baseUrl}/api/Transactions/GetTransactionStatus?orderTrackingId=${order_tracking_id}`,
|
|
63
|
+
{ headers: { Authorization: `Bearer ${token}` } }
|
|
64
|
+
);
|
|
65
|
+
const status = statusRes.data;
|
|
66
|
+
|
|
67
|
+
if (status.payment_status_code === 'COMPLETED' || status.status === 'COMPLETED') {
|
|
68
|
+
const amountCents = Math.round(Number(status.amount) * 100);
|
|
69
|
+
const currency = (status.currency || 'USD').toUpperCase();
|
|
70
|
+
|
|
71
|
+
const payment = await upsertPayment({
|
|
72
|
+
gateway: 'pesapal',
|
|
73
|
+
gatewayRef: order_tracking_id,
|
|
74
|
+
amountCents,
|
|
75
|
+
currency,
|
|
76
|
+
status: 'succeeded',
|
|
77
|
+
rawPayload: status,
|
|
78
|
+
userId,
|
|
79
|
+
videoId
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await issueRentalEntitlement({
|
|
83
|
+
userId, videoId, paymentId: payment.id, rentalDurationHours: Number(rentalDurationHours || 48)
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
res.status(200).send('ok');
|
|
88
|
+
} catch (err) {
|
|
89
|
+
res.status(500).send('IPN handling failed');
|
|
90
|
+
}
|
|
91
|
+
}); */
|
|
92
|
+
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { Router, type Request, type Response } from 'express';
|
|
2
|
+
import Stripe from 'stripe';
|
|
3
|
+
import { config } from '../config.js';
|
|
4
|
+
import { getEntitlement, issueRentalEntitlement } from '../services/entitlements.js';
|
|
5
|
+
import { upsertPayment } from '../services/payments.js';
|
|
6
|
+
import { db } from '../db.js';
|
|
7
|
+
|
|
8
|
+
export const rentalsRouter = Router();
|
|
9
|
+
const stripe = new Stripe(config.stripeSecretKey, { apiVersion: '2024-06-20' });
|
|
10
|
+
|
|
11
|
+
// Simple mock checkout page for local development when Stripe is not configured
|
|
12
|
+
rentalsRouter.get('/mock/checkout', async (_req: Request, res: Response) => {
|
|
13
|
+
res.set('Content-Type', 'text/html');
|
|
14
|
+
return res.send(`<!doctype html>
|
|
15
|
+
<html>
|
|
16
|
+
<head>
|
|
17
|
+
<meta charset="utf-8" />
|
|
18
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
19
|
+
<title>Mock Checkout</title>
|
|
20
|
+
<style>
|
|
21
|
+
body { margin:0; background:#0f0f10; color:#fff; font-family:Arial, sans-serif; display:flex; align-items:center; justify-content:center; height:100vh; }
|
|
22
|
+
.card { background:#141416; border:1px solid rgba(255,255,255,0.12); border-radius:12px; padding:24px; width: min(520px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
|
|
23
|
+
h1 { margin:0 0 8px; font-size:20px; }
|
|
24
|
+
p { margin:0 0 16px; color: rgba(255,255,255,0.75); }
|
|
25
|
+
.actions { display:flex; gap:12px; }
|
|
26
|
+
button { cursor:pointer; border:none; border-radius:8px; padding:10px 16px; }
|
|
27
|
+
.primary { background:linear-gradient(135deg,#ff4d4f,#d9363e); color:#fff; }
|
|
28
|
+
.secondary { background:rgba(255,255,255,0.12); color:#fff; border:1px solid rgba(255,255,255,0.2); }
|
|
29
|
+
</style>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<div class="card">
|
|
33
|
+
<h1>Mock Stripe Checkout</h1>
|
|
34
|
+
<p>This is a simulated checkout used for local development.</p>
|
|
35
|
+
<div class="actions">
|
|
36
|
+
<button class="primary" onclick="complete()">Complete Payment</button>
|
|
37
|
+
<button class="secondary" onclick="cancel()">Cancel</button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
<script>
|
|
41
|
+
function complete(){ try{ window.opener && window.opener.postMessage({ type: 'uvfCheckout', status: 'success' }, '*'); }catch(e){} window.close(); }
|
|
42
|
+
function cancel(){ try{ window.opener && window.opener.postMessage({ type: 'uvfCheckout', status: 'cancel' }, '*'); }catch(e){} window.close(); }
|
|
43
|
+
</script>
|
|
44
|
+
</body>
|
|
45
|
+
</html>`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// POST /api/rentals/stripe/confirm { sessionId }
|
|
49
|
+
rentalsRouter.post('/stripe/confirm', async (req: Request, res: Response) => {
|
|
50
|
+
try {
|
|
51
|
+
if (!config.stripeSecretKey) return res.status(400).json({ error: 'stripe not configured' });
|
|
52
|
+
const { sessionId } = req.body || {};
|
|
53
|
+
if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
|
|
54
|
+
const session = await stripe.checkout.sessions.retrieve(String(sessionId));
|
|
55
|
+
const paid = (session.payment_status === 'paid') || (session.status === 'complete');
|
|
56
|
+
if (!paid) return res.status(400).json({ error: 'not paid', status: session.payment_status || session.status });
|
|
57
|
+
|
|
58
|
+
const userId = (session.metadata?.userId || '').toString();
|
|
59
|
+
const videoId = (session.metadata?.videoId || '').toString();
|
|
60
|
+
const rentalDurationHours = Number(session.metadata?.rentalDurationHours || 48);
|
|
61
|
+
if (!userId || !videoId) return res.status(400).json({ error: 'missing metadata on session' });
|
|
62
|
+
|
|
63
|
+
const amountCents = session.amount_total ?? 0;
|
|
64
|
+
const currency = (session.currency || 'USD').toUpperCase();
|
|
65
|
+
|
|
66
|
+
const payment = await upsertPayment({
|
|
67
|
+
gateway: 'stripe',
|
|
68
|
+
gatewayRef: session.id,
|
|
69
|
+
amountCents: amountCents,
|
|
70
|
+
currency,
|
|
71
|
+
status: 'succeeded',
|
|
72
|
+
rawPayload: session,
|
|
73
|
+
userId,
|
|
74
|
+
videoId
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await issueRentalEntitlement({ userId, videoId, paymentId: payment.id, rentalDurationHours });
|
|
78
|
+
return res.json({ ok: true });
|
|
79
|
+
} catch (err: any) {
|
|
80
|
+
try { console.error('[stripe/confirm] error', err?.message || err); } catch (_) {}
|
|
81
|
+
return res.status(500).json({ error: 'confirm failed', details: err?.message || String(err) });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// GET /api/rentals/config?tenant=&userId=&videoId=
|
|
86
|
+
// Returns a PaywallConfig JSON for the web player
|
|
87
|
+
rentalsRouter.get('/config', async (req: Request, res: Response) => {
|
|
88
|
+
const tenant = String(req.query.tenant || 'demo').toLowerCase();
|
|
89
|
+
const userId = String(req.query.userId || 'u1');
|
|
90
|
+
const videoId = String(req.query.videoId || 'v1');
|
|
91
|
+
|
|
92
|
+
// Basic mock mapping: demo tenants get both gateways
|
|
93
|
+
const gateways = ['stripe','cashfree'];
|
|
94
|
+
const branding = { title: 'Continue watching', description: 'Rent to continue watching this video.' };
|
|
95
|
+
const popup = { width: 1000, height: 800 };
|
|
96
|
+
|
|
97
|
+
// Always point apiBase to this API's origin so the web demo calls the correct server
|
|
98
|
+
const host = req.get('host') as string;
|
|
99
|
+
const apiBase = `${req.protocol}://${host}`;
|
|
100
|
+
|
|
101
|
+
return res.json({ enabled: true, apiBase, userId, videoId, gateways, branding, popup });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// GET /api/rentals/entitlement?userId=&videoId=
|
|
105
|
+
rentalsRouter.get('/entitlement', async (req: Request, res: Response) => {
|
|
106
|
+
const userId = String(req.query.userId || '');
|
|
107
|
+
const videoId = String(req.query.videoId || '');
|
|
108
|
+
if (!userId || !videoId) return res.status(400).json({ error: 'userId and videoId required' });
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const out = await getEntitlement(userId, videoId);
|
|
112
|
+
return res.json(out);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
return res.status(500).json({ error: 'entitlement lookup failed' });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// POST /api/rentals/mock/grant
|
|
119
|
+
// Body: { userId, videoId, rentalDurationHours? }
|
|
120
|
+
rentalsRouter.post('/mock/grant', async (req: Request, res: Response) => {
|
|
121
|
+
if (process.env.ENABLE_DEV_MOCKS !== '1' && process.env.NODE_ENV === 'production') {
|
|
122
|
+
return res.status(403).json({ error: 'mock endpoint disabled' });
|
|
123
|
+
}
|
|
124
|
+
const { userId, videoId, rentalDurationHours } = req.body || {};
|
|
125
|
+
if (!userId || !videoId) return res.status(400).json({ error: 'userId and videoId required' });
|
|
126
|
+
try {
|
|
127
|
+
const { rows } = await db.query(
|
|
128
|
+
`SELECT price_cents, currency, rental_duration_hours FROM videos WHERE video_id=$1`, [videoId]
|
|
129
|
+
);
|
|
130
|
+
const price_cents = rows[0]?.price_cents ?? 0;
|
|
131
|
+
const currency = rows[0]?.currency ?? 'USD';
|
|
132
|
+
const hours = Number(rentalDurationHours || rows[0]?.rental_duration_hours || 48);
|
|
133
|
+
|
|
134
|
+
const pay = await upsertPayment({
|
|
135
|
+
gateway: 'stripe',
|
|
136
|
+
gatewayRef: `mock_${Date.now()}`,
|
|
137
|
+
amountCents: price_cents,
|
|
138
|
+
currency,
|
|
139
|
+
status: 'succeeded',
|
|
140
|
+
rawPayload: { mock: true },
|
|
141
|
+
userId,
|
|
142
|
+
videoId
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const ent = await issueRentalEntitlement({ userId, videoId, paymentId: pay.id, rentalDurationHours: hours });
|
|
146
|
+
return res.json({ ok: true, entitlement: ent });
|
|
147
|
+
} catch (err) {
|
|
148
|
+
return res.status(500).json({ error: 'mock grant failed' });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// POST /api/rentals/mock/revoke
|
|
153
|
+
// Body: { userId, videoId }
|
|
154
|
+
rentalsRouter.post('/mock/revoke', async (req: Request, res: Response) => {
|
|
155
|
+
if (process.env.ENABLE_DEV_MOCKS !== '1' && process.env.NODE_ENV === 'production') {
|
|
156
|
+
return res.status(403).json({ error: 'mock endpoint disabled' });
|
|
157
|
+
}
|
|
158
|
+
const { userId, videoId } = req.body || {};
|
|
159
|
+
if (!userId || !videoId) return res.status(400).json({ error: 'userId and videoId required' });
|
|
160
|
+
try {
|
|
161
|
+
await db.query(
|
|
162
|
+
`UPDATE entitlements SET status='expired', expires_at=LEAST(expires_at, NOW())
|
|
163
|
+
WHERE user_id=$1 AND video_id=$2 AND status='active'`, [userId, videoId]
|
|
164
|
+
);
|
|
165
|
+
return res.json({ ok: true });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return res.status(500).json({ error: 'mock revoke failed' });
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// POST /api/rentals/stripe/checkout-session
|
|
172
|
+
// Body: { userId, videoId, successUrl, cancelUrl }
|
|
173
|
+
rentalsRouter.post('/stripe/checkout-session', async (req: Request, res: Response) => {
|
|
174
|
+
const { userId, videoId, successUrl, cancelUrl } = req.body || {};
|
|
175
|
+
if (!userId || !videoId || !successUrl || !cancelUrl) {
|
|
176
|
+
return res.status(400).json({ error: 'userId, videoId, successUrl, cancelUrl required' });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Derive this API's origin for local mock URLs
|
|
180
|
+
const host = req.get('host') as string;
|
|
181
|
+
const apiBase = `${req.protocol}://${host}`.replace(/\/$/, '');
|
|
182
|
+
|
|
183
|
+
// Defaults for demo when DB is disabled or empty
|
|
184
|
+
let price_cents = 2500; // $25.00
|
|
185
|
+
let currency = 'USD';
|
|
186
|
+
let rental_duration_hours = 48;
|
|
187
|
+
let title = videoId as string;
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const { rows } = await db.query(
|
|
191
|
+
`SELECT price_cents, currency, rental_duration_hours, title FROM videos WHERE video_id=$1`,
|
|
192
|
+
[videoId]
|
|
193
|
+
);
|
|
194
|
+
if (rows[0]) {
|
|
195
|
+
price_cents = Number(rows[0].price_cents ?? price_cents);
|
|
196
|
+
currency = String(rows[0].currency || currency);
|
|
197
|
+
rental_duration_hours = Number(rows[0].rental_duration_hours ?? rental_duration_hours);
|
|
198
|
+
title = String(rows[0].title || title);
|
|
199
|
+
}
|
|
200
|
+
} catch (_) {
|
|
201
|
+
// DB unavailable or disabled: keep defaults
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Fallback to mock checkout when Stripe key is not configured
|
|
205
|
+
if (!config.stripeSecretKey) {
|
|
206
|
+
const mockUrl = `${apiBase}/api/rentals/mock/checkout?videoId=${encodeURIComponent(String(videoId))}&userId=${encodeURIComponent(String(userId))}&amountCents=${price_cents}¤cy=${encodeURIComponent(currency || 'USD')}`;
|
|
207
|
+
return res.json({ url: mockUrl, id: `mock_${Date.now()}` });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
// Ensure successUrl carries the Stripe session placeholder so the client can confirm without webhooks
|
|
212
|
+
const successWithSession = `${successUrl}${successUrl.includes('?') ? '&' : '?'}session_id={CHECKOUT_SESSION_ID}`;
|
|
213
|
+
const session = await stripe.checkout.sessions.create({
|
|
214
|
+
mode: 'payment',
|
|
215
|
+
payment_method_types: ['card'],
|
|
216
|
+
line_items: [{
|
|
217
|
+
price_data: {
|
|
218
|
+
currency: (currency || 'usd').toLowerCase(),
|
|
219
|
+
unit_amount: price_cents,
|
|
220
|
+
product_data: { name: `Rent: ${title || videoId}` }
|
|
221
|
+
},
|
|
222
|
+
quantity: 1
|
|
223
|
+
}],
|
|
224
|
+
success_url: successWithSession,
|
|
225
|
+
cancel_url: cancelUrl,
|
|
226
|
+
metadata: {
|
|
227
|
+
userId, videoId, rentalDurationHours: String(rental_duration_hours || 48)
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return res.json({ url: session.url, id: session.id });
|
|
232
|
+
} catch (e: any) {
|
|
233
|
+
// In dev, gracefully fallback to mock checkout if Stripe call fails and mocks enabled
|
|
234
|
+
try { console.error('[stripe/checkout-session] error', e?.message || e); } catch (_) {}
|
|
235
|
+
if (process.env.ENABLE_DEV_MOCKS === '1') {
|
|
236
|
+
const mockUrl = `${apiBase}/api/rentals/mock/checkout?videoId=${encodeURIComponent(String(videoId))}&userId=${encodeURIComponent(String(userId))}&amountCents=${price_cents}¤cy=${encodeURIComponent(currency || 'USD')}`;
|
|
237
|
+
return res.json({ url: mockUrl, id: `mock_${Date.now()}` });
|
|
238
|
+
}
|
|
239
|
+
return res.status(500).json({ error: 'failed to create checkout session', details: e?.message || String(e) });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|