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
This commit is contained in:
Calum H.
2026-01-12 17:08:30 +00:00
committed by GitHub
parent 915d8c68bf
commit b46f6d0141
21 changed files with 1644 additions and 321 deletions

View File

@@ -127,11 +127,9 @@ import dayjs from 'dayjs'
import { computed } from 'vue'
import type { ModerationProject } from '~/helpers/moderation'
import { useModerationStore } from '~/store/moderation.ts'
const { addNotification } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const moderationStore = useModerationStore()
const baseId = useId()
@@ -139,6 +137,10 @@ const props = defineProps<{
queueEntry: ModerationProject
}>()
const emit = defineEmits<{
startFromProject: [projectId: string]
}>()
function getDaysQueued(date: Date): number {
const now = new Date()
const diff = now.getTime() - date.getTime()
@@ -201,16 +203,6 @@ const quickActions: OverflowMenuOption[] = [
]
function openProjectForReview() {
moderationStore.setSingleProject(props.queueEntry.project.id)
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: props.queueEntry.project.slug || props.queueEntry.project.id,
},
state: {
showChecklist: true,
},
})
emit('startFromProject', props.queueEntry.project.id)
}
</script>

View File

@@ -0,0 +1,56 @@
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
import { useServerModrinthClient } from '~/server/utils/api-client'
// All valid project type URL segments
const PROJECT_TYPES = ['project', 'mod', 'plugin', 'datapack', 'shader', 'resourcepack', 'modpack']
export default defineNuxtRouteMiddleware(async (to) => {
// Only handle project routes
if (!to.params.id || !PROJECT_TYPES.includes(to.params.type as string)) {
return
}
const authToken = useCookie('auth-token')
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
const tags = useGeneratedState()
try {
const project = await client.labrinth.projects_v2.get(to.params.id as string)
if (!project) {
return
}
// Determine the correct URL type
const correctType = getProjectTypeForUrlShorthand(
project.project_type,
project.loaders,
tags.value,
)
// Preserve the rest of the path (subpages like /versions, /settings, etc.)
const pathParts = to.path.split('/')
pathParts.splice(0, 3) // Remove '', type, and id
const remainder = pathParts.filter((x) => x).join('/')
// Build the canonical path
const canonicalPath = `/${correctType}/${project.slug}${remainder ? `/${remainder}` : ''}`
// Only redirect if the path actually changed
if (to.path !== canonicalPath) {
return navigateTo(
{
path: canonicalPath,
query: to.query,
hash: to.hash,
},
{
redirectCode: 301,
replace: true,
},
)
}
} catch {
// Let the page handle 404s and other errors
}
})

View File

@@ -1019,8 +1019,12 @@ import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { useModerationStore } from '~/store/moderation.ts'
import { reportProject } from '~/utils/report-helpers.ts'
definePageMeta({
key: (route) => route.fullPath,
})
const data = useNuxtApp()
const route = useNativeRoute()
const route = useRoute()
const config = useRuntimeConfig()
const moderationStore = useModerationStore()
const notifications = injectNotificationManager()
@@ -1539,8 +1543,6 @@ try {
),
])
await updateProjectRoute()
versions = shallowRef(toRaw(versions))
versionsV3 = shallowRef(toRaw(versionsV3))
versions.value = (versions.value ?? []).map((v) => ({
@@ -1621,22 +1623,6 @@ if (!project.value) {
})
}
if (
project.value.project_type !== route.params.type ||
(route.params.id !== project.value.slug && !flags.value.disablePrettyProjectUrlRedirects)
) {
let path = route.fullPath.split('/')
path.splice(0, 3)
path = path.filter((x) => x)
await navigateTo(
`/${project.value.project_type}/${project.value.slug}${
path.length > 0 ? `/${path.join('/')}` : ''
}`,
{ redirectCode: 301, replace: true },
)
}
// Members should be an array of all members, without the accepted ones, and with the user with the Owner role at the start
// The rest of the members should be sorted by role, then by name
const members = computed(() => {

View File

@@ -89,6 +89,7 @@
:queue-entry="item"
:owner="item.owner"
:org="item.org"
@start-from-project="startFromProject"
/>
</div>
@@ -112,6 +113,7 @@ import {
Combobox,
type ComboboxOption,
defineMessages,
injectNotificationManager,
Pagination,
useVIntl,
} from '@modrinth/ui'
@@ -123,6 +125,7 @@ import { enrichProjectBatch, type ModerationProject } from '~/helpers/moderation
import { useModerationStore } from '~/store/moderation.ts'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const moderationStore = useModerationStore()
const route = useRoute()
const router = useRouter()
@@ -342,13 +345,105 @@ function goToPage(page: number) {
currentPage.value = page
}
function moderateAllInFilter() {
moderationStore.setQueue(filteredProjects.value.map((queueItem) => queueItem.project.id))
async function findFirstUnlockedProject(): Promise<ModerationProject | null> {
let skippedCount = 0
while (moderationStore.hasItems) {
const currentId = moderationStore.getCurrentProjectId()
if (!currentId) return null
const project = filteredProjects.value.find((p) => p.project.id === currentId)
if (!project) {
moderationStore.completeCurrentProject(currentId, 'skipped')
continue
}
try {
const lockStatus = await moderationStore.checkLock(currentId)
if (!lockStatus.locked || lockStatus.expired) {
if (skippedCount > 0) {
addNotification({
title: 'Skipped locked projects',
text: `Skipped ${skippedCount} project(s) being moderated by others.`,
type: 'info',
})
}
return project
}
// Project is locked, skip it
moderationStore.completeCurrentProject(currentId, 'skipped')
skippedCount++
} catch {
return project
}
}
return null
}
async function moderateAllInFilter() {
// Start from the current page - get projects from current page onwards
const startIndex = (currentPage.value - 1) * itemsPerPage
const projectsFromCurrentPage = filteredProjects.value.slice(startIndex)
const projectIds = projectsFromCurrentPage.map((queueItem) => queueItem.project.id)
moderationStore.setQueue(projectIds)
// Find first unlocked project
const targetProject = await findFirstUnlockedProject()
if (!targetProject) {
addNotification({
title: 'All projects locked',
text: 'All projects in queue are currently being moderated by others.',
type: 'warning',
})
return
}
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: moderationStore.getCurrentProjectId(),
id: targetProject.project.slug,
},
state: {
showChecklist: true,
},
})
}
async function startFromProject(projectId: string) {
// Find the index of the clicked project in the filtered list
const projectIndex = filteredProjects.value.findIndex((p) => p.project.id === projectId)
if (projectIndex === -1) {
// Project not found in filtered list, just moderate it alone
moderationStore.setSingleProject(projectId)
} else {
// Start queue from this project onwards
const projectsFromHere = filteredProjects.value.slice(projectIndex)
const projectIds = projectsFromHere.map((queueItem) => queueItem.project.id)
moderationStore.setQueue(projectIds)
}
// Find first unlocked project
const targetProject = await findFirstUnlockedProject()
if (!targetProject) {
addNotification({
title: 'All projects locked',
text: 'All projects in queue are currently being moderated by others.',
type: 'warning',
})
return
}
navigateTo({
name: 'type-id',
params: {
type: 'project',
id: targetProject.project.slug,
},
state: {
showChecklist: true,

View File

@@ -6,7 +6,7 @@ const CACHE_MAX_AGE = 60 * 10 // 10 minutes
export default defineCachedEventHandler(
async (event) => {
const client = useServerModrinthClient(event)
const client = useServerModrinthClient({ event })
const response = await client.request<Labrinth.Tags.v2.GameVersion[]>('/tag/game_version', {
api: 'labrinth',

View File

@@ -1,4 +1,4 @@
import { type NuxtClientConfig, NuxtModrinthClient } from '@modrinth/api-client'
import { AuthFeature, type NuxtClientConfig, NuxtModrinthClient } from '@modrinth/api-client'
import type { H3Event } from 'h3'
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
@@ -11,14 +11,30 @@ async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
}
}
export function useServerModrinthClient(event: H3Event): NuxtModrinthClient {
const config = useRuntimeConfig(event)
export interface ServerModrinthClientOptions {
event?: H3Event
authToken?: string
}
export function useServerModrinthClient(options?: ServerModrinthClientOptions): NuxtModrinthClient {
const config = useRuntimeConfig(options?.event)
const apiBaseUrl = (config.apiBaseUrl || config.public.apiBaseUrl).replace('/v2/', '/')
const features = []
if (options?.authToken) {
features.push(
new AuthFeature({
token: options.authToken,
tokenPrefix: '',
}),
)
}
const clientConfig: NuxtClientConfig = {
labrinthBaseUrl: apiBaseUrl,
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
features: [],
features,
}
return new NuxtModrinthClient(clientConfig)

View File

@@ -9,6 +9,26 @@ export interface ModerationQueue {
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: [],
@@ -28,6 +48,8 @@ pinia.use(piniaPluginPersistedstate)
export const useModerationStore = defineStore('moderation', {
state: () => ({
currentQueue: createEmptyQueue(),
currentLock: null as { projectId: string; lockedAt: Date } | null,
isQueueMode: false,
}),
getters: {
@@ -41,6 +63,7 @@ export const useModerationStore = defineStore('moderation', {
actions: {
setQueue(projectIDs: string[]) {
this.isQueueMode = true
this.currentQueue = {
items: [...projectIDs],
total: projectIDs.length,
@@ -51,6 +74,7 @@ export const useModerationStore = defineStore('moderation', {
},
setSingleProject(projectId: string) {
this.isQueueMode = false
this.currentQueue = {
items: [projectId],
total: 1,
@@ -78,8 +102,71 @@ export const useModerationStore = defineStore('moderation', {
},
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: {