Files
AstralRinth/apps/frontend/src/store/moderation.ts
Calum H. b46f6d0141 feat: moderation locking (#5070)
* feat: base locking impl

* feat: lock logic in place in rev endpoint + fetch rev

* feat: frontend impl and finalize

* feat: auto skip if using the moderation queue page

* fix: qa issues

* fix: async state + locking fix

* fix: lint

* fix: fmt

* fix: qa issue

* fix: qa + redirect bug

* fix: lint

* feat: delete all locks endpoint for admins

* fix: dedupe

* fix: fmt

* fix: project redirect move to middleware

* fix: lint
2026-01-12 17:08:30 +00:00

186 lines
4.4 KiB
TypeScript

import { createPinia, defineStore } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
export interface ModerationQueue {
items: string[]
total: number
completed: number
skipped: number
lastUpdated: Date
}
export interface LockedByUser {
id: string
username: string
avatar_url?: string
}
export interface LockStatusResponse {
locked: boolean
locked_by?: LockedByUser
locked_at?: string
expired?: boolean
}
export interface LockAcquireResponse {
success: boolean
locked_by?: LockedByUser
locked_at?: string
expired?: boolean
}
const EMPTY_QUEUE: Partial<ModerationQueue> = {
items: [],
// TODO: Consider some form of displaying this in the checklist, maybe at the end
total: 0,
completed: 0,
skipped: 0,
}
function createEmptyQueue(): ModerationQueue {
return { ...EMPTY_QUEUE, lastUpdated: new Date() } as ModerationQueue
}
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
export const useModerationStore = defineStore('moderation', {
state: () => ({
currentQueue: createEmptyQueue(),
currentLock: null as { projectId: string; lockedAt: Date } | null,
isQueueMode: false,
}),
getters: {
queueLength: (state) => state.currentQueue.items.length,
hasItems: (state) => state.currentQueue.items.length > 0,
progress: (state) => {
if (state.currentQueue.total === 0) return 0
return (state.currentQueue.completed + state.currentQueue.skipped) / state.currentQueue.total
},
},
actions: {
setQueue(projectIDs: string[]) {
this.isQueueMode = true
this.currentQueue = {
items: [...projectIDs],
total: projectIDs.length,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
}
},
setSingleProject(projectId: string) {
this.isQueueMode = false
this.currentQueue = {
items: [projectId],
total: 1,
completed: 0,
skipped: 0,
lastUpdated: new Date(),
}
},
completeCurrentProject(projectId: string, status: 'completed' | 'skipped' = 'completed') {
if (status === 'completed') {
this.currentQueue.completed++
} else {
this.currentQueue.skipped++
}
this.currentQueue.items = this.currentQueue.items.filter((id: string) => id !== projectId)
this.currentQueue.lastUpdated = new Date()
return this.currentQueue.items.length > 0
},
getCurrentProjectId(): string | null {
return this.currentQueue.items[0] || null
},
resetQueue() {
this.isQueueMode = false
this.currentQueue = createEmptyQueue()
},
async acquireLock(projectId: string): Promise<LockAcquireResponse> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'POST',
internal: true,
})) as LockAcquireResponse
if (response.success) {
this.currentLock = { projectId, lockedAt: new Date() }
}
return response
} catch (error) {
console.error('Failed to acquire moderation lock:', error)
// Return a failed response so the UI can handle it gracefully
return { success: false }
}
},
async releaseLock(projectId: string): Promise<boolean> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'DELETE',
internal: true,
})) as { success: boolean }
if (this.currentLock?.projectId === projectId) {
this.currentLock = null
}
return response.success
} catch {
return false
}
},
async checkLock(projectId: string): Promise<LockStatusResponse> {
try {
const response = (await useBaseFetch(`moderation/lock/${projectId}`, {
method: 'GET',
internal: true,
})) as LockStatusResponse
return response
} catch (error) {
console.error('Failed to check moderation lock:', error)
// Return unlocked status on error so moderation can proceed
return { locked: false }
}
},
async refreshLock(): Promise<boolean> {
if (!this.currentLock) return false
try {
const response = await this.acquireLock(this.currentLock.projectId)
return response.success
} catch (error) {
console.error('Failed to refresh moderation lock:', error)
return false
}
},
},
persist: {
key: 'moderation-store',
serializer: {
serialize: JSON.stringify,
deserialize: (value: string) => {
const parsed = JSON.parse(value)
if (parsed.currentQueue?.lastUpdated) {
parsed.currentQueue.lastUpdated = new Date(parsed.currentQueue.lastUpdated)
}
return parsed
},
},
},
})