feat(m10): file sharing with upload, lightbox, permission, tests, e2e

- FileRepository (CRUD, soft-delete, pagination, project isolation) +
  FileStorageService (local FS save data/uploads/<projectId>/<uuid>.<ext>,
  MIME allowlist, filename sanitize, unique uuid name, member permission,
  file_shared notification to other members, file_uploaded activity log,
  SSE file.uploaded, uploader/admin delete + FS unlink)
- APIs: files list/upload(multipart), download (membership), delete
- Screens: files page + Uploader(multipart) + FileList(image Lightbox,
  PDF link, delete), ProjectNav ファイル link
- Unit tests (FileRepository, FileStorageService) + e2e file-sharing
This commit is contained in:
Ken Yasue
2026-06-25 02:02:39 +02:00
parent 1425773cd4
commit 60fef5c0c9
13 changed files with 945 additions and 0 deletions

View File

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
import fs from 'node:fs';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createFileStorageService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { fileId } = await params;
const service = createFileStorageService();
try {
const file = service.getFileInfo(user.id, Number(fileId));
const body = fs.readFileSync(file.path);
return new NextResponse(body, {
headers: {
'Content-Type': file.mimeType,
'Content-Disposition': `inline; filename="${file.originalName}"`,
'Content-Length': String(file.size),
},
});
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createFileStorageService } from '@/lib/api/services';
import { UnauthorizedError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function DELETE(
_request: NextRequest,
{ params }: { params: Promise<{ fileId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { fileId } = await params;
const service = createFileStorageService();
try {
service.delete(user.id, Number(fileId));
return NextResponse.json({ ok: true });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { getCurrentUser } from '@/lib/auth/getCurrentUser';
import { createFileStorageService } from '@/lib/api/services';
import { UnauthorizedError, ValidationError } from '@/lib/errors';
import { handleApiError } from '@/lib/api/handleError';
export const runtime = 'nodejs';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const page = Number(request.nextUrl.searchParams.get('page') ?? '1') || 1;
const service = createFileStorageService();
try {
return NextResponse.json(
service.listFiles(user.id, Number(projectId), page)
);
} catch (error) {
return handleApiError(error);
}
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
const user = await getCurrentUser();
if (!user) return handleApiError(new UnauthorizedError());
const { projectId } = await params;
const formData = await request.formData();
const file = formData.get('file');
if (!(file instanceof File)) {
return handleApiError(
new ValidationError('ファイルを指定してください', 'file')
);
}
const data = Buffer.from(await file.arrayBuffer());
const service = createFileStorageService();
try {
const fileAsset = service.upload(user.id, Number(projectId), {
originalName: file.name,
mimeType: file.type || 'application/octet-stream',
data,
});
return NextResponse.json({ file: fileAsset }, { status: 201 });
} catch (error) {
return handleApiError(error);
}
}

View File

@ -0,0 +1,52 @@
import { redirect } from 'next/navigation';
import { getCurrentUser, toPublicUser } from '@/lib/auth/getCurrentUser';
import {
createFileStorageService,
createProjectService,
} from '@/lib/api/services';
import { Header } from '@/components/layout/Header';
import { ProjectNav } from '@/components/layout/ProjectNav';
import { Uploader } from '@/components/files/Uploader';
import { FileList } from '@/components/files/FileList';
import { ForbiddenError, NotFoundError } from '@/lib/errors';
export const dynamic = 'force-dynamic';
export default async function FilesPage({
params,
}: {
params: Promise<{ projectId: string }>;
}) {
const user = await getCurrentUser();
if (!user) redirect('/login');
const { projectId } = await params;
const projectService = createProjectService();
let project: Awaited<ReturnType<typeof projectService.getProject>>;
try {
project = projectService.getProject(user.id, Number(projectId));
} catch (error) {
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
redirect('/dashboard');
}
throw error;
}
const fileService = createFileStorageService();
const { items } = fileService.listFiles(user.id, project.id);
const canDelete =
projectService.getMemberRole(user.id, project.id) === 'admin' ||
items.some((f) => f.uploaderId === user.id);
return (
<div className="min-h-screen bg-gray-50">
<Header user={toPublicUser(user)} />
<ProjectNav projectId={project.id} active="files" />
<main className="mx-auto max-w-4xl space-y-6 p-6">
<h1 className="text-2xl font-bold"></h1>
<Uploader projectId={project.id} />
<FileList files={items} canDelete={canDelete} />
</main>
</div>
);
}