import { restoreAgent } from '@/lib/oauth' import { requireSession } from '@/lib/session' import { requireListScopes } from '@/lib/scope-gate' import { getBlockEvent, updateBlockEvent, deleteBlock, getListSubscriptionRkey, unsubscribeFromList, } from '@/lib/lexicon' import { isEventJobRunning } from '@/lib/queue' import { sleep } from '@/lib/utils' type ListAction = 'none' | 'unsubscribe' | 'delete' export async function POST(request: Request) { let session: { did: string } try { session = await requireSession() } catch { return Response.json({ error: 'Not authenticated' }, { status: 401 }) } let body: { eventRkey: string; listAction?: ListAction } try { body = await request.json() } catch { return Response.json({ error: 'Invalid request body' }, { status: 400 }) } const { eventRkey, listAction = 'none' } = body if (!eventRkey) { return Response.json({ error: 'eventRkey is required' }, { status: 400 }) } try { const agent = await restoreAgent(session.did) const { record } = await getBlockEvent(agent, session.did, eventRkey) if (isEventJobRunning(eventRkey)) { return Response.json( { error: 'A block job is already running for this event.' }, { status: 409 } ) } // Undoing blocks alone needs only base scopes; unsubscribing from or // deleting the shared list needs list scopes. Check before deleting any // blocks so the user isn't left with a half-completed combined action. if (record.listUri && listAction !== 'none') { const scopeGate = await requireListScopes(session.did) if (scopeGate) return scopeGate } if (record.blockedRkeys.length === 0) { return Response.json({ removed: 0 }) } let removed = 0 let failed = 0 const remainingRkeys: string[] = [] // Delete blocks with gentle pacing to respect rate limits for (const rkey of record.blockedRkeys) { let deleted = false try { await deleteBlock(agent, session.did, rkey) removed++ deleted = true } catch (err: unknown) { const status = (err as { status?: number })?.status if (status === 429) { // Back off on rate limit await sleep(30_000) try { await deleteBlock(agent, session.did, rkey) removed++ deleted = true } catch { failed++ } } else if (status === 400) { // Record doesn't exist (already deleted) — count as removed removed++ deleted = true } else { failed++ console.error(`[undo] Failed to delete block rkey ${rkey}:`, err) } } if (!deleted) remainingRkeys.push(rkey) // Keep this blocking route from taking minutes on large events. A full // queued/SSE undo would be better long-term; for now pace in small batches // and preserve failed rkeys so a later undo can retry them. if (removed > 0 && removed % 10 === 0) await sleep(1000) } // Handle list action if a list was previously created let listCleared = false if (record.listUri && listAction !== 'none') { try { // Unsubscribe in both cases const subscriptionRkey = await getListSubscriptionRkey(agent, session.did, record.listUri) if (subscriptionRkey) { await unsubscribeFromList(agent, session.did, subscriptionRkey) } if (listAction === 'delete') { const listRkey = record.listUri.split('/').pop() if (listRkey) { await agent.com.atproto.repo.deleteRecord({ repo: session.did, collection: 'app.bsky.graph.list', rkey: listRkey, }) } listCleared = true } } catch (err) { // Log but don't fail the whole undo — blocks are already removed console.error('[undo] List action failed:', err) } } // Update the event record. blockedDids is intentionally preserved — it's // the only record of which accounts were blocked, and is required for re-do. // Only clear rkeys that were actually deleted; failed rkeys remain retryable. const fullyUndone = remainingRkeys.length === 0 await updateBlockEvent(agent, session.did, eventRkey, { ...record, blockedRkeys: remainingRkeys, failedDids: fullyUndone ? [] : record.failedDids, pendingDids: fullyUndone ? [] : record.pendingDids, undoneAt: fullyUndone ? new Date().toISOString() : record.undoneAt, // Clear listUri if the list was deleted so the link doesn't show ...(listCleared ? { listUri: undefined } : {}), stats: fullyUndone ? { total: record.stats?.total ?? 0, completed: 0, failed: 0 } : { total: record.stats?.total ?? record.blockedRkeys.length, completed: remainingRkeys.length, failed, }, }) return Response.json({ removed, failed, remaining: remainingRkeys.length, listAction }) } catch (err) { console.error('[api/blocks/undo]', err) return Response.json({ error: 'Failed to undo blocks' }, { status: 500 }) } }