feat(tutorials): add video training academy management with YouTube parser and live preview

This commit is contained in:
mstfyldz
2026-08-22 13:49:00 +03:00
parent 2d149f1178
commit 7f690cc6d3
29 changed files with 2476 additions and 1034 deletions
+186
View File
@@ -0,0 +1,186 @@
'use server'
import { randomBytes, createHash } from 'crypto'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { supabaseAdmin, listAuthUsers } from '@/lib/supabaseAdmin'
import { setProfileAccess } from '../users/actions'
async function requireAdmin() {
const session = await auth()
if (!session || (session.user as any)?.role !== 'ADMIN') {
throw new Error('Yetkisiz erişim')
}
}
function toIsoOrNull(dateInput: FormDataEntryValue | null): string | null {
const value = String(dateInput || '').trim()
if (!value) return null
const d = new Date(`${value}T23:59:59.999Z`)
if (isNaN(d.getTime())) return null
return d.toISOString()
}
// null dönerse "e-posta girildi ama eşleşen kullanıcı yok" (hata), undefined dönerse "e-posta boş" (dokunma)
async function resolveCustomerIdByEmail(email: string): Promise<string | null | undefined> {
const trimmed = email.trim().toLowerCase()
if (!trimmed) return undefined
const authUsers = await listAuthUsers()
const match = [...authUsers.values()].find((u) => (u.email || '').toLowerCase() === trimmed)
return match?.id ?? null
}
// profiles.license_status, licenses tablosundan ayrı bir alan ama artık uygulamanın
// TEK gerçek erişim kapısı (bkz. users/actions.ts::setProfileAccess) — buradan bir
// lisans kaydedildiğinde bağlı müşterinin profilini de aynı ortak fonksiyonla senkron
// tutuyoruz, yoksa "lisansı aktif yaptım ama uygulama hâlâ süresi dolmuş diyor" durumu oluşuyor.
async function syncProfileLicenseStatus(customerId: string, licenseStatus: string, expiresAt: string | null) {
const profileStatus = licenseStatus === 'active' ? 'active' : 'expired' // 'expired' | 'revoked' -> ikisi de erişimi kapatmalı
await setProfileAccess(customerId, profileStatus, expiresAt)
}
export async function updateLicenseAction(formData: FormData) {
await requireAdmin()
const id = String(formData.get('id') || '')
if (!id) throw new Error('Lisans id eksik')
const plan = String(formData.get('plan') || '').trim()
const status = String(formData.get('status') || '').trim()
const maxDevicesRaw = String(formData.get('max_devices') || '').trim()
const maxDevices = maxDevicesRaw ? parseInt(maxDevicesRaw, 10) : null
const expiresAt = toIsoOrNull(formData.get('expires_at'))
const email = String(formData.get('customer_email') || '')
const update: Record<string, unknown> = {}
if (plan) update.plan = plan
if (status) update.status = status
if (maxDevices && !isNaN(maxDevices)) update.max_devices = maxDevices
update.expires_at = expiresAt
const customerId = await resolveCustomerIdByEmail(email)
if (customerId === null) {
redirect(`/admin/licenses/${id}?error=${encodeURIComponent('Bu e-posta ile kayıtlı kullanıcı bulunamadı')}`)
}
update.customer_id = customerId === undefined ? null : customerId
const { error } = await supabaseAdmin.from('licenses').update(update).eq('id', id)
if (error) {
redirect(`/admin/licenses/${id}?error=${encodeURIComponent(error.message)}`)
}
const finalCustomerId = update.customer_id as string | null
if (finalCustomerId) {
await syncProfileLicenseStatus(finalCustomerId, status || 'active', expiresAt)
}
revalidatePath('/admin/licenses')
revalidatePath(`/admin/licenses/${id}`)
revalidatePath('/admin/users')
redirect(`/admin/licenses/${id}?success=1`)
}
// XXXX-XXXX-XXXX-XXXX — 0/O/1/I hariç, elle okurken/yazarken karışmasın diye
const KEY_CHARSET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
function generateLicenseKey(): string {
const groups: string[] = []
for (let g = 0; g < 4; g++) {
const bytes = randomBytes(4)
let group = ''
for (let i = 0; i < 4; i++) group += KEY_CHARSET[bytes[i] % KEY_CHARSET.length]
groups.push(group)
}
return groups.join('-')
}
// license Edge Function'daki algoritmayla birebir aynı olmalı (supa.ayris.tech/functions/v1/license):
// licenseKey.trim().toUpperCase() -> SHA-256 hex digest, salt yok.
function hashLicenseKey(key: string): string {
return createHash('sha256').update(key.trim().toUpperCase()).digest('hex')
}
export interface CreateLicenseState {
error?: string
result?: { id: string; licenseKey: string }
}
export async function createLicenseAction(_prevState: CreateLicenseState, formData: FormData): Promise<CreateLicenseState> {
await requireAdmin()
const plan = String(formData.get('plan') || 'professional').trim() || 'professional'
const status = String(formData.get('status') || 'active').trim() || 'active'
const maxDevicesRaw = String(formData.get('max_devices') || '2').trim()
const maxDevices = parseInt(maxDevicesRaw, 10) || 2
const expiresAt = toIsoOrNull(formData.get('expires_at'))
const email = String(formData.get('customer_email') || '')
const customerId = await resolveCustomerIdByEmail(email)
if (customerId === null) {
return { error: 'Bu e-posta ile kayıtlı kullanıcı bulunamadı' }
}
let licenseKey = ''
let licenseKeyHash = ''
for (let attempt = 0; attempt < 5; attempt++) {
licenseKey = generateLicenseKey()
licenseKeyHash = hashLicenseKey(licenseKey)
const { data: collision } = await supabaseAdmin.from('licenses').select('id').eq('license_key_hash', licenseKeyHash).maybeSingle()
if (!collision) break
}
const { data, error } = await supabaseAdmin
.from('licenses')
.insert({
license_key_hash: licenseKeyHash,
customer_id: customerId ?? null,
plan,
status,
max_devices: maxDevices,
expires_at: expiresAt,
})
.select('id')
.single()
if (error || !data) {
return { error: error?.message || 'Lisans oluşturulamadı' }
}
if (customerId) {
await syncProfileLicenseStatus(customerId, status, expiresAt)
}
revalidatePath('/admin/licenses')
revalidatePath('/admin/users')
return { result: { id: data.id, licenseKey } }
}
export async function deleteLicenseAction(formData: FormData) {
await requireAdmin()
const id = String(formData.get('id') || '')
if (!id) throw new Error('Lisans id eksik')
await supabaseAdmin.from('license_activations').delete().eq('license_id', id)
const { error } = await supabaseAdmin.from('licenses').delete().eq('id', id)
if (error) {
redirect(`/admin/licenses/${id}?error=${encodeURIComponent(error.message)}`)
}
revalidatePath('/admin/licenses')
redirect('/admin/licenses?deleted=1')
}
export async function removeActivationAction(formData: FormData) {
await requireAdmin()
const activationId = String(formData.get('activation_id') || '')
const licenseId = String(formData.get('license_id') || '')
if (!activationId || !licenseId) throw new Error('Eksik parametre')
await supabaseAdmin.from('license_activations').delete().eq('id', activationId)
revalidatePath(`/admin/licenses/${licenseId}`)
redirect(`/admin/licenses/${licenseId}?success=1`)
}