feat(voice): add async selectable summaries
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
import {
|
||||
applyFeedbackSummaryRun,
|
||||
copyApiRequestId,
|
||||
createFeedbackRecord,
|
||||
createFeedbackSummaryRun,
|
||||
createVoiceLesson,
|
||||
discardVoiceSegment,
|
||||
ensureFeedbackSession,
|
||||
finishVoiceLesson,
|
||||
generateFeedbackFromVoice,
|
||||
getActiveFeedbackSession,
|
||||
getApiErrorMessage,
|
||||
getApiRequestId,
|
||||
getFeedbackSummaryRun,
|
||||
listProfiles,
|
||||
markVoiceLessonApplied,
|
||||
retryVoiceSegment,
|
||||
@@ -18,6 +20,7 @@ import {
|
||||
import type {
|
||||
FeedbackDraft,
|
||||
FeedbackSession,
|
||||
FeedbackSummaryRun,
|
||||
StudentProfile,
|
||||
VoiceLesson,
|
||||
VoiceLessonFinished
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
|
||||
type PickerEvent = { detail: { value: string | number } }
|
||||
type InputEvent = { detail: { value: string } }
|
||||
type CheckboxGroupEvent = { detail: { value: string[] } }
|
||||
type LessonActionEvent = { currentTarget: { dataset: { lessonId: string } } }
|
||||
|
||||
interface PendingVoiceUpload {
|
||||
@@ -51,6 +55,8 @@ interface VoiceLessonView {
|
||||
transcriptLength: number
|
||||
transcriptExpandable: boolean
|
||||
canAdd: boolean
|
||||
summaryEligible: boolean
|
||||
summarySelected: boolean
|
||||
addActionText: string
|
||||
emptyText: string
|
||||
}
|
||||
@@ -58,13 +64,17 @@ interface VoiceLessonView {
|
||||
const MAX_CONTENT_LENGTH = 2000
|
||||
const RECORDING_SEGMENT_DURATION_MS = 8 * 60 * 1000
|
||||
const VOICE_POLL_INTERVAL_MS = 3000
|
||||
const SUMMARY_POLL_INTERVAL_MS = 2000
|
||||
const PENDING_UPLOADS_KEY = 'teaching-feedback-pending-voice-uploads-v1'
|
||||
|
||||
const recorderManager = wx.getRecorderManager()
|
||||
let recordingTimer: number | null = null
|
||||
let draftSaveTimer: number | null = null
|
||||
let draftSaveQueue: Promise<void> = Promise.resolve()
|
||||
let voicePollTimer: number | null = null
|
||||
let summaryPollTimer: number | null = null
|
||||
let voicePollBusy = false
|
||||
let summaryPollBusy = false
|
||||
let pageVisible = false
|
||||
let lessonStartedAt = 0
|
||||
let segmentStartedAt = 0
|
||||
@@ -121,11 +131,17 @@ function wait(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function buildVoiceLessonViews(session: FeedbackSession | null, content: string): VoiceLessonView[] {
|
||||
function buildVoiceLessonViews(
|
||||
session: FeedbackSession | null,
|
||||
content: string,
|
||||
excludedSummaryLessonIds: string[] = []
|
||||
): VoiceLessonView[] {
|
||||
if (!session) return []
|
||||
const excluded = new Set(excludedSummaryLessonIds)
|
||||
return session.lessons.map((lesson: VoiceLesson) => {
|
||||
const transcript = lesson.transcript.trim()
|
||||
const isInDraft = Boolean(transcript) && content.includes(transcript)
|
||||
const summaryEligible = lesson.status === 'ready' && Boolean(transcript)
|
||||
let emptyText = '转录完成后可查看内容'
|
||||
if (lesson.status === 'failed') {
|
||||
emptyText = lesson.segments.find((segment) => segment.status === 'failed')?.errorMessage
|
||||
@@ -142,6 +158,8 @@ function buildVoiceLessonViews(session: FeedbackSession | null, content: string)
|
||||
transcriptLength: transcript.length,
|
||||
transcriptExpandable: transcript.length > 120,
|
||||
canAdd: lesson.status === 'ready' && Boolean(transcript) && !isInDraft,
|
||||
summaryEligible,
|
||||
summarySelected: summaryEligible && !excluded.has(lesson.id),
|
||||
addActionText: isInDraft
|
||||
? '已在输入框'
|
||||
: lesson.appliedDirectly
|
||||
@@ -156,6 +174,19 @@ function logVoiceOperation(event: string, fields: Record<string, unknown>): void
|
||||
console.info('voice_operation', { event, ...fields })
|
||||
}
|
||||
|
||||
function createSummaryIdempotencyKey(sessionId: string): string {
|
||||
return `${sessionId}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
}
|
||||
|
||||
function getSummaryStatusText(run: FeedbackSummaryRun | null): string {
|
||||
if (!run) return ''
|
||||
if (run.status === 'queued') return '已提交,等待生成'
|
||||
if (run.status === 'processing') return '正在整理所选录音'
|
||||
if (run.status === 'ready') return `总结已生成 · ${run.characterCount || 0}字`
|
||||
if (run.status === 'applied') return `已替换输入框 · ${run.characterCount || 0}字`
|
||||
return run.errorMessage || '总结生成失败,请重新生成'
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
profiles: [] as StudentProfile[],
|
||||
@@ -166,7 +197,14 @@ Page({
|
||||
contentLength: 0,
|
||||
loading: false,
|
||||
saving: false,
|
||||
generating: false,
|
||||
summaryBusy: false,
|
||||
summaryRun: null as FeedbackSummaryRun | null,
|
||||
summaryStatusText: '',
|
||||
summaryActionText: '生成总结',
|
||||
excludedSummaryLessonIds: [] as string[],
|
||||
selectedSummaryLessonCount: 0,
|
||||
eligibleSummaryLessonCount: 0,
|
||||
allSummaryLessonsSelected: false,
|
||||
voiceBusy: false,
|
||||
recording: false,
|
||||
recordingPaused: false,
|
||||
@@ -200,6 +238,7 @@ Page({
|
||||
onHide() {
|
||||
pageVisible = false
|
||||
this.clearVoicePollTimer()
|
||||
this.clearSummaryPollTimer()
|
||||
if (this.data.recording) this.stopRecording()
|
||||
},
|
||||
|
||||
@@ -208,6 +247,7 @@ Page({
|
||||
this.clearRecordingTimer()
|
||||
this.clearDraftSaveTimer()
|
||||
this.clearVoicePollTimer()
|
||||
this.clearSummaryPollTimer()
|
||||
if (this.data.recording) recorderManager.stop()
|
||||
},
|
||||
|
||||
@@ -277,11 +317,16 @@ Page({
|
||||
async loadActiveSession(profileId?: string | null) {
|
||||
try {
|
||||
const targetProfileId = profileId === undefined ? this.data.draft.profileId : profileId
|
||||
const previousSessionId = this.data.session?.id || null
|
||||
const session = await getActiveFeedbackSession(targetProfileId)
|
||||
if (targetProfileId !== this.data.draft.profileId) return
|
||||
const nextData: Record<string, unknown> = {
|
||||
session,
|
||||
'draft.feedbackSessionId': session?.id || null
|
||||
'draft.feedbackSessionId': session?.id || null,
|
||||
summaryRun: session?.latestSummaryRun || null
|
||||
}
|
||||
if ((session?.id || null) !== previousSessionId) {
|
||||
nextData.excludedSummaryLessonIds = []
|
||||
}
|
||||
if (session) {
|
||||
nextData['draft.feedbackDate'] = session.feedbackDate
|
||||
@@ -290,6 +335,7 @@ Page({
|
||||
}
|
||||
this.setData(nextData)
|
||||
this.syncVoicePresentation(session)
|
||||
this.syncSummaryRun(session?.latestSummaryRun || null)
|
||||
this.scheduleVoicePolling(session)
|
||||
} catch (error) {
|
||||
this.setData({ errorMessage: getApiErrorMessage(error), errorRequestId: getApiRequestId(error) })
|
||||
@@ -310,6 +356,9 @@ Page({
|
||||
voiceDetailsExpanded: false,
|
||||
expandedVoiceLessonId: '',
|
||||
voiceLessonViews: [],
|
||||
selectedSummaryLessonCount: 0,
|
||||
eligibleSummaryLessonCount: 0,
|
||||
allSummaryLessonsSelected: false,
|
||||
primaryActionText: '保存反馈'
|
||||
})
|
||||
return
|
||||
@@ -319,17 +368,39 @@ Page({
|
||||
let suffix = '已就绪'
|
||||
if (session.failedSegmentCount > 0) suffix = `${session.failedSegmentCount}段转录失败`
|
||||
else if (session.processingSegmentCount > 0) suffix = '正在处理'
|
||||
else if (session.needsGeneration) suffix = '待生成反馈'
|
||||
else if (session.needsGeneration) suffix = '可生成总结'
|
||||
const primaryAction = getVoicePrimaryAction(session)
|
||||
const voiceLessonViews = buildVoiceLessonViews(
|
||||
session,
|
||||
this.data.draft.content,
|
||||
this.data.excludedSummaryLessonIds
|
||||
)
|
||||
const eligibleSummaryLessonCount = voiceLessonViews.filter((lesson) => lesson.summaryEligible).length
|
||||
const selectedSummaryLessonCount = voiceLessonViews.filter((lesson) => lesson.summarySelected).length
|
||||
this.setData({
|
||||
voiceStatus: `${prefix} · ${suffix}`,
|
||||
voiceFailureHint: session.failedSegmentCount > 0 ? getVoiceFailureHint(session) : '',
|
||||
voiceActionable: true,
|
||||
voiceLessonViews: buildVoiceLessonViews(session, this.data.draft.content),
|
||||
voiceLessonViews,
|
||||
eligibleSummaryLessonCount,
|
||||
selectedSummaryLessonCount,
|
||||
allSummaryLessonsSelected: eligibleSummaryLessonCount > 0
|
||||
&& selectedSummaryLessonCount === eligibleSummaryLessonCount,
|
||||
primaryActionText: getVoicePrimaryActionText(primaryAction, session.failedSegmentCount)
|
||||
})
|
||||
},
|
||||
|
||||
syncSummaryRun(run: FeedbackSummaryRun | null) {
|
||||
this.setData({
|
||||
summaryRun: run,
|
||||
summaryStatusText: getSummaryStatusText(run),
|
||||
summaryActionText: run && ['ready', 'failed', 'applied'].includes(run.status)
|
||||
? '重新生成总结'
|
||||
: '生成总结'
|
||||
})
|
||||
this.scheduleSummaryPolling(run)
|
||||
},
|
||||
|
||||
showApiError(error: unknown, showToast = true) {
|
||||
const errorMessage = getApiErrorMessage(error)
|
||||
const errorRequestId = getApiRequestId(error)
|
||||
@@ -352,6 +423,42 @@ Page({
|
||||
voicePollTimer = null
|
||||
},
|
||||
|
||||
scheduleSummaryPolling(run: FeedbackSummaryRun | null) {
|
||||
this.clearSummaryPollTimer()
|
||||
if (!pageVisible || !run || (run.status !== 'queued' && run.status !== 'processing')) return
|
||||
summaryPollTimer = setTimeout(() => {
|
||||
summaryPollTimer = null
|
||||
void this.pollFeedbackSummary()
|
||||
}, SUMMARY_POLL_INTERVAL_MS) as unknown as number
|
||||
},
|
||||
|
||||
clearSummaryPollTimer() {
|
||||
if (summaryPollTimer === null) return
|
||||
clearTimeout(summaryPollTimer)
|
||||
summaryPollTimer = null
|
||||
},
|
||||
|
||||
async pollFeedbackSummary() {
|
||||
const run = this.data.summaryRun
|
||||
if (!pageVisible || !run) return
|
||||
if (summaryPollBusy) {
|
||||
this.scheduleSummaryPolling(run)
|
||||
return
|
||||
}
|
||||
summaryPollBusy = true
|
||||
try {
|
||||
const updated = await getFeedbackSummaryRun(run.id)
|
||||
if (!pageVisible || this.data.summaryRun?.id !== run.id) return
|
||||
this.syncSummaryRun(updated)
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh feedback summary status', error)
|
||||
this.showApiError(error, false)
|
||||
this.scheduleSummaryPolling(this.data.summaryRun)
|
||||
} finally {
|
||||
summaryPollBusy = false
|
||||
}
|
||||
},
|
||||
|
||||
async pollVoiceSession() {
|
||||
if (!pageVisible) return
|
||||
if (voicePollBusy || this.data.recording || this.data.voiceBusy) {
|
||||
@@ -363,8 +470,13 @@ Page({
|
||||
try {
|
||||
const session = await getActiveFeedbackSession(profileId)
|
||||
if (!pageVisible || profileId !== this.data.draft.profileId) return
|
||||
this.setData({ session, 'draft.feedbackSessionId': session?.id || null })
|
||||
this.setData({
|
||||
session,
|
||||
'draft.feedbackSessionId': session?.id || null,
|
||||
summaryRun: session?.latestSummaryRun || this.data.summaryRun
|
||||
})
|
||||
this.syncVoicePresentation(session)
|
||||
if (session?.latestSummaryRun) this.syncSummaryRun(session.latestSummaryRun)
|
||||
} catch (error) {
|
||||
console.warn('Failed to refresh voice transcription status', error)
|
||||
this.showApiError(error, false)
|
||||
@@ -384,6 +496,7 @@ Page({
|
||||
|
||||
onProfileChange(event: PickerEvent) {
|
||||
this.clearDraftSaveTimer()
|
||||
this.clearSummaryPollTimer()
|
||||
const selectedProfileIndex = Number(event.detail.value)
|
||||
const profile = this.data.profiles[selectedProfileIndex - 1]
|
||||
const profileId = profile?.id || null
|
||||
@@ -398,6 +511,13 @@ Page({
|
||||
voiceDetailsExpanded: false,
|
||||
expandedVoiceLessonId: '',
|
||||
voiceLessonViews: [],
|
||||
excludedSummaryLessonIds: [],
|
||||
selectedSummaryLessonCount: 0,
|
||||
eligibleSummaryLessonCount: 0,
|
||||
allSummaryLessonsSelected: false,
|
||||
summaryRun: null,
|
||||
summaryStatusText: '',
|
||||
summaryActionText: '生成总结',
|
||||
primaryActionText: '保存反馈'
|
||||
})
|
||||
void this.loadActiveSession(profileId)
|
||||
@@ -413,7 +533,11 @@ Page({
|
||||
this.setData({
|
||||
'draft.content': content,
|
||||
contentLength: content.length,
|
||||
voiceLessonViews: buildVoiceLessonViews(this.data.session, content)
|
||||
voiceLessonViews: buildVoiceLessonViews(
|
||||
this.data.session,
|
||||
content,
|
||||
this.data.excludedSummaryLessonIds
|
||||
)
|
||||
})
|
||||
this.scheduleDraftSave()
|
||||
},
|
||||
@@ -437,7 +561,11 @@ Page({
|
||||
this.setData({
|
||||
'draft.content': '',
|
||||
contentLength: 0,
|
||||
voiceLessonViews: buildVoiceLessonViews(this.data.session, '')
|
||||
voiceLessonViews: buildVoiceLessonViews(
|
||||
this.data.session,
|
||||
'',
|
||||
this.data.excludedSummaryLessonIds
|
||||
)
|
||||
})
|
||||
await this.persistSessionDraft()
|
||||
},
|
||||
@@ -456,23 +584,26 @@ Page({
|
||||
draftSaveTimer = null
|
||||
},
|
||||
|
||||
async persistSessionDraft() {
|
||||
persistSessionDraft(): Promise<void> {
|
||||
const session = this.data.session
|
||||
if (!session) return
|
||||
if (!session) return Promise.resolve()
|
||||
const sessionId = session.id
|
||||
try {
|
||||
const updated = await updateFeedbackSession(
|
||||
sessionId,
|
||||
this.data.draft.feedbackDate,
|
||||
this.data.draft.content
|
||||
)
|
||||
if (this.data.session?.id !== sessionId) return
|
||||
this.setData({ session: updated })
|
||||
this.syncVoicePresentation(updated)
|
||||
} catch (error) {
|
||||
console.warn('Failed to auto-save feedback session', error)
|
||||
this.showApiError(error, false)
|
||||
const feedbackDate = this.data.draft.feedbackDate
|
||||
const content = this.data.draft.content
|
||||
const save = async () => {
|
||||
try {
|
||||
const updated = await updateFeedbackSession(sessionId, feedbackDate, content)
|
||||
if (this.data.session?.id !== sessionId) return
|
||||
this.setData({ session: updated, summaryRun: updated.latestSummaryRun || this.data.summaryRun })
|
||||
this.syncVoicePresentation(updated)
|
||||
if (updated.latestSummaryRun) this.syncSummaryRun(updated.latestSummaryRun)
|
||||
} catch (error) {
|
||||
console.warn('Failed to auto-save feedback session', error)
|
||||
this.showApiError(error, false)
|
||||
}
|
||||
}
|
||||
draftSaveQueue = draftSaveQueue.then(save, save)
|
||||
return draftSaveQueue
|
||||
},
|
||||
|
||||
ensureRecordPermission(): Promise<boolean> {
|
||||
@@ -777,6 +908,99 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
onSummarySelectionChange(event: CheckboxGroupEvent) {
|
||||
const selectedLessonIds = new Set(event.detail.value)
|
||||
const excludedSummaryLessonIds = this.data.voiceLessonViews
|
||||
.filter((lesson) => lesson.summaryEligible && !selectedLessonIds.has(lesson.id))
|
||||
.map((lesson) => lesson.id)
|
||||
this.setData({ excludedSummaryLessonIds })
|
||||
this.syncVoicePresentation(this.data.session)
|
||||
},
|
||||
|
||||
toggleAllSummaryLessons() {
|
||||
if (this.data.eligibleSummaryLessonCount === 0) return
|
||||
const excludedSummaryLessonIds = this.data.allSummaryLessonsSelected
|
||||
? this.data.voiceLessonViews
|
||||
.filter((lesson) => lesson.summaryEligible)
|
||||
.map((lesson) => lesson.id)
|
||||
: []
|
||||
this.setData({ excludedSummaryLessonIds })
|
||||
this.syncVoicePresentation(this.data.session)
|
||||
},
|
||||
|
||||
async generateFeedbackSummary() {
|
||||
const session = this.data.session
|
||||
if (!session || this.data.summaryBusy || this.data.recording || this.data.voiceBusy) return
|
||||
if (this.data.summaryRun?.status === 'queued' || this.data.summaryRun?.status === 'processing') {
|
||||
wx.showToast({ title: '当前总结仍在生成', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const lessonIds = this.data.voiceLessonViews
|
||||
.filter((lesson) => lesson.summarySelected)
|
||||
.map((lesson) => lesson.id)
|
||||
if (lessonIds.length === 0) {
|
||||
wx.showToast({ title: '请至少选择一节已就绪录音', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({ summaryBusy: true, errorMessage: '', errorRequestId: '' })
|
||||
try {
|
||||
const run = await createFeedbackSummaryRun(
|
||||
session.id,
|
||||
lessonIds,
|
||||
this.data.draft.content,
|
||||
createSummaryIdempotencyKey(session.id)
|
||||
)
|
||||
logVoiceOperation('feedback_summary_queued', {
|
||||
feedbackSessionId: session.id,
|
||||
summaryRunId: run.id,
|
||||
lessonIds
|
||||
})
|
||||
this.syncSummaryRun(run)
|
||||
wx.showToast({ title: '已开始生成总结', icon: 'none' })
|
||||
} catch (error) {
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.setData({ summaryBusy: false })
|
||||
}
|
||||
},
|
||||
|
||||
confirmApplySummary(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
wx.showModal({
|
||||
title: '替换反馈内容',
|
||||
content: '将使用预览总结替换当前输入框内容,原始语音记录仍会保留。',
|
||||
confirmText: '确认替换',
|
||||
success: (result) => resolve(result.confirm),
|
||||
fail: () => resolve(false)
|
||||
})
|
||||
})
|
||||
},
|
||||
|
||||
async applyGeneratedSummary() {
|
||||
const run = this.data.summaryRun
|
||||
if (!run || run.status !== 'ready' || !(await this.confirmApplySummary())) return
|
||||
this.clearDraftSaveTimer()
|
||||
this.setData({ summaryBusy: true, errorMessage: '', errorRequestId: '' })
|
||||
try {
|
||||
await this.persistSessionDraft()
|
||||
const session = await applyFeedbackSummaryRun(run.id)
|
||||
this.setData({
|
||||
session,
|
||||
summaryRun: session.latestSummaryRun,
|
||||
'draft.content': session.content,
|
||||
contentLength: session.content.length
|
||||
})
|
||||
this.syncVoicePresentation(session)
|
||||
this.syncSummaryRun(session.latestSummaryRun)
|
||||
wx.showToast({ title: '总结已替换输入框', icon: 'none' })
|
||||
} catch (error) {
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.setData({ summaryBusy: false })
|
||||
}
|
||||
},
|
||||
|
||||
async retryFailedSegments() {
|
||||
const session = this.data.session
|
||||
if (!session) return
|
||||
@@ -878,8 +1102,9 @@ Page({
|
||||
this.scheduleVoicePolling(session)
|
||||
return
|
||||
}
|
||||
if (session?.needsGeneration) {
|
||||
await this.generateFeedback()
|
||||
if (this.data.summaryRun?.status === 'queued' || this.data.summaryRun?.status === 'processing') {
|
||||
wx.showToast({ title: '总结仍在生成,请稍候', icon: 'none' })
|
||||
this.scheduleSummaryPolling(this.data.summaryRun)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -896,6 +1121,7 @@ Page({
|
||||
content,
|
||||
feedbackSessionId: session?.id || null
|
||||
})
|
||||
this.clearSummaryPollTimer()
|
||||
this.setData({
|
||||
draft: buildDraft(),
|
||||
session: null,
|
||||
@@ -903,6 +1129,15 @@ Page({
|
||||
contentLength: 0,
|
||||
voiceStatus: '语音录入',
|
||||
voiceActionable: false,
|
||||
voiceDetailsExpanded: false,
|
||||
voiceLessonViews: [],
|
||||
excludedSummaryLessonIds: [],
|
||||
selectedSummaryLessonCount: 0,
|
||||
eligibleSummaryLessonCount: 0,
|
||||
allSummaryLessonsSelected: false,
|
||||
summaryRun: null,
|
||||
summaryStatusText: '',
|
||||
summaryActionText: '生成总结',
|
||||
primaryActionText: '保存反馈'
|
||||
})
|
||||
wx.showToast({ title: '反馈已保存', icon: 'success' })
|
||||
@@ -914,34 +1149,6 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
async generateFeedback() {
|
||||
const session = this.data.session
|
||||
if (!session) return
|
||||
this.setData({
|
||||
generating: true,
|
||||
voiceBusy: true,
|
||||
voiceStatus: '正在生成反馈',
|
||||
errorMessage: '',
|
||||
errorRequestId: ''
|
||||
})
|
||||
try {
|
||||
const result = await runWithSessionRefresh(
|
||||
async () => generateFeedbackFromVoice(session.id, this.data.draft.content),
|
||||
async () => this.loadActiveSession()
|
||||
)
|
||||
this.setData({
|
||||
'draft.content': result.content,
|
||||
contentLength: result.content.length
|
||||
})
|
||||
wx.showToast({ title: '反馈已生成,可继续修改', icon: 'none' })
|
||||
} catch (error) {
|
||||
this.showApiError(error)
|
||||
} finally {
|
||||
this.setData({ generating: false, voiceBusy: false })
|
||||
this.syncVoicePresentation(this.data.session)
|
||||
}
|
||||
},
|
||||
|
||||
goStudents() {
|
||||
wx.switchTab({ url: '/pages/students/index' })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user