feat: MVP-5 P2 - Export/Import, Settings, Tests y Validaciones
- Ticket 10: Navegación completa de listas por teclado (↑↓ Enter E F P) - Ticket 13: Historial de navegación contextual con recent-context-list - Ticket 17: Exportación mejorada a Markdown con frontmatter - Ticket 18: Exportación HTML simple y legible - Ticket 19: Importador Markdown mejorado con frontmatter, tags, wiki links - Ticket 20: Importador Obsidian-compatible (wiki links, #tags inline) - Ticket 21: Centro de respaldo y portabilidad en Settings - Ticket 22: Configuración visible de feature flags - Ticket 24: Tests de command palette y captura externa - Ticket 25: Harden de validaciones y límites (50MB backup, 10K notas, etc)
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
import { RecallBackup } from '@/types/backup'
|
||||
|
||||
// Limits
|
||||
const MAX_BACKUP_SIZE_BYTES = 50 * 1024 * 1024 // 50MB
|
||||
const MAX_NOTE_COUNT = 10000
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
warnings?: string[]
|
||||
info?: {
|
||||
noteCount: number
|
||||
tagCount: number
|
||||
@@ -13,6 +18,7 @@ interface ValidationResult {
|
||||
|
||||
export function validateBackup(data: unknown): ValidationResult {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return { valid: false, errors: ['Backup must be an object'] }
|
||||
@@ -50,6 +56,13 @@ export function validateBackup(data: unknown): ValidationResult {
|
||||
const data = backup.data as Record<string, unknown>
|
||||
if (!Array.isArray(data.notes)) {
|
||||
errors.push('Missing or invalid data.notes (expected array)')
|
||||
} else {
|
||||
if (data.notes.length > MAX_NOTE_COUNT) {
|
||||
errors.push(`Too many notes: ${data.notes.length} (max: ${MAX_NOTE_COUNT})`)
|
||||
}
|
||||
if (data.notes.length > 1000) {
|
||||
warnings.push(`Large backup with ${data.notes.length} notes - restore may take time`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +74,7 @@ export function validateBackup(data: unknown): ValidationResult {
|
||||
return {
|
||||
valid: true,
|
||||
errors: [],
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
info: {
|
||||
noteCount: metadata.noteCount,
|
||||
tagCount: metadata.tagCount,
|
||||
@@ -70,6 +84,16 @@ export function validateBackup(data: unknown): ValidationResult {
|
||||
}
|
||||
}
|
||||
|
||||
export function validateBackupSize(sizeBytes: number): { valid: boolean; error?: string } {
|
||||
if (sizeBytes > MAX_BACKUP_SIZE_BYTES) {
|
||||
return {
|
||||
valid: false,
|
||||
error: `Backup too large: ${(sizeBytes / 1024 / 1024).toFixed(2)}MB (max: ${MAX_BACKUP_SIZE_BYTES / 1024 / 1024}MB)`,
|
||||
}
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export function validateSchemaVersion(backup: RecallBackup): boolean {
|
||||
return backup.schemaVersion === '1.0'
|
||||
}
|
||||
|
||||
@@ -56,6 +56,18 @@ export class ConflictError extends AppError {
|
||||
}
|
||||
}
|
||||
|
||||
export class PayloadTooLargeError extends AppError {
|
||||
constructor(message: string = 'Payload too large') {
|
||||
super('PAYLOAD_TOO_LARGE', message, 413)
|
||||
}
|
||||
}
|
||||
|
||||
export class RateLimitError extends AppError {
|
||||
constructor(message: string = 'Too many requests') {
|
||||
super('RATE_LIMITED', message, 429)
|
||||
}
|
||||
}
|
||||
|
||||
export function formatZodError(error: ZodError): ApiError {
|
||||
return {
|
||||
code: 'VALIDATION_ERROR',
|
||||
|
||||
134
src/lib/export-html.ts
Normal file
134
src/lib/export-html.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
interface NoteWithTags {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
type: string
|
||||
isFavorite: boolean
|
||||
isPinned: boolean
|
||||
creationSource: string
|
||||
tags: { tag: { id: string; name: string } }[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function simpleMarkdownToHtml(content: string): string {
|
||||
// Convert markdown to basic HTML
|
||||
return content
|
||||
// Headers
|
||||
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
|
||||
// Bold and italic
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
// Code blocks
|
||||
.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code class="language-$1">$2</code></pre>')
|
||||
// Inline code
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
// Lists
|
||||
.replace(/^\s*-\s+(.*$)/gm, '<li>$1</li>')
|
||||
// Links
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>')
|
||||
// Paragraphs
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
// Line breaks
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
const HTML_TEMPLATE = `<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{TITLE}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
line-height: 1.6;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
color: #333;
|
||||
}
|
||||
h1 { border-bottom: 2px solid #333; padding-bottom: 10px; }
|
||||
.meta { color: #666; font-size: 0.9em; margin-bottom: 20px; }
|
||||
.tags { margin: 10px 0; }
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8em;
|
||||
margin-right: 5px;
|
||||
}
|
||||
pre {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
pre code { background: none; padding: 0; }
|
||||
li { margin-left: 20px; }
|
||||
a { color: #0066cc; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<article>
|
||||
<h1>{{TITLE}}</h1>
|
||||
<div class="meta">
|
||||
<span class="type">{{TYPE}}</span>
|
||||
<span class="date"> · Creado: {{CREATED}} · Actualizado: {{UPDATED}}</span>
|
||||
</div>
|
||||
<div class="tags">{{TAGS}}</div>
|
||||
<div class="content">{{CONTENT}}</div>
|
||||
</article>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
export function noteToHtml(note: NoteWithTags): string {
|
||||
const htmlContent = simpleMarkdownToHtml(escapeHtml(note.content))
|
||||
const tagsHtml = note.tags
|
||||
.map(({ tag }) => `<span class="tag">${escapeHtml(tag.name)}</span>`)
|
||||
.join('')
|
||||
|
||||
return HTML_TEMPLATE
|
||||
.replace('{{TITLE}}', escapeHtml(note.title))
|
||||
.replace('{{TYPE}}', escapeHtml(note.type))
|
||||
.replace('{{CREATED}}', new Date(note.createdAt).toLocaleDateString())
|
||||
.replace('{{UPDATED}}', new Date(note.updatedAt).toLocaleDateString())
|
||||
.replace('{{TAGS}}', tagsHtml)
|
||||
.replace('{{CONTENT}}', htmlContent)
|
||||
}
|
||||
|
||||
export function generateHtmlFilename(note: NoteWithTags): string {
|
||||
const sanitized = note.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 100)
|
||||
|
||||
return `${sanitized}-${note.id.slice(-8)}.html`
|
||||
}
|
||||
|
||||
export function notesToHtmlZip(notes: NoteWithTags[]): { files: { name: string; content: string }[] } {
|
||||
const files = notes.map((note) => ({
|
||||
name: generateHtmlFilename(note),
|
||||
content: noteToHtml(note),
|
||||
}))
|
||||
|
||||
return { files }
|
||||
}
|
||||
56
src/lib/export-markdown.ts
Normal file
56
src/lib/export-markdown.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
interface NoteWithTags {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
type: string
|
||||
isFavorite: boolean
|
||||
isPinned: boolean
|
||||
creationSource: string
|
||||
tags: { tag: { id: string; name: string } }[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export function noteToMarkdown(note: NoteWithTags): string {
|
||||
const frontmatter = [
|
||||
'---',
|
||||
`title: "${escapeYaml(note.title)}"`,
|
||||
`type: ${note.type}`,
|
||||
`createdAt: ${note.createdAt}`,
|
||||
`updatedAt: ${note.updatedAt}`,
|
||||
note.tags && note.tags.length > 0
|
||||
? `tags:\n${note.tags.map(({ tag }) => ` - ${tag.name}`).join('\n')}`
|
||||
: null,
|
||||
note.isFavorite ? 'favorite: true' : null,
|
||||
note.isPinned ? 'pinned: true' : null,
|
||||
'---',
|
||||
'',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
return `${frontmatter}\n# ${note.title}\n\n${note.content}`
|
||||
}
|
||||
|
||||
export function generateFilename(note: NoteWithTags): string {
|
||||
const sanitized = note.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 100)
|
||||
|
||||
return `${sanitized}-${note.id.slice(-8)}.md`
|
||||
}
|
||||
|
||||
export function escapeYaml(str: string): string {
|
||||
return str.replace(/"/g, '\\"').replace(/\n/g, '\\n')
|
||||
}
|
||||
|
||||
export function notesToMarkdownZip(notes: NoteWithTags[]): { files: { name: string; content: string }[] } {
|
||||
const files = notes.map((note) => ({
|
||||
name: generateFilename(note),
|
||||
content: noteToMarkdown(note),
|
||||
}))
|
||||
|
||||
return { files }
|
||||
}
|
||||
@@ -4,11 +4,48 @@ export interface CapturePayload {
|
||||
selection: string
|
||||
}
|
||||
|
||||
// Limits for capture payloads
|
||||
const MAX_TITLE_LENGTH = 500
|
||||
const MAX_URL_LENGTH = 2000
|
||||
const MAX_SELECTION_LENGTH = 10000
|
||||
|
||||
export interface CaptureValidationResult {
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export function validateCapturePayload(payload: CapturePayload): CaptureValidationResult {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!payload.title || typeof payload.title !== 'string') {
|
||||
errors.push('Title is required')
|
||||
} else if (payload.title.length > MAX_TITLE_LENGTH) {
|
||||
errors.push(`Title too long: ${payload.title.length} chars (max: ${MAX_TITLE_LENGTH})`)
|
||||
}
|
||||
|
||||
if (payload.url && typeof payload.url !== 'string') {
|
||||
errors.push('URL must be a string')
|
||||
} else if (payload.url && payload.url.length > MAX_URL_LENGTH) {
|
||||
errors.push(`URL too long: ${payload.url.length} chars (max: ${MAX_URL_LENGTH})`)
|
||||
}
|
||||
|
||||
if (payload.selection && typeof payload.selection !== 'string') {
|
||||
errors.push('Selection must be a string')
|
||||
} else if (payload.selection && payload.selection.length > MAX_SELECTION_LENGTH) {
|
||||
errors.push(`Selection too long: ${payload.selection.length} chars (max: ${MAX_SELECTION_LENGTH})`)
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
export function encodeCapturePayload(payload: CapturePayload): string {
|
||||
const params = new URLSearchParams({
|
||||
title: payload.title,
|
||||
url: payload.url,
|
||||
selection: payload.selection,
|
||||
title: payload.title.slice(0, MAX_TITLE_LENGTH),
|
||||
url: payload.url.slice(0, MAX_URL_LENGTH),
|
||||
selection: payload.selection.slice(0, MAX_SELECTION_LENGTH),
|
||||
})
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
132
src/lib/import-markdown.ts
Normal file
132
src/lib/import-markdown.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
interface ParsedFrontmatter {
|
||||
title?: string
|
||||
type?: string
|
||||
tags?: string[]
|
||||
favorite?: boolean
|
||||
pinned?: boolean
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
interface ParsedMarkdown {
|
||||
frontmatter: ParsedFrontmatter
|
||||
content: string
|
||||
title: string
|
||||
hasWikiLinks: boolean
|
||||
}
|
||||
|
||||
export function parseMarkdownContent(
|
||||
markdown: string,
|
||||
filename?: string
|
||||
): ParsedMarkdown {
|
||||
const frontmatter: ParsedFrontmatter = {}
|
||||
let content = markdown
|
||||
let title = ''
|
||||
|
||||
// Check for frontmatter
|
||||
const frontmatterMatch = markdown.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
||||
if (frontmatterMatch) {
|
||||
const frontmatterStr = frontmatterMatch[1]
|
||||
content = frontmatterMatch[2]
|
||||
|
||||
// Parse frontmatter fields
|
||||
const lines = frontmatterStr.split('\n')
|
||||
let currentKey = ''
|
||||
let inList = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (inList && line.match(/^\s+-\s+/)) {
|
||||
// Continuation of list
|
||||
const value = line.replace(/^\s+-\s+/, '').trim()
|
||||
if (frontmatter.tags && Array.isArray(frontmatter.tags)) {
|
||||
frontmatter.tags.push(value)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
inList = false
|
||||
|
||||
// Key: value
|
||||
const kvMatch = line.match(/^(\w+):\s*(.*)$/)
|
||||
if (kvMatch) {
|
||||
currentKey = kvMatch[1]
|
||||
const value = kvMatch[2].trim()
|
||||
|
||||
if (currentKey === 'tags' && !value) {
|
||||
frontmatter.tags = []
|
||||
inList = true
|
||||
} else if (currentKey === 'tags') {
|
||||
frontmatter.tags = value.split(',').map((t) => t.trim())
|
||||
} else if (currentKey === 'favorite' || currentKey === 'pinned') {
|
||||
frontmatter[currentKey] = value === 'true'
|
||||
} else {
|
||||
;(frontmatter as Record<string, unknown>)[currentKey] = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// List item
|
||||
const listMatch = line.match(/^\s+-\s+(.*)$/)
|
||||
if (listMatch) {
|
||||
if (!frontmatter.tags) frontmatter.tags = []
|
||||
frontmatter.tags.push(listMatch[1].trim())
|
||||
inList = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract title from content if not in frontmatter
|
||||
if (frontmatter.title) {
|
||||
title = frontmatter.title
|
||||
} else {
|
||||
// Try to find first # heading
|
||||
const headingMatch = content.match(/^#\s+(.+)$/m)
|
||||
if (headingMatch) {
|
||||
title = headingMatch[1].trim()
|
||||
} else if (filename) {
|
||||
// Derive from filename
|
||||
title = filename
|
||||
.replace(/\.md$/i, '')
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/^\d+-\d+-\d+\s*/, '') // Remove date prefix if present
|
||||
} else {
|
||||
title = 'Untitled'
|
||||
}
|
||||
}
|
||||
|
||||
// Remove title heading from content if it exists
|
||||
content = content.replace(/^#\s+.+\n+/, '')
|
||||
|
||||
// Check for wiki links
|
||||
const hasWikiLinks = /\[\[([^\]]+)\]\]/.test(content)
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
content: content.trim(),
|
||||
title,
|
||||
hasWikiLinks,
|
||||
}
|
||||
}
|
||||
|
||||
export function convertWikiLinksToMarkdown(content: string): string {
|
||||
// Convert [[link]] to [link](link)
|
||||
return content.replace(/\[\[([^\]|]+)\]\]/g, '[$1]($1)')
|
||||
}
|
||||
|
||||
export function extractInlineTags(content: string): string[] {
|
||||
// Extract #tag patterns that aren't in code blocks
|
||||
const tags: string[] = []
|
||||
const codeBlockRegex = /```[\s\S]*?```|`[^`]+`/g
|
||||
const contentWithoutCode = content.replace(codeBlockRegex, '')
|
||||
|
||||
const tagRegex = /#([a-zA-Z][a-zA-Z0-9_-]*)/g
|
||||
let match
|
||||
while ((match = tagRegex.exec(contentWithoutCode)) !== null) {
|
||||
const tag = match[1].toLowerCase()
|
||||
if (!tags.includes(tag)) {
|
||||
tags.push(tag)
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
52
src/lib/navigation-history.ts
Normal file
52
src/lib/navigation-history.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
const NAVIGATION_HISTORY_KEY = 'recall_navigation_history'
|
||||
const MAX_HISTORY_SIZE = 10
|
||||
|
||||
export interface NavigationEntry {
|
||||
noteId: string
|
||||
title: string
|
||||
type: string
|
||||
visitedAt: string
|
||||
}
|
||||
|
||||
export function getNavigationHistory(): NavigationEntry[] {
|
||||
if (typeof window === 'undefined') return []
|
||||
try {
|
||||
const stored = localStorage.getItem(NAVIGATION_HISTORY_KEY)
|
||||
if (!stored) return []
|
||||
return JSON.parse(stored)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function addToNavigationHistory(entry: Omit<NavigationEntry, 'visitedAt'>): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const history = getNavigationHistory()
|
||||
|
||||
// Remove duplicate entries for the same note
|
||||
const filtered = history.filter((e) => e.noteId !== entry.noteId)
|
||||
|
||||
// Add new entry at the beginning
|
||||
const newEntry: NavigationEntry = {
|
||||
...entry,
|
||||
visitedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
const newHistory = [newEntry, ...filtered].slice(0, MAX_HISTORY_SIZE)
|
||||
|
||||
localStorage.setItem(NAVIGATION_HISTORY_KEY, JSON.stringify(newHistory))
|
||||
}
|
||||
|
||||
export function clearNavigationHistory(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.removeItem(NAVIGATION_HISTORY_KEY)
|
||||
}
|
||||
|
||||
export function removeFromNavigationHistory(noteId: string): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const history = getNavigationHistory()
|
||||
const filtered = history.filter((e) => e.noteId !== noteId)
|
||||
localStorage.setItem(NAVIGATION_HISTORY_KEY, JSON.stringify(filtered))
|
||||
}
|
||||
57
src/lib/preferences.ts
Normal file
57
src/lib/preferences.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
const FEATURES_KEY = 'recall_features'
|
||||
const BACKUP_ENABLED_KEY = 'recall_backup_enabled'
|
||||
const BACKUP_RETENTION_KEY = 'recall_backup_retention'
|
||||
|
||||
export interface FeatureFlags {
|
||||
backupEnabled: boolean
|
||||
backupRetention: number // days
|
||||
workModeEnabled: boolean
|
||||
}
|
||||
|
||||
const defaultFlags: FeatureFlags = {
|
||||
backupEnabled: true,
|
||||
backupRetention: 30,
|
||||
workModeEnabled: true,
|
||||
}
|
||||
|
||||
export function getFeatureFlags(): FeatureFlags {
|
||||
if (typeof window === 'undefined') return defaultFlags
|
||||
try {
|
||||
const stored = localStorage.getItem(FEATURES_KEY)
|
||||
if (!stored) return defaultFlags
|
||||
return { ...defaultFlags, ...JSON.parse(stored) }
|
||||
} catch {
|
||||
return defaultFlags
|
||||
}
|
||||
}
|
||||
|
||||
export function setFeatureFlags(flags: Partial<FeatureFlags>): void {
|
||||
if (typeof window === 'undefined') return
|
||||
const current = getFeatureFlags()
|
||||
const updated = { ...current, ...flags }
|
||||
localStorage.setItem(FEATURES_KEY, JSON.stringify(updated))
|
||||
}
|
||||
|
||||
export function isBackupEnabled(): boolean {
|
||||
return getFeatureFlags().backupEnabled
|
||||
}
|
||||
|
||||
export function setBackupEnabled(enabled: boolean): void {
|
||||
setFeatureFlags({ backupEnabled: enabled })
|
||||
}
|
||||
|
||||
export function getBackupRetention(): number {
|
||||
return getFeatureFlags().backupRetention
|
||||
}
|
||||
|
||||
export function setBackupRetention(days: number): void {
|
||||
setFeatureFlags({ backupRetention: days })
|
||||
}
|
||||
|
||||
export function isWorkModeEnabled(): boolean {
|
||||
return getFeatureFlags().workModeEnabled
|
||||
}
|
||||
|
||||
export function setWorkModeEnabled(enabled: boolean): void {
|
||||
setFeatureFlags({ workModeEnabled: enabled })
|
||||
}
|
||||
Reference in New Issue
Block a user