Explorar o código

feat: project-scoped Force Start Stuck Jobs for admins and editors

- Both global ADMIN and project ADMIN/EDITOR can now:
  - See the "Force Start Stuck Jobs" button
  - Call /api/assets/admin/stuck-count (projectId param)
  - Call /api/assets/admin/reprocess-all (projectId in body)
- stuck-count endpoint: if projectId provided, checks project membership
  (ADMIN/EDITOR) instead of global ADMIN only
- reprocess-all endpoint: same project-scoped auth
- Button renamed from "Force Reprocess All" → "Force Start Stuck Jobs"
- Badge shows project-wide stuck count (was workspace-wide before)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
kingkong hai 1 mes
pai
achega
0eec6d685d

+ 72 - 25
packages/api/src/routes/assets.ts

@@ -514,42 +514,89 @@ router.delete('/:id', async (req: Request, res: Response) => {
 
 // ── Admin routes (registered before /:id so /admin/* is never shadowed) ────
 
-// GET /api/assets/admin/stuck-count — lightweight workspace-wide stuck job count
+// GET /api/assets/admin/stuck-count — count stuck jobs (workspace-wide or per-project)
+// Allowed for: global ADMIN, or project ADMIN/EDITOR
 router.get('/admin/stuck-count', async (req: Request, res: Response) => {
   try {
-    if (req.user!.globalRole !== 'ADMIN') {
-      res.status(403).json({ error: 'Admin access required' });
-      return;
+    const { projectId } = req.query as { projectId?: string };
+    const isGlobalAdmin = req.user!.globalRole === 'ADMIN';
+    if (projectId) {
+      // Project-scoped: also allow project ADMIN/EDITOR
+      const membership = await prisma.projectMember.findFirst({
+        where: { projectId, userId: req.user!.userId, role: { in: ['ADMIN', 'EDITOR'] } },
+      });
+      if (!isGlobalAdmin && !membership) {
+        res.status(403).json({ error: 'Forbidden' });
+        return;
+      }
+      const count = await prisma.asset.count({
+        where: { projectId, transcodeStatus: 'PROCESSING', transcodePaused: false },
+      });
+      res.json({ count });
+    } else {
+      // Workspace-wide: global ADMIN only
+      if (!isGlobalAdmin) {
+        res.status(403).json({ error: 'Admin access required' });
+        return;
+      }
+      const count = await prisma.asset.count({
+        where: { transcodeStatus: 'PROCESSING', transcodePaused: false },
+      });
+      res.json({ count });
     }
-    const count = await prisma.asset.count({
-      where: { transcodeStatus: 'PROCESSING', transcodePaused: false },
-    });
-    res.json({ count });
   } catch (err) {
     res.status(500).json({ error: 'Internal server error' });
   }
 });
 
-// POST /api/assets/admin/reprocess-all — admin-only: reset all PROCESSING jobs to PENDING
+// POST /api/assets/admin/reprocess-all — reset stuck jobs (workspace-wide or per-project)
+// Allowed for: global ADMIN, or project ADMIN/EDITOR
 router.post('/admin/reprocess-all', async (req: Request, res: Response) => {
   try {
-    if (req.user!.globalRole !== 'ADMIN') {
-      res.status(403).json({ error: 'Admin access required' });
-      return;
-    }
-    const stuck = await prisma.asset.findMany({
-      where: { transcodeStatus: 'PROCESSING', transcodePaused: false },
-      select: { id: true },
-    });
-    if (stuck.length === 0) {
-      res.json({ message: 'No stuck jobs found', count: 0 });
-      return;
+    const { projectId } = req.body as { projectId?: string };
+    const isGlobalAdmin = req.user!.globalRole === 'ADMIN';
+    if (projectId) {
+      // Project-scoped: also allow project ADMIN/EDITOR
+      const membership = await prisma.projectMember.findFirst({
+        where: { projectId, userId: req.user!.userId, role: { in: ['ADMIN', 'EDITOR'] } },
+      });
+      if (!isGlobalAdmin && !membership) {
+        res.status(403).json({ error: 'Forbidden' });
+        return;
+      }
+      const stuck = await prisma.asset.findMany({
+        where: { projectId, transcodeStatus: 'PROCESSING', transcodePaused: false },
+        select: { id: true },
+      });
+      if (stuck.length === 0) {
+        res.json({ message: 'No stuck jobs found', count: 0 });
+        return;
+      }
+      await prisma.asset.updateMany({
+        where: { id: { in: stuck.map(s => s.id) } },
+        data: { transcodeStatus: 'PENDING', transcodeProgress: 0 },
+      });
+      res.json({ message: `Reset ${stuck.length} stuck job(s) to PENDING`, count: stuck.length });
+    } else {
+      // Workspace-wide: global ADMIN only
+      if (!isGlobalAdmin) {
+        res.status(403).json({ error: 'Admin access required' });
+        return;
+      }
+      const stuck = await prisma.asset.findMany({
+        where: { transcodeStatus: 'PROCESSING', transcodePaused: false },
+        select: { id: true },
+      });
+      if (stuck.length === 0) {
+        res.json({ message: 'No stuck jobs found', count: 0 });
+        return;
+      }
+      await prisma.asset.updateMany({
+        where: { id: { in: stuck.map(s => s.id) } },
+        data: { transcodeStatus: 'PENDING', transcodeProgress: 0 },
+      });
+      res.json({ message: `Reset ${stuck.length} stuck job(s) to PENDING`, count: stuck.length });
     }
-    await prisma.asset.updateMany({
-      where: { id: { in: stuck.map(s => s.id) } },
-      data: { transcodeStatus: 'PENDING', transcodeProgress: 0 },
-    });
-    res.json({ message: `Reset ${stuck.length} stuck job(s) to PENDING`, count: stuck.length });
   } catch (err) {
     console.error('Reprocess-all error:', err);
     res.status(500).json({ error: 'Internal server error' });

+ 5 - 5
src/app/(dashboard)/projects/[projectId]/page.tsx

@@ -196,21 +196,21 @@ export default function ProjectDetailPage() {
   const isOwner = project?.ownerId === user?.id;
   const isAdmin = user?.globalRole === 'ADMIN';
 
-  // Poll workspace-wide stuck job count every 30s (admins can use it; badge shows for canManage)
+  // Poll workspace-wide stuck job count every 30s (admins + editors can use it)
   useEffect(() => {
-    if (!isAdmin || !token) return;
+    if ((!isAdmin && !canManage) || !token) return;
     let cancelled = false;
     async function fetchStuckCount() {
       const t = token as string;
       try {
-        const data = await assetsApi.getStuckCount(t);
+        const data = await assetsApi.getStuckCount(t, projectId as string);
         if (!cancelled) setGlobalStuckCount(data.count ?? 0);
       } catch {}
     }
     fetchStuckCount();
     const id = setInterval(fetchStuckCount, 30_000);
     return () => { cancelled = true; clearInterval(id); };
-  }, [isAdmin, token]);
+  }, [isAdmin, canManage, token]);
 
   // ── Folder data derived from state ──────────────────────────────────────────
   // For file mode: only assets directly in the selected folder
@@ -1001,7 +1001,7 @@ export default function ProjectDetailPage() {
                 if (!token) return;
                 setReprocessingAll(true);
                 try {
-                  const result = await assetsApi.reprocessAll(token);
+                  const result = await assetsApi.reprocessAll(token, projectId as string);
                   // Reset all PROCESSING assets in local state
                   setAssets(prev => prev.map(a =>
                     a.transcodeStatus === 'PROCESSING'

+ 5 - 5
src/components/transcode/TranscodeTasksPanel.tsx

@@ -296,8 +296,8 @@ export function TranscodeTasksPanel({ assets, canManage, isAdmin, onDelete, onCa
         ))}
       </div>
 
-      {/* Admin / Editor: Force Reprocess All */}
-      {canManage && (
+      {/* Admin / Editor: Force Start Stuck Jobs */}
+      {(isAdmin || canManage) && (
         <div className="flex justify-end mb-4">
           <button
             onClick={onReprocessAll}
@@ -308,7 +308,7 @@ export function TranscodeTasksPanel({ assets, canManage, isAdmin, onDelete, onCa
               color: '#FB923C',
               border: '1px solid rgba(251,146,60,0.20)',
             }}
-            title="Reset all stuck PROCESSING jobs to PENDING"
+            title="Force-start all stuck PROCESSING jobs in this project"
           >
             {isReprocessingAll ? (
               <div className="w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full animate-spin" />
@@ -317,10 +317,10 @@ export function TranscodeTasksPanel({ assets, canManage, isAdmin, onDelete, onCa
                 <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />
               </svg>
             )}
-            <span>{isReprocessingAll ? 'Resetting…' : 'Force Reprocess All'}</span>
+            <span>{isReprocessingAll ? 'Resetting…' : 'Force Start Stuck Jobs'}</span>
             {globalStuckCount > 0 && !isReprocessingAll && (
               <span className="text-[10px] px-1.5 py-0.5 rounded-full" style={{ background: 'rgba(251,146,60,0.20)' }}>
-                {stuckCount} stuck
+                {globalStuckCount} stuck
               </span>
             )}
           </button>

+ 11 - 5
src/lib/api.ts

@@ -155,11 +155,17 @@ export const assetsApi = {
   resumeTranscode: (token: string, id: string) =>
     apiFetch<{ asset: Asset }>(`/api/assets/${id}/transcode/resume`, { method: 'POST', token }),
 
-  reprocessAll: (token: string) =>
-    apiFetch<{ message: string; count: number }>(`/api/assets/admin/reprocess-all`, { method: 'POST', token }),
-
-  getStuckCount: (token: string) =>
-    apiFetch<{ count: number }>(`/api/assets/admin/stuck-count`, { token }),
+  reprocessAll: (token: string, projectId?: string) =>
+    apiFetch<{ message: string; count: number }>(
+      `/api/assets/admin/reprocess-all`,
+      { method: 'POST', token, body: JSON.stringify({ projectId }) }
+    ),
+
+  getStuckCount: (token: string, projectId?: string) =>
+    apiFetch<{ count: number }>(
+      `/api/assets/admin/stuck-count${projectId ? `?projectId=${projectId}` : ''}`,
+      { token }
+    ),
 };
 
 // ── Comments ─────────────────────────────────────────────────────────────────