mirror of
https://github.com/yusufipk/RepoHub.git
synced 2026-09-11 10:36:07 +00:00
feat: add PostgreSQL database support and development scripts
- Added pg and @types/pg dependencies for PostgreSQL integration - Created database testing and backend initialization scripts - Changed dev server port to 3002 to avoid conflicts
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { exec } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { PackageService } from './packageService'
|
||||
import { PlatformService } from './platformService'
|
||||
import { CreatePackageInput } from '@/models/Package'
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export class MetadataFetcher {
|
||||
// Fetch Ubuntu/Debian package metadata
|
||||
static async fetchUbuntuPackages(): Promise<void> {
|
||||
console.log('🔄 Starting Ubuntu/Debian package metadata fetch...')
|
||||
|
||||
try {
|
||||
// Update package lists
|
||||
console.log('📦 Updating apt package lists...')
|
||||
await execAsync('apt-get update')
|
||||
|
||||
// Get package list
|
||||
console.log('📋 Getting package list...')
|
||||
const { stdout } = await execAsync('apt-cache dumpavail')
|
||||
|
||||
// Parse packages
|
||||
const packages = this.parseAptPackages(stdout)
|
||||
console.log(`📊 Found ${packages.length} packages`)
|
||||
|
||||
// Store in database
|
||||
await this.storePackages('ubuntu', packages)
|
||||
|
||||
console.log('✅ Ubuntu/Debian package metadata fetch completed')
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching Ubuntu packages:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Parse apt-cache dumpavail output
|
||||
private static parseAptPackages(output: string): CreatePackageInput[] {
|
||||
const packages: CreatePackageInput[] = []
|
||||
const blocks = output.split('\n\n')
|
||||
|
||||
for (const block of blocks) {
|
||||
if (!block.trim()) continue
|
||||
|
||||
const pkg: any = {}
|
||||
const lines = block.split('\n')
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('Package: ')) {
|
||||
pkg.id = line.substring(9).trim()
|
||||
pkg.name = pkg.id
|
||||
} else if (line.startsWith('Description: ')) {
|
||||
pkg.description = line.substring(13).trim()
|
||||
} else if (line.startsWith('Version: ')) {
|
||||
pkg.version = line.substring(9).trim()
|
||||
} else if (line.startsWith('Homepage: ')) {
|
||||
pkg.homepage_url = line.substring(10).trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (pkg.id && pkg.name) {
|
||||
packages.push({
|
||||
...pkg,
|
||||
platform_id: 'ubuntu',
|
||||
type: this.determinePackageType(pkg.name, pkg.description),
|
||||
repository: 'official',
|
||||
popularity_score: Math.floor(Math.random() * 100) // Placeholder
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return packages
|
||||
}
|
||||
|
||||
// Determine if package is GUI or CLI
|
||||
private static determinePackageType(name: string, description?: string): 'gui' | 'cli' {
|
||||
const guiKeywords = [
|
||||
'gui', 'gtk', 'qt', 'x11', 'desktop', 'window', 'display',
|
||||
'graphical', 'visual', 'image', 'video', 'audio', 'media',
|
||||
'browser', 'editor', 'viewer', 'player', 'manager'
|
||||
]
|
||||
|
||||
const cliKeywords = [
|
||||
'cli', 'command', 'terminal', 'console', 'shell', 'bash',
|
||||
'tool', 'utility', 'daemon', 'service', 'server', 'client'
|
||||
]
|
||||
|
||||
const searchText = `${name} ${description || ''}`.toLowerCase()
|
||||
|
||||
for (const keyword of guiKeywords) {
|
||||
if (searchText.includes(keyword)) return 'gui'
|
||||
}
|
||||
|
||||
for (const keyword of cliKeywords) {
|
||||
if (searchText.includes(keyword)) return 'cli'
|
||||
}
|
||||
|
||||
// Default to CLI for system packages
|
||||
return 'cli'
|
||||
}
|
||||
|
||||
// Store packages in database
|
||||
private static async storePackages(platformId: string, packages: CreatePackageInput[]): Promise<void> {
|
||||
console.log(`💾 Storing ${packages.length} packages for platform ${platformId}...`)
|
||||
|
||||
let added = 0
|
||||
let updated = 0
|
||||
|
||||
for (const pkg of packages) {
|
||||
try {
|
||||
// Check if package exists
|
||||
const existing = await PackageService.getById(pkg.id)
|
||||
|
||||
if (existing) {
|
||||
// Update existing package
|
||||
await PackageService.update(pkg.id, {
|
||||
description: pkg.description,
|
||||
version: pkg.version,
|
||||
homepage_url: pkg.homepage_url,
|
||||
type: pkg.type,
|
||||
repository: pkg.repository,
|
||||
popularity_score: pkg.popularity_score
|
||||
})
|
||||
updated++
|
||||
} else {
|
||||
// Create new package
|
||||
await PackageService.create(pkg)
|
||||
added++
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error storing package ${pkg.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`📈 Added: ${added}, Updated: ${updated}`)
|
||||
}
|
||||
|
||||
// Fetch package categories from Ubuntu
|
||||
static async fetchUbuntuCategories(): Promise<void> {
|
||||
console.log('🏷️ Fetching Ubuntu package categories...')
|
||||
|
||||
try {
|
||||
// Get sections from apt-cache
|
||||
const { stdout } = await execAsync('apt-cache dump | grep "^Section:" | sort | uniq')
|
||||
|
||||
const categories = stdout
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => line.replace('Section:', '').trim())
|
||||
.filter(category => category && category !== 'unknown')
|
||||
|
||||
console.log(`📋 Found ${categories.length} categories`)
|
||||
|
||||
// Store categories in database (would need CategoryService)
|
||||
for (const category of categories) {
|
||||
console.log(` - ${category}`)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching Ubuntu categories:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Get popular packages from Ubuntu popularity contest
|
||||
static async getPopularUbuntuPackages(limit: number = 100): Promise<string[]> {
|
||||
try {
|
||||
// Try to get popularity contest data
|
||||
const { stdout } = await execAsync('popularity-contest -a 2>/dev/null || echo ""')
|
||||
|
||||
if (stdout) {
|
||||
const packages = stdout
|
||||
.split('\n')
|
||||
.filter(line => line.includes('POP-CON-Package'))
|
||||
.map(line => line.split(' ')[1])
|
||||
.slice(0, limit)
|
||||
|
||||
return packages
|
||||
}
|
||||
|
||||
// Fallback: return some common packages
|
||||
return [
|
||||
'curl', 'wget', 'git', 'vim', 'nano', 'firefox', 'chromium-browser',
|
||||
'libreoffice', 'gimp', 'vlc', 'audacity', 'thunderbird', 'code'
|
||||
]
|
||||
} catch (error) {
|
||||
console.error('Error getting popular packages:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Sync metadata for a specific platform
|
||||
static async syncPlatform(platformId: string): Promise<void> {
|
||||
console.log(`🔄 Starting metadata sync for platform: ${platformId}`)
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
switch (platformId) {
|
||||
case 'ubuntu':
|
||||
await this.fetchUbuntuPackages()
|
||||
break
|
||||
case 'fedora':
|
||||
// TODO: Implement Fedora metadata fetching
|
||||
console.log('⏳ Fedora metadata fetching not yet implemented')
|
||||
break
|
||||
case 'arch':
|
||||
// TODO: Implement Arch metadata fetching
|
||||
console.log('⏳ Arch metadata fetching not yet implemented')
|
||||
break
|
||||
case 'windows':
|
||||
// TODO: Implement Windows metadata fetching
|
||||
console.log('⏳ Windows metadata fetching not yet implemented')
|
||||
break
|
||||
case 'macos':
|
||||
// TODO: Implement macOS metadata fetching
|
||||
console.log('⏳ macOS metadata fetching not yet implemented')
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platformId}`)
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
console.log(`✅ Sync completed for ${platformId} in ${duration}ms`)
|
||||
|
||||
} catch (error) {
|
||||
console.error(`❌ Sync failed for ${platformId}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Sync all platforms
|
||||
static async syncAllPlatforms(): Promise<void> {
|
||||
console.log('🔄 Starting metadata sync for all platforms...')
|
||||
|
||||
const platforms = await PlatformService.getAll()
|
||||
|
||||
for (const platform of platforms) {
|
||||
try {
|
||||
await this.syncPlatform(platform.id)
|
||||
} catch (error) {
|
||||
console.error(`Failed to sync ${platform.name}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ All platforms sync completed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { query } from '@/lib/database/config'
|
||||
import { Package, CreatePackageInput, UpdatePackageInput, PackageFilter } from '@/models/Package'
|
||||
|
||||
export class PackageService {
|
||||
// Get packages with filtering and pagination
|
||||
static async getMany(filter: PackageFilter = {}): Promise<{ packages: Package[], total: number }> {
|
||||
const {
|
||||
platform_id,
|
||||
category_id,
|
||||
type,
|
||||
repository,
|
||||
search,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
sort_by = 'name',
|
||||
sort_order = 'asc'
|
||||
} = filter
|
||||
|
||||
// Build WHERE clause
|
||||
const whereConditions = []
|
||||
const values = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (platform_id) {
|
||||
whereConditions.push(`p.platform_id = $${paramIndex++}`)
|
||||
values.push(platform_id)
|
||||
}
|
||||
|
||||
if (category_id) {
|
||||
whereConditions.push(`p.category_id = $${paramIndex++}`)
|
||||
values.push(category_id)
|
||||
}
|
||||
|
||||
if (type) {
|
||||
whereConditions.push(`p.type = $${paramIndex++}`)
|
||||
values.push(type)
|
||||
}
|
||||
|
||||
if (repository) {
|
||||
whereConditions.push(`p.repository = $${paramIndex++}`)
|
||||
values.push(repository)
|
||||
}
|
||||
|
||||
if (search) {
|
||||
whereConditions.push(`(
|
||||
to_tsvector('english', p.name) @@ to_tsquery('english', $${paramIndex}) OR
|
||||
to_tsvector('english', p.description) @@ to_tsquery('english', $${paramIndex})
|
||||
)`)
|
||||
values.push(search.split(' ').join(' & '))
|
||||
paramIndex++
|
||||
}
|
||||
|
||||
whereConditions.push('p.is_active = true')
|
||||
|
||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : ''
|
||||
|
||||
// Build ORDER BY clause
|
||||
const validSortFields = ['name', 'popularity_score', 'last_updated', 'downloads_count']
|
||||
const sortField = validSortFields.includes(sort_by || '') ? sort_by : 'name'
|
||||
const sortDirection = sort_order === 'desc' ? 'DESC' : 'ASC'
|
||||
const orderClause = `ORDER BY p.${sortField} ${sortDirection}`
|
||||
|
||||
// Get total count
|
||||
const countQuery = `
|
||||
SELECT COUNT(*) as total
|
||||
FROM packages p
|
||||
${whereClause}
|
||||
`
|
||||
const countResult = await query(countQuery, values)
|
||||
const total = parseInt(countResult.rows[0].total)
|
||||
|
||||
// Get packages with joins
|
||||
const packagesQuery = `
|
||||
SELECT
|
||||
p.*,
|
||||
pl.name as platform_name,
|
||||
pl.package_manager as platform_package_manager,
|
||||
pl.icon as platform_icon,
|
||||
c.name as category_name,
|
||||
l.name as license_name,
|
||||
l.url as license_url,
|
||||
COALESCE(
|
||||
ARRAY_AGG(DISTINCT pt.tag) FILTER (WHERE pt.tag IS NOT NULL),
|
||||
ARRAY[]::VARCHAR[]
|
||||
) as tags
|
||||
FROM packages p
|
||||
LEFT JOIN platforms pl ON p.platform_id = pl.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
LEFT JOIN licenses l ON p.license_id = l.id
|
||||
LEFT JOIN package_tags pt ON p.id = pt.package_id
|
||||
${whereClause}
|
||||
GROUP BY p.id, pl.name, pl.package_manager, pl.icon, c.name, l.name, l.url
|
||||
${orderClause}
|
||||
LIMIT $${paramIndex++} OFFSET $${paramIndex++}
|
||||
`
|
||||
|
||||
values.push(limit, offset)
|
||||
const packagesResult = await query(packagesQuery, values)
|
||||
|
||||
// Transform the results
|
||||
const packages = packagesResult.rows.map((row: any) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
platform_id: row.platform_id,
|
||||
category_id: row.category_id,
|
||||
license_id: row.license_id,
|
||||
type: row.type,
|
||||
repository: row.repository,
|
||||
homepage_url: row.homepage_url,
|
||||
download_url: row.download_url,
|
||||
last_updated: row.last_updated,
|
||||
downloads_count: row.downloads_count,
|
||||
popularity_score: row.popularity_score,
|
||||
is_active: row.is_active,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
platform: row.platform_name ? {
|
||||
id: row.platform_id,
|
||||
name: row.platform_name,
|
||||
package_manager: row.platform_package_manager,
|
||||
icon: row.platform_icon
|
||||
} : undefined,
|
||||
category: row.category_name ? {
|
||||
id: row.category_id,
|
||||
name: row.category_name
|
||||
} : undefined,
|
||||
license: row.license_name ? {
|
||||
id: row.license_id,
|
||||
name: row.license_name,
|
||||
url: row.license_url
|
||||
} : undefined,
|
||||
tags: row.tags || []
|
||||
}))
|
||||
|
||||
return { packages, total }
|
||||
}
|
||||
|
||||
// Get package by ID
|
||||
static async getById(id: string): Promise<Package | null> {
|
||||
const packageQuery = `
|
||||
SELECT
|
||||
p.*,
|
||||
pl.name as platform_name,
|
||||
pl.package_manager as platform_package_manager,
|
||||
pl.icon as platform_icon,
|
||||
c.name as category_name,
|
||||
l.name as license_name,
|
||||
l.url as license_url,
|
||||
COALESCE(
|
||||
ARRAY_AGG(DISTINCT pt.tag) FILTER (WHERE pt.tag IS NOT NULL),
|
||||
ARRAY[]::VARCHAR[]
|
||||
) as tags
|
||||
FROM packages p
|
||||
LEFT JOIN platforms pl ON p.platform_id = pl.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
LEFT JOIN licenses l ON p.license_id = l.id
|
||||
LEFT JOIN package_tags pt ON p.id = pt.package_id
|
||||
WHERE p.id = $1 AND p.is_active = true
|
||||
GROUP BY p.id, pl.name, pl.package_manager, pl.icon, c.name, l.name, l.url
|
||||
`
|
||||
|
||||
const result = await query(packageQuery, [id])
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = result.rows[0]
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
platform_id: row.platform_id,
|
||||
category_id: row.category_id,
|
||||
license_id: row.license_id,
|
||||
type: row.type,
|
||||
repository: row.repository,
|
||||
homepage_url: row.homepage_url,
|
||||
download_url: row.download_url,
|
||||
last_updated: row.last_updated,
|
||||
downloads_count: row.downloads_count,
|
||||
popularity_score: row.popularity_score,
|
||||
is_active: row.is_active,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
platform: row.platform_name ? {
|
||||
id: row.platform_id,
|
||||
name: row.platform_name,
|
||||
package_manager: row.platform_package_manager,
|
||||
icon: row.platform_icon
|
||||
} : undefined,
|
||||
category: row.category_name ? {
|
||||
id: row.category_id,
|
||||
name: row.category_name
|
||||
} : undefined,
|
||||
license: row.license_name ? {
|
||||
id: row.license_id,
|
||||
name: row.license_name,
|
||||
url: row.license_url
|
||||
} : undefined,
|
||||
tags: row.tags || []
|
||||
}
|
||||
}
|
||||
|
||||
// Create new package
|
||||
static async create(data: CreatePackageInput): Promise<Package> {
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
version,
|
||||
platform_id,
|
||||
category_id,
|
||||
license_id,
|
||||
type,
|
||||
repository,
|
||||
homepage_url,
|
||||
download_url,
|
||||
popularity_score
|
||||
} = data
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO packages (
|
||||
id, name, description, version, platform_id, category_id,
|
||||
license_id, type, repository, homepage_url, download_url,
|
||||
popularity_score
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[id, name, description, version, platform_id, category_id,
|
||||
license_id, type, repository, homepage_url, download_url,
|
||||
popularity_score]
|
||||
)
|
||||
|
||||
const createdPackage = await this.getById(result.rows[0].id)
|
||||
if (!createdPackage) {
|
||||
throw new Error('Failed to create package')
|
||||
}
|
||||
return createdPackage
|
||||
}
|
||||
|
||||
// Update package
|
||||
static async update(id: string, data: UpdatePackageInput): Promise<Package | null> {
|
||||
const fields = []
|
||||
const values = []
|
||||
let paramIndex = 1
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (value !== undefined) {
|
||||
fields.push(`${key} = $${paramIndex++}`)
|
||||
values.push(value)
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.getById(id)
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
await query(
|
||||
`UPDATE packages SET ${fields.join(', ')} WHERE id = $${paramIndex}`,
|
||||
values
|
||||
)
|
||||
|
||||
return this.getById(id)
|
||||
}
|
||||
|
||||
// Delete package (soft delete)
|
||||
static async delete(id: string): Promise<boolean> {
|
||||
const result = await query(
|
||||
'UPDATE packages SET is_active = false WHERE id = $1',
|
||||
[id]
|
||||
)
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
// Add tags to package
|
||||
static async addTags(packageId: string, tags: string[]): Promise<void> {
|
||||
if (tags.length === 0) return
|
||||
|
||||
const values = tags.map((tag, index) => `($1, $${index + 2})`).join(', ')
|
||||
const params = [packageId, ...tags]
|
||||
|
||||
await query(
|
||||
`INSERT INTO package_tags (package_id, tag) VALUES ${values} ON CONFLICT DO NOTHING`,
|
||||
params
|
||||
)
|
||||
}
|
||||
|
||||
// Remove tags from package
|
||||
static async removeTags(packageId: string, tags: string[]): Promise<void> {
|
||||
if (tags.length === 0) return
|
||||
|
||||
await query(
|
||||
'DELETE FROM package_tags WHERE package_id = $1 AND tag = ANY($2)',
|
||||
[packageId, tags]
|
||||
)
|
||||
}
|
||||
|
||||
// Get popular packages
|
||||
static async getPopular(limit: number = 10): Promise<Package[]> {
|
||||
const result = await this.getMany({
|
||||
limit,
|
||||
sort_by: 'popularity_score',
|
||||
sort_order: 'desc'
|
||||
})
|
||||
return result.packages
|
||||
}
|
||||
|
||||
// Get recently updated packages
|
||||
static async getRecentlyUpdated(limit: number = 10): Promise<Package[]> {
|
||||
const result = await this.getMany({
|
||||
limit,
|
||||
sort_by: 'last_updated',
|
||||
sort_order: 'desc'
|
||||
})
|
||||
return result.packages
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { query } from '@/lib/database/config'
|
||||
import { Platform, CreatePlatformInput, UpdatePlatformInput } from '@/models/Platform'
|
||||
|
||||
export class PlatformService {
|
||||
// Get all platforms
|
||||
static async getAll(): Promise<Platform[]> {
|
||||
const result = await query('SELECT * FROM platforms ORDER BY name')
|
||||
return result.rows
|
||||
}
|
||||
|
||||
// Get platform by ID
|
||||
static async getById(id: string): Promise<Platform | null> {
|
||||
const result = await query('SELECT * FROM platforms WHERE id = $1', [id])
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
// Create new platform
|
||||
static async create(data: CreatePlatformInput): Promise<Platform> {
|
||||
const { id, name, package_manager, icon } = data
|
||||
const result = await query(
|
||||
`INSERT INTO platforms (id, name, package_manager, icon)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *`,
|
||||
[id, name, package_manager, icon]
|
||||
)
|
||||
return result.rows[0]
|
||||
}
|
||||
|
||||
// Update platform
|
||||
static async update(id: string, data: UpdatePlatformInput): Promise<Platform | null> {
|
||||
const fields = []
|
||||
const values = []
|
||||
let paramIndex = 1
|
||||
|
||||
if (data.name !== undefined) {
|
||||
fields.push(`name = $${paramIndex++}`)
|
||||
values.push(data.name)
|
||||
}
|
||||
if (data.package_manager !== undefined) {
|
||||
fields.push(`package_manager = $${paramIndex++}`)
|
||||
values.push(data.package_manager)
|
||||
}
|
||||
if (data.icon !== undefined) {
|
||||
fields.push(`icon = $${paramIndex++}`)
|
||||
values.push(data.icon)
|
||||
}
|
||||
|
||||
if (fields.length === 0) {
|
||||
return this.getById(id)
|
||||
}
|
||||
|
||||
values.push(id)
|
||||
const result = await query(
|
||||
`UPDATE platforms SET ${fields.join(', ')} WHERE id = $${paramIndex} RETURNING *`,
|
||||
values
|
||||
)
|
||||
return result.rows[0] || null
|
||||
}
|
||||
|
||||
// Delete platform
|
||||
static async delete(id: string): Promise<boolean> {
|
||||
const result = await query('DELETE FROM platforms WHERE id = $1', [id])
|
||||
return (result.rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
// Check if platform exists
|
||||
static async exists(id: string): Promise<boolean> {
|
||||
const result = await query('SELECT 1 FROM platforms WHERE id = $1 LIMIT 1', [id])
|
||||
return result.rows.length > 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user