mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add manual-import and download-access features
Introduce manual import workflow and download permission support. Adds a Prisma migration and schema field (users.download_access) to track per-user download access, and updates admin UI to toggle global and per-user download access. Implements new APIs: filesystem browse, manual-import endpoint, download-access settings, audiobook download-status, and on-demand download-token generation. Adds frontend components for manual import and related tests, plus documentation for the manual-import feature and the documentation-agent prompt. Key files: prisma/migrations/20260212000000_add_download_access_permission/migration.sql, prisma/schema.prisma, src/app/api/admin/filesystem/browse/route.ts, src/app/api/admin/manual-import/route.ts, src/app/api/admin/settings/download-access/route.ts, src/app/api/requests/[id]/download-token/route.ts, src/app/api/audiobooks/[asin]/download-status/route.ts, and updated admin users pages/components and permissions util.
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Component: Admin Filesystem Browse API
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*
|
||||
* Lets admins browse server directories for manual audiobook import.
|
||||
* Restricted to download_dir and media_dir roots only.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Filesystem.Browse');
|
||||
|
||||
interface DirectoryEntry {
|
||||
name: string;
|
||||
type: 'directory';
|
||||
audioFileCount: number;
|
||||
subfolderCount: number;
|
||||
totalSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan immediate children of a directory to gather audio file and subfolder stats.
|
||||
*/
|
||||
async function getDirectoryStats(
|
||||
dirPath: string
|
||||
): Promise<{ audioFileCount: number; subfolderCount: number; totalSize: number }> {
|
||||
const fs = await import('fs/promises');
|
||||
const pathModule = await import('path');
|
||||
|
||||
let audioFileCount = 0;
|
||||
let subfolderCount = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
try {
|
||||
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
for (const child of children) {
|
||||
if (child.isDirectory()) {
|
||||
subfolderCount++;
|
||||
} else if (child.isFile()) {
|
||||
const ext = pathModule.extname(child.name).toLowerCase();
|
||||
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
audioFileCount++;
|
||||
try {
|
||||
const stat = await fs.stat(pathModule.join(dirPath, child.name));
|
||||
totalSize += stat.size;
|
||||
} catch {
|
||||
/* skip unreadable files */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* directory not readable */
|
||||
}
|
||||
|
||||
return { audioFileCount, subfolderCount, totalSize };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load allowed root directories from Configuration table.
|
||||
*/
|
||||
const BOOKDROP_PATH = '/bookdrop';
|
||||
|
||||
async function getAllowedRoots(): Promise<{ downloadDir: string | null; mediaDir: string | null; bookdropExists: boolean }> {
|
||||
const downloadDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_dir' },
|
||||
});
|
||||
const mediaDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'media_dir' },
|
||||
});
|
||||
|
||||
let bookdropExists = false;
|
||||
try {
|
||||
const fs = await import('fs/promises');
|
||||
const stat = await fs.stat(BOOKDROP_PATH);
|
||||
bookdropExists = stat.isDirectory();
|
||||
} catch {
|
||||
/* not mounted */
|
||||
}
|
||||
|
||||
return {
|
||||
downloadDir: downloadDirConfig?.value || null,
|
||||
mediaDir: mediaDirConfig?.value || null,
|
||||
bookdropExists,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a normalized path is within one of the allowed roots.
|
||||
*/
|
||||
function isPathAllowed(normalizedPath: string, roots: string[]): boolean {
|
||||
return roots.some(
|
||||
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const pathModule = await import('path');
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const { downloadDir, mediaDir, bookdropExists } = await getAllowedRoots();
|
||||
const requestedPath = request.nextUrl.searchParams.get('path');
|
||||
|
||||
// No path param: return root directories
|
||||
if (!requestedPath) {
|
||||
const roots: Array<{ name: string; path: string; icon: string }> = [];
|
||||
if (downloadDir) {
|
||||
roots.push({ name: 'Downloads', path: downloadDir, icon: 'download' });
|
||||
}
|
||||
if (mediaDir) {
|
||||
roots.push({ name: 'Media Library', path: mediaDir, icon: 'library' });
|
||||
}
|
||||
if (bookdropExists) {
|
||||
roots.push({ name: 'Book Drop', path: BOOKDROP_PATH, icon: 'bookdrop' });
|
||||
}
|
||||
|
||||
if (roots.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No browsable directories available' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ roots });
|
||||
}
|
||||
|
||||
// Path param provided: browse that directory
|
||||
// Normalize to forward slashes and resolve
|
||||
const normalizedPath = pathModule.resolve(requestedPath).replace(/\\/g, '/');
|
||||
|
||||
// Build list of allowed roots (normalized)
|
||||
const allowedRoots: string[] = [];
|
||||
if (downloadDir) allowedRoots.push(pathModule.resolve(downloadDir).replace(/\\/g, '/'));
|
||||
if (mediaDir) allowedRoots.push(pathModule.resolve(mediaDir).replace(/\\/g, '/'));
|
||||
if (bookdropExists) allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||
|
||||
if (!isPathAllowed(normalizedPath, allowedRoots)) {
|
||||
logger.warn(`Access denied: ${normalizedPath} is outside allowed directories`);
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied: path outside allowed directories' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Read directory entries
|
||||
const dirEntries = await fs.readdir(normalizedPath, { withFileTypes: true });
|
||||
|
||||
// Gather stats for each subdirectory (parallel for performance)
|
||||
const directoryEntries = dirEntries.filter((e) => e.isDirectory());
|
||||
const statsPromises = directoryEntries.map(async (entry): Promise<DirectoryEntry> => {
|
||||
const fullPath = pathModule.join(normalizedPath, entry.name);
|
||||
const stats = await getDirectoryStats(fullPath);
|
||||
return {
|
||||
name: entry.name,
|
||||
type: 'directory',
|
||||
...stats,
|
||||
};
|
||||
});
|
||||
|
||||
const entries = await Promise.all(statsPromises);
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Gather audio files in the current directory
|
||||
const audioFiles: Array<{ name: string; size: number }> = [];
|
||||
for (const entry of dirEntries) {
|
||||
if (entry.isFile()) {
|
||||
const ext = pathModule.extname(entry.name).toLowerCase();
|
||||
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
try {
|
||||
const stat = await fs.stat(pathModule.join(normalizedPath, entry.name));
|
||||
audioFiles.push({ name: entry.name, size: stat.size });
|
||||
} catch {
|
||||
audioFiles.push({ name: entry.name, size: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
audioFiles.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return NextResponse.json({ path: normalizedPath, entries, audioFiles });
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
|
||||
if (code === 'ENOENT') {
|
||||
return NextResponse.json({ error: 'Directory not found' }, { status: 404 });
|
||||
}
|
||||
if (code === 'EACCES' || code === 'EPERM') {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
|
||||
logger.error('Failed to browse directory', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to browse directory' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Component: Admin Manual Import API
|
||||
* Documentation: documentation/features/manual-import.md
|
||||
*
|
||||
* Triggers the organize_files pipeline for a manually-selected folder.
|
||||
* Creates or recycles a request, then queues the organize job.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getJobQueueService } from '@/lib/services/job-queue.service';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
import { AUDIO_EXTENSIONS } from '@/lib/constants/audio-formats';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.ManualImport');
|
||||
|
||||
/** Statuses that indicate the request is actively being worked on. */
|
||||
const ACTIVE_STATUSES = ['searching', 'downloading', 'processing', 'awaiting_import'];
|
||||
|
||||
/** Statuses that can be recycled for a new manual import. */
|
||||
const RECYCLABLE_STATUSES = ['failed', 'warn', 'cancelled', 'denied', 'pending', 'awaiting_search', 'awaiting_approval'];
|
||||
|
||||
/**
|
||||
* Check if a directory contains at least one audio file (immediate children only).
|
||||
*/
|
||||
async function hasAudioFiles(dirPath: string): Promise<{ found: boolean; count: number }> {
|
||||
const fs = await import('fs/promises');
|
||||
const pathModule = await import('path');
|
||||
|
||||
let count = 0;
|
||||
try {
|
||||
const children = await fs.readdir(dirPath, { withFileTypes: true });
|
||||
for (const child of children) {
|
||||
if (child.isFile()) {
|
||||
const ext = pathModule.extname(child.name).toLowerCase();
|
||||
if ((AUDIO_EXTENSIONS as readonly string[]).includes(ext)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* directory not readable */
|
||||
}
|
||||
|
||||
return { found: count > 0, count };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const pathModule = await import('path');
|
||||
const fs = await import('fs/promises');
|
||||
|
||||
const body = await request.json();
|
||||
const { folderPath, asin } = body;
|
||||
let { audiobookId } = body;
|
||||
|
||||
// Validate required fields
|
||||
if ((!audiobookId && !asin) || !folderPath) {
|
||||
return NextResponse.json(
|
||||
{ error: 'folderPath and either audiobookId or asin are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Load allowed roots
|
||||
const BOOKDROP_PATH = '/bookdrop';
|
||||
const downloadDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'download_dir' },
|
||||
});
|
||||
const mediaDirConfig = await prisma.configuration.findUnique({
|
||||
where: { key: 'media_dir' },
|
||||
});
|
||||
|
||||
const allowedRoots: string[] = [];
|
||||
if (downloadDirConfig?.value) {
|
||||
allowedRoots.push(pathModule.resolve(downloadDirConfig.value).replace(/\\/g, '/'));
|
||||
}
|
||||
if (mediaDirConfig?.value) {
|
||||
allowedRoots.push(pathModule.resolve(mediaDirConfig.value).replace(/\\/g, '/'));
|
||||
}
|
||||
try {
|
||||
const bookdropStat = await fs.stat(BOOKDROP_PATH);
|
||||
if (bookdropStat.isDirectory()) {
|
||||
allowedRoots.push(pathModule.resolve(BOOKDROP_PATH).replace(/\\/g, '/'));
|
||||
}
|
||||
} catch {
|
||||
/* not mounted */
|
||||
}
|
||||
|
||||
// Normalize and validate path
|
||||
const normalizedPath = pathModule.resolve(folderPath).replace(/\\/g, '/');
|
||||
const isAllowed = allowedRoots.some(
|
||||
(root) => normalizedPath === root || normalizedPath.startsWith(root + '/')
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Access denied: path outside allowed directories' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify folder exists and is a directory
|
||||
try {
|
||||
const stat = await fs.stat(normalizedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Path is not a directory' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Directory not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify folder contains audio files
|
||||
const audioCheck = await hasAudioFiles(normalizedPath);
|
||||
if (!audioCheck.found) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No audio files found in the selected directory' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve audiobook by ASIN if audiobookId not provided
|
||||
if (!audiobookId && asin) {
|
||||
const byAsin = await prisma.audiobook.findFirst({
|
||||
where: { audibleAsin: asin },
|
||||
});
|
||||
if (byAsin) {
|
||||
audiobookId = byAsin.id;
|
||||
} else {
|
||||
// Create audiobook record from Audible cache if available
|
||||
const cached = await prisma.audibleCache.findUnique({
|
||||
where: { asin },
|
||||
});
|
||||
if (cached) {
|
||||
const newBook = await prisma.audiobook.create({
|
||||
data: {
|
||||
audibleAsin: asin,
|
||||
title: cached.title,
|
||||
author: cached.author,
|
||||
coverArtUrl: cached.coverArtUrl,
|
||||
narrator: cached.narrator,
|
||||
status: 'pending',
|
||||
},
|
||||
});
|
||||
audiobookId = newBook.id;
|
||||
logger.info(`Created audiobook record from cache for ASIN ${asin}: ${newBook.id}`);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found for the given ASIN' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify audiobook exists
|
||||
const audiobook = await prisma.audiobook.findUnique({
|
||||
where: { id: audiobookId },
|
||||
});
|
||||
|
||||
if (!audiobook) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Audiobook not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check for existing requests
|
||||
const existingRequest = await prisma.request.findFirst({
|
||||
where: {
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
deletedAt: null,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
let requestId: string;
|
||||
|
||||
if (existingRequest) {
|
||||
// Check if already in an active state
|
||||
if (ACTIVE_STATUSES.includes(existingRequest.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This audiobook is already being processed' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
// Recycle the existing request
|
||||
if (RECYCLABLE_STATUSES.includes(existingRequest.status) ||
|
||||
existingRequest.status === 'downloaded' ||
|
||||
existingRequest.status === 'available') {
|
||||
await prisma.request.update({
|
||||
where: { id: existingRequest.id },
|
||||
data: {
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
errorMessage: null,
|
||||
importAttempts: 0,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
requestId = existingRequest.id;
|
||||
logger.info(`Recycled existing request ${requestId} for manual import`);
|
||||
} else {
|
||||
// Unknown status - create new
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user!.id,
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
},
|
||||
});
|
||||
requestId = newRequest.id;
|
||||
logger.info(`Created new request ${requestId} (existing had status: ${existingRequest.status})`);
|
||||
}
|
||||
} else {
|
||||
// No existing request - create one
|
||||
const newRequest = await prisma.request.create({
|
||||
data: {
|
||||
userId: req.user!.id,
|
||||
audiobookId,
|
||||
type: 'audiobook',
|
||||
status: 'processing',
|
||||
progress: 100,
|
||||
},
|
||||
});
|
||||
requestId = newRequest.id;
|
||||
logger.info(`Created new request ${requestId} for manual import`);
|
||||
}
|
||||
|
||||
// Queue organize_files job
|
||||
const jobQueue = getJobQueueService();
|
||||
await jobQueue.addOrganizeJob(requestId, audiobookId, normalizedPath);
|
||||
|
||||
logger.info(`Manual import queued: request=${requestId}, path=${normalizedPath}, audioFiles=${audioCheck.count}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
requestId,
|
||||
message: `Import started for ${audiobook.title}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Manual import failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Manual import failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Component: Admin Download Access Settings API
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { requireAuth, requireAdmin, AuthenticatedRequest } from '@/lib/middleware/auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { RMABLogger } from '@/lib/utils/logger';
|
||||
|
||||
const logger = RMABLogger.create('API.Admin.Settings.DownloadAccess');
|
||||
|
||||
const CONFIG_KEY = 'download_access';
|
||||
|
||||
/**
|
||||
* GET /api/admin/settings/download-access
|
||||
* Get current global download access setting
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const config = await prisma.configuration.findUnique({
|
||||
where: { key: CONFIG_KEY },
|
||||
});
|
||||
|
||||
// Default to true if not configured (backward compatibility)
|
||||
const downloadAccess = config === null ? true : config.value === 'true';
|
||||
|
||||
return NextResponse.json({ downloadAccess });
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch download access setting', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch download access setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/admin/settings/download-access
|
||||
* Update global download access setting
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
return requireAuth(request, async (req: AuthenticatedRequest) => {
|
||||
return requireAdmin(req, async () => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { downloadAccess } = body;
|
||||
|
||||
// Validate input
|
||||
if (typeof downloadAccess !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid input. downloadAccess must be a boolean' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Update configuration
|
||||
await prisma.configuration.upsert({
|
||||
where: { key: CONFIG_KEY },
|
||||
create: {
|
||||
key: CONFIG_KEY,
|
||||
value: downloadAccess.toString(),
|
||||
},
|
||||
update: {
|
||||
value: downloadAccess.toString(),
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Download access setting updated to: ${downloadAccess}`, {
|
||||
userId: req.user?.sub,
|
||||
});
|
||||
|
||||
return NextResponse.json({ downloadAccess });
|
||||
} catch (error) {
|
||||
logger.error('Failed to update download access setting', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update download access setting' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export async function PUT(
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const { role, autoApproveRequests, interactiveSearchAccess } = body;
|
||||
const { role, autoApproveRequests, interactiveSearchAccess, downloadAccess } = body;
|
||||
|
||||
// Validate role
|
||||
if (!role || (role !== 'user' && role !== 'admin')) {
|
||||
@@ -45,6 +45,14 @@ export async function PUT(
|
||||
);
|
||||
}
|
||||
|
||||
// Validate downloadAccess (optional)
|
||||
if (downloadAccess !== undefined && downloadAccess !== null && typeof downloadAccess !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid downloadAccess. Must be a boolean or null' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent user from demoting themselves
|
||||
if (req.user && id === req.user.sub) {
|
||||
return NextResponse.json(
|
||||
@@ -112,15 +120,24 @@ export async function PUT(
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (role === 'admin' && downloadAccess === false) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Admins always have download access. Cannot set downloadAccess to false for admin users.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null } = { role };
|
||||
const updateData: { role: string; autoApproveRequests?: boolean | null; interactiveSearchAccess?: boolean | null; downloadAccess?: boolean | null } = { role };
|
||||
if (autoApproveRequests !== undefined) {
|
||||
updateData.autoApproveRequests = autoApproveRequests;
|
||||
}
|
||||
if (interactiveSearchAccess !== undefined) {
|
||||
updateData.interactiveSearchAccess = interactiveSearchAccess;
|
||||
}
|
||||
if (downloadAccess !== undefined) {
|
||||
updateData.downloadAccess = downloadAccess;
|
||||
}
|
||||
|
||||
// Update user
|
||||
const updatedUser = await prisma.user.update({
|
||||
@@ -132,6 +149,7 @@ export async function PUT(
|
||||
role: true,
|
||||
autoApproveRequests: true,
|
||||
interactiveSearchAccess: true,
|
||||
downloadAccess: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function GET(request: NextRequest) {
|
||||
lastLoginAt: true,
|
||||
autoApproveRequests: true,
|
||||
interactiveSearchAccess: true,
|
||||
downloadAccess: true,
|
||||
_count: {
|
||||
select: {
|
||||
requests: true,
|
||||
|
||||
Reference in New Issue
Block a user