Compare commits
5 Commits
9fce9e9215
...
feature/to
| Author | SHA1 | Date | |
|---|---|---|---|
| 91dd05ba7b | |||
| b2b3fb00b5 | |||
| 25d800a529 | |||
| c747978f3d | |||
| 41d65f58bb |
41
app/api/projects/[projectId]/attachments/route.ts
Normal file
41
app/api/projects/[projectId]/attachments/route.ts
Normal file
@ -0,0 +1,41 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
* チャット/掲示板添付用のファイルアップロード。
|
||||
* Files一覧公開用の通知/SSE/アクティビティを行わず、source='attachment'で保存する。
|
||||
*/
|
||||
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.uploadForAttachment(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);
|
||||
}
|
||||
}
|
||||
@ -22,10 +22,16 @@ export async function POST(
|
||||
|
||||
const service = createBoardService();
|
||||
try {
|
||||
const fileIds = Array.isArray(body.fileIds)
|
||||
? body.fileIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: [];
|
||||
const comment = service.createComment(
|
||||
user.id,
|
||||
Number(threadId),
|
||||
String(body.bodyMd ?? '')
|
||||
String(body.bodyMd ?? ''),
|
||||
fileIds
|
||||
);
|
||||
return NextResponse.json({ comment }, { status: 201 });
|
||||
} catch (error) {
|
||||
|
||||
@ -42,6 +42,11 @@ export async function POST(
|
||||
|
||||
const service = createBoardService();
|
||||
try {
|
||||
const fileIds = Array.isArray(body.fileIds)
|
||||
? body.fileIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: [];
|
||||
const thread = service.createThread(user.id, Number(projectId), {
|
||||
title: String(body.title ?? ''),
|
||||
bodyMd: String(body.bodyMd ?? ''),
|
||||
@ -49,6 +54,7 @@ export async function POST(
|
||||
typeof body.category === 'string'
|
||||
? (body.category as never)
|
||||
: undefined,
|
||||
fileIds,
|
||||
});
|
||||
return NextResponse.json({ thread }, { status: 201 });
|
||||
} catch (error) {
|
||||
|
||||
@ -42,10 +42,16 @@ export async function POST(
|
||||
|
||||
const service = createChatService();
|
||||
try {
|
||||
const fileIds = Array.isArray(body.fileIds)
|
||||
? body.fileIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: [];
|
||||
const message = service.sendMessage(
|
||||
user.id,
|
||||
Number(projectId),
|
||||
String(body.body ?? '')
|
||||
String(body.body ?? ''),
|
||||
fileIds
|
||||
);
|
||||
return NextResponse.json({ message }, { status: 201 });
|
||||
} catch (error) {
|
||||
|
||||
@ -6,6 +6,23 @@ import { handleApiError, jsonError } from '@/lib/api/handleError';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return handleApiError(new UnauthorizedError());
|
||||
const { itemId } = await params;
|
||||
const service = createTodoService();
|
||||
try {
|
||||
const item = service.getItem(user.id, Number(itemId));
|
||||
const attachments = service.getItemAttachments(user.id, Number(itemId));
|
||||
return NextResponse.json({ item, attachments });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ projectId: string; itemId: string }> }
|
||||
@ -35,23 +52,29 @@ export async function PATCH(
|
||||
);
|
||||
return NextResponse.json({ item });
|
||||
}
|
||||
// nullable フィールドは absent(undefined=更新しない) と null(クリア) を区別する
|
||||
const nullableString = (v: unknown): string | null | undefined =>
|
||||
v === undefined ? undefined : v === null ? null : String(v);
|
||||
const nullableNumber = (v: unknown): number | null | undefined =>
|
||||
v === undefined ? undefined : v === null ? null : Number(v);
|
||||
|
||||
const item = service.updateItem(user.id, Number(itemId), {
|
||||
title: typeof body.title === 'string' ? body.title : undefined,
|
||||
description:
|
||||
typeof body.description === 'string' ? body.description : undefined,
|
||||
assigneeId:
|
||||
body.assigneeId === null || body.assigneeId === undefined
|
||||
? undefined
|
||||
: Number(body.assigneeId),
|
||||
description: nullableString(body.description),
|
||||
assigneeId: nullableNumber(body.assigneeId),
|
||||
priority:
|
||||
typeof body.priority === 'string'
|
||||
? (body.priority as 'low' | 'normal' | 'high')
|
||||
: undefined,
|
||||
dueDate: typeof body.dueDate === 'string' ? body.dueDate : undefined,
|
||||
milestoneId:
|
||||
body.milestoneId === null || body.milestoneId === undefined
|
||||
? undefined
|
||||
: Number(body.milestoneId),
|
||||
startDate: nullableString(body.startDate),
|
||||
dueDate: nullableString(body.dueDate),
|
||||
tags: nullableString(body.tags),
|
||||
milestoneId: nullableNumber(body.milestoneId),
|
||||
fileIds: Array.isArray(body.fileIds)
|
||||
? body.fileIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: undefined,
|
||||
});
|
||||
return NextResponse.json({ item });
|
||||
} catch (error) {
|
||||
|
||||
@ -38,6 +38,11 @@ export async function POST(
|
||||
}
|
||||
const service = createTodoService();
|
||||
try {
|
||||
const fileIds = Array.isArray(body.fileIds)
|
||||
? body.fileIds
|
||||
.map((n) => Number(n))
|
||||
.filter((n) => Number.isFinite(n) && n > 0)
|
||||
: [];
|
||||
const item = service.createItem(user.id, Number(projectId), {
|
||||
title: String(body.title ?? ''),
|
||||
columnId: Number(body.columnId ?? 0),
|
||||
@ -51,7 +56,11 @@ export async function POST(
|
||||
typeof body.priority === 'string'
|
||||
? (body.priority as 'low' | 'normal' | 'high')
|
||||
: undefined,
|
||||
startDate:
|
||||
typeof body.startDate === 'string' ? body.startDate : undefined,
|
||||
dueDate: typeof body.dueDate === 'string' ? body.dueDate : null,
|
||||
tags: typeof body.tags === 'string' ? body.tags : undefined,
|
||||
fileIds,
|
||||
});
|
||||
return NextResponse.json({ item }, { status: 201 });
|
||||
} catch (error) {
|
||||
|
||||
@ -5,6 +5,8 @@ import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { MarkdownBody } from '@/components/board/MarkdownBody';
|
||||
import { CommentForm } from '@/components/board/CommentForm';
|
||||
import { AttachmentList } from '@/components/files/AttachmentList';
|
||||
import type { AttachmentView } from '@/lib/types';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
@ -21,9 +23,15 @@ export default async function ThreadDetailPage({
|
||||
const boardService = createBoardService();
|
||||
let thread;
|
||||
let comments;
|
||||
let attachments: { thread: AttachmentView[]; comments: AttachmentView[] };
|
||||
try {
|
||||
thread = boardService.getThread(user.id, Number(threadId));
|
||||
comments = boardService.listComments(user.id, Number(threadId));
|
||||
attachments = boardService.getAttachments(
|
||||
user.id,
|
||||
thread.id,
|
||||
comments.items.map((c) => c.id)
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ForbiddenError || error instanceof NotFoundError) {
|
||||
redirect(`/projects/${projectId}/board`);
|
||||
@ -31,6 +39,18 @@ export default async function ThreadDetailPage({
|
||||
throw error;
|
||||
}
|
||||
|
||||
// コメントIDごとに添付をグループ化
|
||||
const commentAttachments = new Map(
|
||||
attachments.comments.map((a) => [
|
||||
a.targetId,
|
||||
[] as typeof attachments.comments,
|
||||
])
|
||||
);
|
||||
for (const a of attachments.comments) {
|
||||
const list = commentAttachments.get(a.targetId);
|
||||
if (list) list.push(a);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
@ -54,6 +74,14 @@ export default async function ThreadDetailPage({
|
||||
<div className="mt-4">
|
||||
<MarkdownBody bodyMd={thread.bodyMd} />
|
||||
</div>
|
||||
{attachments.thread.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">
|
||||
添付ファイル
|
||||
</p>
|
||||
<AttachmentList attachments={attachments.thread} />
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-4 text-xs text-gray-400">
|
||||
投稿: {thread.createdAt} / 更新: {thread.updatedAt}
|
||||
</p>
|
||||
@ -67,6 +95,14 @@ export default async function ThreadDetailPage({
|
||||
className="rounded-lg border bg-white p-4 shadow-sm"
|
||||
>
|
||||
<MarkdownBody bodyMd={comment.bodyMd} />
|
||||
{commentAttachments.has(comment.id) &&
|
||||
commentAttachments.get(comment.id)!.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<AttachmentList
|
||||
attachments={commentAttachments.get(comment.id)!}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-gray-400">{comment.createdAt}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -7,27 +7,34 @@ import {
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { ProjectNav } from '@/components/layout/ProjectNav';
|
||||
import { CalendarEventForm } from '@/components/calendar/CalendarEventForm';
|
||||
import { CalendarView } from '@/components/calendar/CalendarView';
|
||||
import {
|
||||
rangeForView,
|
||||
toISODate,
|
||||
parseISODate,
|
||||
type CalendarViewMode,
|
||||
} from '@/lib/calendar/grid';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const SOURCE_COLORS: Record<string, string> = {
|
||||
event: 'bg-blue-100 text-blue-700',
|
||||
milestone: 'bg-purple-100 text-purple-700',
|
||||
todo: 'bg-yellow-100 text-yellow-700',
|
||||
};
|
||||
const VALID_VIEWS: CalendarViewMode[] = ['month', 'week', 'day'];
|
||||
|
||||
function isCalendarView(v: unknown): v is CalendarViewMode {
|
||||
return typeof v === 'string' && (VALID_VIEWS as string[]).includes(v);
|
||||
}
|
||||
|
||||
export default async function CalendarPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ projectId: string }>;
|
||||
searchParams: Promise<{ from?: string; to?: string }>;
|
||||
searchParams: Promise<{ view?: string; date?: string }>;
|
||||
}) {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) redirect('/login');
|
||||
const { projectId } = await params;
|
||||
const { from, to } = await searchParams;
|
||||
const { view, date } = await searchParams;
|
||||
|
||||
const projectService = createProjectService();
|
||||
let project: Awaited<ReturnType<typeof projectService.getProject>>;
|
||||
@ -40,55 +47,35 @@ export default async function CalendarPage({
|
||||
throw error;
|
||||
}
|
||||
|
||||
// デフォルトは当月
|
||||
const now = new Date();
|
||||
const defaultFrom = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
const rangeFrom = from ?? defaultFrom;
|
||||
const rangeTo = to ?? defaultFrom.replace(/-01$/, '-28');
|
||||
// 表示モードと基準日を解決(不正値はデフォルトへフォールバック)
|
||||
const resolvedView: CalendarViewMode = isCalendarView(view) ? view : 'month';
|
||||
const today = new Date();
|
||||
const anchor =
|
||||
date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? parseISODate(date) : today;
|
||||
|
||||
// ビューに応じた取得範囲を計算しイベントを取得。
|
||||
// アクセス権は getProject で参加確認済みなのでここでは再チェックしない。
|
||||
const range = rangeForView(resolvedView, anchor);
|
||||
const scheduleService = createScheduleService();
|
||||
const events = scheduleService.getCalendarEvents(user.id, project.id, {
|
||||
from: rangeFrom,
|
||||
to: rangeTo,
|
||||
});
|
||||
const events = scheduleService.getCalendarEvents(user.id, project.id, range);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header user={toPublicUser(user)} />
|
||||
<ProjectNav projectId={project.id} active="calendar" />
|
||||
<main className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<main className="mx-auto max-w-6xl space-y-6 p-6">
|
||||
<h1 className="text-2xl font-bold">カレンダー</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
期間: {rangeFrom} 〜 {rangeTo}
|
||||
</p>
|
||||
<CalendarEventForm projectId={project.id} defaultDate={defaultFrom} />
|
||||
<section className="space-y-2">
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
期間内のイベントはありません。
|
||||
</p>
|
||||
) : (
|
||||
events.map((e) => (
|
||||
<div
|
||||
key={e.key}
|
||||
className="flex items-center justify-between rounded border bg-white p-3 shadow-sm"
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{e.title}</p>
|
||||
<p className="text-xs text-gray-400">{e.startAt}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{e.source}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
<CalendarEventForm
|
||||
projectId={project.id}
|
||||
defaultDate={toISODate(anchor)}
|
||||
/>
|
||||
<CalendarView
|
||||
projectId={project.id}
|
||||
events={events}
|
||||
view={resolvedView}
|
||||
anchorDate={toISODate(anchor)}
|
||||
todayKey={toISODate(today)}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -31,6 +31,7 @@ export default async function TodosPage({
|
||||
const todoService = createTodoService();
|
||||
const columns = todoService.getColumns(user.id, project.id);
|
||||
const items = todoService.getItems(user.id, project.id);
|
||||
const members = projectService.getMembers(user.id, project.id);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
@ -42,6 +43,7 @@ export default async function TodosPage({
|
||||
projectId={project.id}
|
||||
columns={columns}
|
||||
initialItems={items}
|
||||
members={members}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRef, useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
|
||||
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
|
||||
|
||||
export function CommentForm({
|
||||
projectId,
|
||||
@ -11,25 +13,29 @@ export function CommentForm({
|
||||
threadId: number;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pickerRef = useRef<AttachmentPickerHandle>(null);
|
||||
const [bodyMd, setBodyMd] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const fileIds = pickerRef.current?.getFileIds() ?? [];
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/board/threads/${threadId}/comments`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bodyMd }),
|
||||
body: JSON.stringify({ bodyMd, fileIds }),
|
||||
}
|
||||
);
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
setBodyMd('');
|
||||
pickerRef.current?.clear();
|
||||
router.refresh();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
@ -48,6 +54,11 @@ export function CommentForm({
|
||||
className="min-h-[80px] w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
<AttachmentPicker
|
||||
ref={pickerRef}
|
||||
projectId={projectId}
|
||||
onLoadingChange={setPickerLoading}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
@ -55,7 +66,7 @@ export function CommentForm({
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loading || pickerLoading}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '投稿中...' : 'コメント投稿'}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useRef, useState, type FormEvent } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
|
||||
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: '', label: 'なし' },
|
||||
@ -16,16 +18,19 @@ const CATEGORIES = [
|
||||
|
||||
export function ThreadForm({ projectId }: { projectId: number }) {
|
||||
const router = useRouter();
|
||||
const pickerRef = useRef<AttachmentPickerHandle>(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [bodyMd, setBodyMd] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
|
||||
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const fileIds = pickerRef.current?.getFileIds() ?? [];
|
||||
const res = await fetch(`/api/projects/${projectId}/board/threads`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@ -33,11 +38,13 @@ export function ThreadForm({ projectId }: { projectId: number }) {
|
||||
title,
|
||||
bodyMd,
|
||||
category: category || undefined,
|
||||
fileIds,
|
||||
}),
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { thread: { id: number } };
|
||||
pickerRef.current?.clear();
|
||||
router.push(`/projects/${projectId}/board/${data.thread.id}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
@ -82,6 +89,11 @@ export function ThreadForm({ projectId }: { projectId: number }) {
|
||||
className="min-h-[120px] w-full rounded border px-3 py-2"
|
||||
required
|
||||
/>
|
||||
<AttachmentPicker
|
||||
ref={pickerRef}
|
||||
projectId={projectId}
|
||||
onLoadingChange={setPickerLoading}
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
@ -89,7 +101,7 @@ export function ThreadForm({ projectId }: { projectId: number }) {
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loading || pickerLoading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '作成中...' : 'スレッド作成'}
|
||||
|
||||
185
components/calendar/CalendarView.tsx
Normal file
185
components/calendar/CalendarView.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||
import {
|
||||
getMonthGrid,
|
||||
getWeekDays,
|
||||
parseISODate,
|
||||
stepAnchor,
|
||||
toISODate,
|
||||
eventsOnDay,
|
||||
type CalendarViewMode,
|
||||
} from '@/lib/calendar/grid';
|
||||
import { WEEKDAY_LABELS } from '@/components/calendar/sourceColors';
|
||||
import { MonthView } from '@/components/calendar/MonthView';
|
||||
import { WeekView } from '@/components/calendar/WeekView';
|
||||
import { DayView } from '@/components/calendar/DayView';
|
||||
import { EventDetailDialog } from '@/components/calendar/EventDetailDialog';
|
||||
|
||||
const VIEW_LABELS: { mode: CalendarViewMode; label: string }[] = [
|
||||
{ mode: 'month', label: '月' },
|
||||
{ mode: 'week', label: '週' },
|
||||
{ mode: 'day', label: '日' },
|
||||
];
|
||||
|
||||
/**
|
||||
* カレンダーUIのクライアントコンテナ。
|
||||
* 表示モード切替・前/次/今日ナビ・詳細ダイアログを管理し、
|
||||
* データ取得はURL searchParams経由でServer Componentに委ねる。
|
||||
*/
|
||||
export function CalendarView({
|
||||
projectId,
|
||||
events,
|
||||
view,
|
||||
anchorDate,
|
||||
todayKey,
|
||||
}: {
|
||||
projectId: number;
|
||||
events: CalendarEventView[];
|
||||
view: CalendarViewMode;
|
||||
anchorDate: string;
|
||||
todayKey: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [selectedDateKey, setSelectedDateKey] = useState<string | null>(null);
|
||||
const anchor = parseISODate(anchorDate);
|
||||
|
||||
function navigate(nextView: CalendarViewMode, nextAnchor: Date) {
|
||||
const params = new URLSearchParams({
|
||||
view: nextView,
|
||||
date: toISODate(nextAnchor),
|
||||
});
|
||||
router.push(`/projects/${projectId}/calendar?${params.toString()}`);
|
||||
}
|
||||
|
||||
function changeView(nextView: CalendarViewMode) {
|
||||
navigate(nextView, anchor);
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
navigate(view, stepAnchor(view, anchor, -1));
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
navigate(view, stepAnchor(view, anchor, 1));
|
||||
}
|
||||
|
||||
function goToday() {
|
||||
navigate(view, new Date());
|
||||
}
|
||||
|
||||
function openDetail(dateKey: string) {
|
||||
setSelectedDateKey(dateKey);
|
||||
}
|
||||
|
||||
const title = buildTitle(view, anchor);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2
|
||||
className="text-xl font-bold text-gray-800"
|
||||
data-testid="calendar-title"
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex overflow-hidden rounded border">
|
||||
{VIEW_LABELS.map((v) => (
|
||||
<button
|
||||
key={v.mode}
|
||||
type="button"
|
||||
onClick={() => changeView(v.mode)}
|
||||
className={`px-3 py-1 text-sm ${
|
||||
view === v.mode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
data-testid={`calendar-view-${v.mode}`}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||
aria-label="前へ"
|
||||
data-testid="calendar-prev"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToday}
|
||||
className="rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||
data-testid="calendar-today"
|
||||
>
|
||||
今日
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
className="rounded border bg-white px-2 py-1 text-sm text-gray-600 hover:bg-gray-100"
|
||||
aria-label="次へ"
|
||||
data-testid="calendar-next"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view === 'month' && (
|
||||
<MonthView
|
||||
events={events}
|
||||
weeks={getMonthGrid(anchor.getFullYear(), anchor.getMonth())}
|
||||
anchorMonth={anchor.getMonth()}
|
||||
todayKey={todayKey}
|
||||
onSelectDate={openDetail}
|
||||
/>
|
||||
)}
|
||||
{view === 'week' && (
|
||||
<WeekView
|
||||
events={events}
|
||||
days={getWeekDays(anchor)}
|
||||
todayKey={todayKey}
|
||||
onSelectDate={openDetail}
|
||||
/>
|
||||
)}
|
||||
{view === 'day' && (
|
||||
<DayView
|
||||
day={anchor}
|
||||
events={events}
|
||||
todayKey={todayKey}
|
||||
onSelectDate={openDetail}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedDateKey && (
|
||||
<EventDetailDialog
|
||||
date={parseISODate(selectedDateKey)}
|
||||
events={eventsOnDay(events, selectedDateKey)}
|
||||
onClose={() => setSelectedDateKey(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTitle(view: CalendarViewMode, anchor: Date): string {
|
||||
if (view === 'month') {
|
||||
return `${anchor.getFullYear()}年${anchor.getMonth() + 1}月`;
|
||||
}
|
||||
if (view === 'week') {
|
||||
const days = getWeekDays(anchor);
|
||||
const s = days[0];
|
||||
const e = days[6];
|
||||
return `${s.getFullYear()}年${s.getMonth() + 1}月${s.getDate()}日 〜 ${e.getMonth() + 1}月${e.getDate()}日`;
|
||||
}
|
||||
return `${anchor.getFullYear()}年${anchor.getMonth() + 1}月${anchor.getDate()}日(${WEEKDAY_LABELS[anchor.getDay()]})`;
|
||||
}
|
||||
121
components/calendar/DayView.tsx
Normal file
121
components/calendar/DayView.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||
import {
|
||||
eventsOnDay,
|
||||
eventHour,
|
||||
formatTime,
|
||||
getDayHours,
|
||||
toISODate,
|
||||
} from '@/lib/calendar/grid';
|
||||
import {
|
||||
SOURCE_COLORS,
|
||||
SOURCE_CHIP_BORDER,
|
||||
WEEKDAY_LABELS,
|
||||
} from '@/components/calendar/sourceColors';
|
||||
|
||||
/**
|
||||
* 日表示(時間グリッド)。0:00〜23:00 を1時間刻みで表示し、
|
||||
* 時刻付きイベントを該当時刻行に、終日(日付のみ)イベントを上部に配置する。
|
||||
*/
|
||||
export function DayView({
|
||||
day,
|
||||
events,
|
||||
todayKey,
|
||||
onSelectDate,
|
||||
}: {
|
||||
day: Date;
|
||||
events: CalendarEventView[];
|
||||
todayKey: string;
|
||||
onSelectDate: (dateKey: string) => void;
|
||||
}) {
|
||||
const key = toISODate(day);
|
||||
const dayEvents = eventsOnDay(events, key);
|
||||
const allDay = dayEvents.filter((e) => eventHour(e.startAt) === null);
|
||||
const timed = dayEvents.filter((e) => eventHour(e.startAt) !== null);
|
||||
const hours = getDayHours();
|
||||
const isToday = key === todayKey;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-white">
|
||||
<div className="flex items-center justify-between border-b p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
className="text-left"
|
||||
>
|
||||
<p className="text-lg font-bold text-gray-800">
|
||||
{day.getMonth() + 1}月{day.getDate()}日(
|
||||
{WEEKDAY_LABELS[day.getDay()]})
|
||||
</p>
|
||||
</button>
|
||||
{isToday && (
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
||||
今日
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allDay.length > 0 && (
|
||||
<div
|
||||
className="border-b bg-gray-50 p-2"
|
||||
data-testid="calendar-all-day-section"
|
||||
>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">終日</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{allDay.map((e) => (
|
||||
<button
|
||||
key={e.key}
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
title={e.title}
|
||||
className={`max-w-full truncate rounded border px-2 py-0.5 text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
{e.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[600px] overflow-y-auto">
|
||||
{hours.map((h) => {
|
||||
const hourEvents = timed.filter((e) => eventHour(e.startAt) === h);
|
||||
return (
|
||||
<div
|
||||
key={h}
|
||||
className="flex border-b last:border-b-0"
|
||||
data-testid={`calendar-hour-${String(h).padStart(2, '0')}`}
|
||||
>
|
||||
<div className="w-16 shrink-0 border-r bg-gray-50 p-1 text-right text-xs text-gray-400">
|
||||
{String(h).padStart(2, '0')}:00
|
||||
</div>
|
||||
<div className="flex-1 p-1">
|
||||
{hourEvents.map((e) => (
|
||||
<button
|
||||
key={e.key}
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
title={e.title}
|
||||
className={`mb-1 block w-full truncate rounded border px-2 py-1 text-left text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{formatTime(e.startAt)}
|
||||
</span>{' '}
|
||||
{e.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
components/calendar/EventDetailDialog.tsx
Normal file
113
components/calendar/EventDetailDialog.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||
import { formatTime } from '@/lib/calendar/grid';
|
||||
import {
|
||||
SOURCE_COLORS,
|
||||
SOURCE_LABELS,
|
||||
} from '@/components/calendar/sourceColors';
|
||||
|
||||
/**
|
||||
* 指定日のイベント詳細ダイアログ。
|
||||
* セル内ではtruncateされるタイトルも、ここでは全文と説明を表示する。
|
||||
* 開閉時にフォーカスをダイアログへ移し、閉じたら元の要素へ戻す。
|
||||
*/
|
||||
export function EventDetailDialog({
|
||||
date,
|
||||
events,
|
||||
onClose,
|
||||
}: {
|
||||
date: Date;
|
||||
events: CalendarEventView[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
dialogRef.current?.focus();
|
||||
return () => {
|
||||
previouslyFocused?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
const dateLabel = `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
data-testid="calendar-detail-backdrop"
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
tabIndex={-1}
|
||||
className="mt-16 w-full max-w-lg rounded-lg bg-white shadow-xl focus:outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`${dateLabel}のイベント`}
|
||||
data-testid="calendar-detail-dialog"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">{dateLabel}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="閉じる"
|
||||
data-testid="calendar-detail-close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[60vh] space-y-3 overflow-y-auto p-4">
|
||||
{events.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">
|
||||
この日のイベントはありません。
|
||||
</p>
|
||||
) : (
|
||||
events.map((e) => (
|
||||
<div
|
||||
key={e.key}
|
||||
className="rounded border border-gray-200 p-3"
|
||||
data-testid={`calendar-detail-${e.key}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-medium text-gray-800 break-words">
|
||||
{e.title}
|
||||
</p>
|
||||
<span
|
||||
className={`shrink-0 rounded px-2 py-0.5 text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{SOURCE_LABELS[e.source] ?? e.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{formatTime(e.startAt)}
|
||||
{e.endAt ? ` 〜 ${formatTime(e.endAt)}` : ''}
|
||||
</p>
|
||||
{e.description && (
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-gray-600">
|
||||
{e.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
components/calendar/MonthView.tsx
Normal file
105
components/calendar/MonthView.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
'use client';
|
||||
|
||||
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||
import { eventsOnDay, toISODate } from '@/lib/calendar/grid';
|
||||
import {
|
||||
SOURCE_COLORS,
|
||||
SOURCE_CHIP_BORDER,
|
||||
WEEKDAY_LABELS,
|
||||
} from '@/components/calendar/sourceColors';
|
||||
|
||||
const MAX_VISIBLE_CHIPS = 3;
|
||||
|
||||
/**
|
||||
* 月表示グリッド。日曜始まりの7列グリッドで各日にイベントチップを表示する。
|
||||
* 長いタイトルは truncate し、3件を超える分は「+N件」でまとめる。
|
||||
*/
|
||||
export function MonthView({
|
||||
events,
|
||||
weeks,
|
||||
anchorMonth,
|
||||
todayKey,
|
||||
onSelectDate,
|
||||
}: {
|
||||
events: CalendarEventView[];
|
||||
weeks: Date[][];
|
||||
anchorMonth: number;
|
||||
todayKey: string;
|
||||
onSelectDate: (dateKey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-white">
|
||||
<div className="grid grid-cols-7 border-b bg-gray-50 text-center text-xs font-medium text-gray-500">
|
||||
{WEEKDAY_LABELS.map((w) => (
|
||||
<div key={w} className="py-2">
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{weeks.map((week, wi) => (
|
||||
<div key={wi} className="grid grid-cols-7 border-b last:border-b-0">
|
||||
{week.map((day) => {
|
||||
const key = toISODate(day);
|
||||
const dayEvents = eventsOnDay(events, key);
|
||||
const inMonth = day.getMonth() === anchorMonth;
|
||||
const isToday = key === todayKey;
|
||||
const visible = dayEvents.slice(0, MAX_VISIBLE_CHIPS);
|
||||
const hidden = dayEvents.length - visible.length;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`min-h-[110px] border-r border-t p-1 last:border-r-0 ${
|
||||
inMonth ? 'bg-white' : 'bg-gray-50'
|
||||
}`}
|
||||
data-testid={`calendar-day-${key}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-full text-xs ${
|
||||
isToday
|
||||
? 'bg-blue-600 font-bold text-white'
|
||||
: inMonth
|
||||
? 'text-gray-700 hover:bg-gray-100'
|
||||
: 'text-gray-300 hover:bg-gray-100'
|
||||
}`}
|
||||
aria-label={`${key} の詳細を開く`}
|
||||
>
|
||||
{day.getDate()}
|
||||
</button>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{visible.map((e) => (
|
||||
<button
|
||||
key={e.key}
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
title={e.title}
|
||||
className={`block w-full truncate rounded border px-1 text-left text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
{e.title}
|
||||
</button>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
aria-label={`${key}の残り${hidden}件を開く`}
|
||||
className="block w-full truncate text-left text-xs text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
+{hidden}件
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
components/calendar/WeekView.tsx
Normal file
91
components/calendar/WeekView.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import type { CalendarEventView } from '@/services/ScheduleService';
|
||||
import { eventsOnDay, formatTime, toISODate } from '@/lib/calendar/grid';
|
||||
import {
|
||||
SOURCE_COLORS,
|
||||
SOURCE_CHIP_BORDER,
|
||||
WEEKDAY_LABELS,
|
||||
} from '@/components/calendar/sourceColors';
|
||||
|
||||
/**
|
||||
* 週表示。日〜土の7日を並べ、各日のイベントを開始時刻付きで表示する。
|
||||
*/
|
||||
export function WeekView({
|
||||
events,
|
||||
days,
|
||||
todayKey,
|
||||
onSelectDate,
|
||||
}: {
|
||||
events: CalendarEventView[];
|
||||
days: Date[];
|
||||
todayKey: string;
|
||||
onSelectDate: (dateKey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-white">
|
||||
<div className="grid grid-cols-7 border-b bg-gray-50 text-center text-xs font-medium text-gray-500">
|
||||
{days.map((d) => {
|
||||
const key = toISODate(d);
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
className="py-2 hover:bg-gray-100"
|
||||
data-testid={`calendar-weekday-${key}`}
|
||||
>
|
||||
<div className="text-gray-500">{WEEKDAY_LABELS[d.getDay()]}</div>
|
||||
<div
|
||||
className={`mt-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full text-sm ${
|
||||
key === todayKey
|
||||
? 'bg-blue-600 font-bold text-white'
|
||||
: 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{d.getDate()}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map((d) => {
|
||||
const key = toISODate(d);
|
||||
const dayEvents = eventsOnDay(events, key);
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="min-h-[400px] border-r p-1 last:border-r-0"
|
||||
data-testid={`calendar-week-cell-${key}`}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{dayEvents.length === 0 ? (
|
||||
<p className="px-1 py-1 text-xs text-gray-300">-</p>
|
||||
) : (
|
||||
dayEvents.map((e) => (
|
||||
<button
|
||||
key={e.key}
|
||||
type="button"
|
||||
onClick={() => onSelectDate(key)}
|
||||
title={e.title}
|
||||
className={`block w-full truncate rounded border px-1 py-0.5 text-left text-xs ${
|
||||
SOURCE_COLORS[e.source] ?? 'bg-gray-100 text-gray-600'
|
||||
} ${SOURCE_CHIP_BORDER[e.source] ?? 'border-gray-200'}`}
|
||||
data-testid={`calendar-event-${e.key}`}
|
||||
>
|
||||
<span className="font-mono text-[10px] opacity-70">
|
||||
{formatTime(e.startAt)}
|
||||
</span>{' '}
|
||||
{e.title}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
components/calendar/sourceColors.ts
Normal file
28
components/calendar/sourceColors.ts
Normal file
@ -0,0 +1,28 @@
|
||||
/** カレンダーイベントの来源ごとの色とラベル。 */
|
||||
export const SOURCE_COLORS: Record<string, string> = {
|
||||
event: 'bg-blue-100 text-blue-700',
|
||||
milestone: 'bg-purple-100 text-purple-700',
|
||||
todo: 'bg-yellow-100 text-yellow-700',
|
||||
};
|
||||
|
||||
export const SOURCE_CHIP_BORDER: Record<string, string> = {
|
||||
event: 'border-blue-200',
|
||||
milestone: 'border-purple-200',
|
||||
todo: 'border-yellow-200',
|
||||
};
|
||||
|
||||
export const SOURCE_LABELS: Record<string, string> = {
|
||||
event: 'イベント',
|
||||
milestone: 'マイルストーン',
|
||||
todo: 'ToDo',
|
||||
};
|
||||
|
||||
export const WEEKDAY_LABELS = [
|
||||
'日',
|
||||
'月',
|
||||
'火',
|
||||
'水',
|
||||
'木',
|
||||
'金',
|
||||
'土',
|
||||
] as const;
|
||||
@ -1,7 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import type { ChatMessage } from '@/lib/types';
|
||||
import { useEffect, useRef, useState, type FormEvent } from 'react';
|
||||
import type { ChatMessageWithAttachments } from '@/lib/types';
|
||||
import { AttachmentPicker } from '@/components/files/AttachmentPicker';
|
||||
import type { AttachmentPickerHandle } from '@/components/files/AttachmentPicker';
|
||||
import { AttachmentList } from '@/components/files/AttachmentList';
|
||||
|
||||
interface ChatWindowProps {
|
||||
projectId: number;
|
||||
@ -9,16 +12,20 @@ interface ChatWindowProps {
|
||||
}
|
||||
|
||||
export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [messages, setMessages] = useState<ChatMessageWithAttachments[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
const pickerRef = useRef<AttachmentPickerHandle>(null);
|
||||
|
||||
// 履歴取得
|
||||
useEffect(() => {
|
||||
fetch(`/api/projects/${projectId}/chat/messages`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (data?.items) setMessages(data.items as ChatMessage[]);
|
||||
if (data?.items)
|
||||
setMessages(data.items as ChatMessageWithAttachments[]);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, [projectId]);
|
||||
@ -30,7 +37,10 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data) as {
|
||||
type: string;
|
||||
data: { message?: ChatMessage; id?: number };
|
||||
data: {
|
||||
message?: ChatMessageWithAttachments;
|
||||
id?: number;
|
||||
};
|
||||
};
|
||||
if (parsed.type === 'chat.message.created' && parsed.data.message) {
|
||||
setMessages((prev) =>
|
||||
@ -42,9 +52,12 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
parsed.type === 'chat.message.updated' &&
|
||||
parsed.data.message
|
||||
) {
|
||||
// 編集イベントは本文のみ更新し、添付は既存のものを保持する
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === parsed.data.message!.id ? parsed.data.message! : m
|
||||
m.id === parsed.data.message!.id
|
||||
? { ...m, ...parsed.data.message!, attachments: m.attachments }
|
||||
: m
|
||||
)
|
||||
);
|
||||
} else if (parsed.type === 'chat.message.deleted' && parsed.data.id) {
|
||||
@ -61,13 +74,17 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
event.preventDefault();
|
||||
if (!input.trim()) return;
|
||||
setError(null);
|
||||
setSending(true);
|
||||
const fileIds = pickerRef.current?.getFileIds() ?? [];
|
||||
const res = await fetch(`/api/projects/${projectId}/chat/messages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ body: input }),
|
||||
body: JSON.stringify({ body: input, fileIds }),
|
||||
});
|
||||
setSending(false);
|
||||
if (res.ok) {
|
||||
setInput('');
|
||||
pickerRef.current?.clear();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
@ -95,6 +112,11 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
>
|
||||
{m.body}
|
||||
</span>
|
||||
{m.attachments && m.attachments.length > 0 && (
|
||||
<div className="max-w-[80%]">
|
||||
<AttachmentList attachments={m.attachments} />
|
||||
</div>
|
||||
)}
|
||||
<span className="mt-0.5 text-xs text-gray-400">
|
||||
{m.createdAt}
|
||||
</span>
|
||||
@ -104,24 +126,32 @@ export function ChatWindow({ projectId, userName }: ChatWindowProps) {
|
||||
</div>
|
||||
<form
|
||||
onSubmit={onSend}
|
||||
className="flex gap-2 border-t p-3"
|
||||
className="space-y-2 border-t p-3"
|
||||
data-testid="chat-form"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="メッセージを入力(@email でメンション)"
|
||||
className="flex-1 rounded border px-3 py-2"
|
||||
data-testid="chat-input"
|
||||
<AttachmentPicker
|
||||
ref={pickerRef}
|
||||
projectId={projectId}
|
||||
onLoadingChange={setPickerLoading}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700"
|
||||
data-testid="chat-send"
|
||||
>
|
||||
送信
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="メッセージを入力(@email でメンション)"
|
||||
className="flex-1 rounded border px-3 py-2"
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={sending || pickerLoading}
|
||||
className="rounded bg-blue-600 px-4 py-2 font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
data-testid="chat-send"
|
||||
>
|
||||
{sending ? '送信中...' : '送信'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error && (
|
||||
<p className="px-3 pb-2 text-sm text-red-600" role="alert">
|
||||
|
||||
93
components/files/AttachmentList.tsx
Normal file
93
components/files/AttachmentList.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { AttachmentView } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* 添付ファイル一覧表示。画像はサムネイル(クリックでLightbox)、
|
||||
* それ以外はダウンロードリンク。チャット/掲示板で共通利用。
|
||||
*/
|
||||
export function AttachmentList({
|
||||
attachments,
|
||||
}: {
|
||||
attachments: AttachmentView[];
|
||||
}) {
|
||||
const [lightbox, setLightbox] = useState<AttachmentView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lightbox) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setLightbox(null);
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [lightbox]);
|
||||
|
||||
if (attachments.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ul className="mt-1 flex flex-wrap gap-2" data-testid="attachment-list">
|
||||
{attachments.map((a) => {
|
||||
const url = `/api/files/${a.fileId}/download`;
|
||||
const isImage = a.mimeType.startsWith('image/');
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
className="overflow-hidden rounded border bg-gray-50"
|
||||
data-testid={`attachment-${a.id}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightbox(a)}
|
||||
className="block"
|
||||
aria-label={`画像 ${a.originalName} を開く`}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={a.originalName}
|
||||
className="h-20 w-20 object-cover"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={url}
|
||||
className="flex h-20 w-28 flex-col items-center justify-center px-2 text-center text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
<span className="mb-1">📎</span>
|
||||
<span className="w-full truncate" title={a.originalName}>
|
||||
{a.originalName}
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{lightbox && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={() => setLightbox(null)}
|
||||
data-testid="attachment-lightbox"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLightbox(null)}
|
||||
className="absolute right-4 top-4 rounded bg-black/50 px-2 py-0.5 text-2xl text-white hover:bg-black/70"
|
||||
aria-label="閉じる"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<img
|
||||
src={`/api/files/${lightbox.fileId}/download`}
|
||||
alt={lightbox.originalName}
|
||||
className="max-h-full max-w-full rounded"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
components/files/AttachmentPicker.tsx
Normal file
163
components/files/AttachmentPicker.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
} from 'react';
|
||||
|
||||
export interface AttachmentPickerHandle {
|
||||
getFileIds: () => number[];
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
interface PickedFile {
|
||||
fileId: number;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* チャット/掲示板用の添付ファイルピッカー。
|
||||
* 選択したファイルを添付用エンドポイントへアップロードし、
|
||||
* 親フォームは送信時に getFileIds() でファイルIDを取り出す。
|
||||
* 送信完了後は clear() で状態をリセットする。
|
||||
*/
|
||||
export const AttachmentPicker = forwardRef<
|
||||
AttachmentPickerHandle,
|
||||
{ projectId: number; onLoadingChange?: (loading: boolean) => void }
|
||||
>(function AttachmentPicker({ projectId, onLoadingChange }, ref) {
|
||||
const [files, setFiles] = useState<PickedFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function reportLoading(next: boolean) {
|
||||
setLoading(next);
|
||||
onLoadingChange?.(next);
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getFileIds: () => files.map((f) => f.fileId),
|
||||
clear: () => {
|
||||
setFiles([]);
|
||||
setError(null);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
},
|
||||
}),
|
||||
[files]
|
||||
);
|
||||
|
||||
async function onFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const selected = Array.from(event.target.files ?? []);
|
||||
if (selected.length === 0) return;
|
||||
reportLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const uploaded: PickedFile[] = [];
|
||||
for (const f of selected) {
|
||||
const form = new FormData();
|
||||
form.append('file', f);
|
||||
const res = await fetch(`/api/projects/${projectId}/attachments`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
throw new Error(b?.error?.message ?? 'アップロードに失敗しました');
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
file: { id: number; originalName: string; mimeType: string };
|
||||
};
|
||||
uploaded.push({
|
||||
fileId: data.file.id,
|
||||
originalName: data.file.originalName,
|
||||
mimeType: data.file.mimeType,
|
||||
});
|
||||
}
|
||||
setFiles((prev) => [...prev, ...uploaded]);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : 'アップロードに失敗しました'
|
||||
);
|
||||
} finally {
|
||||
reportLoading(false);
|
||||
if (inputRef.current) inputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function removeFile(fileId: number) {
|
||||
setFiles((prev) => prev.filter((f) => f.fileId !== fileId));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-testid="attachment-picker">
|
||||
<label className="inline-flex cursor-pointer items-center gap-1 rounded border bg-white px-3 py-1 text-sm text-gray-600 hover:bg-gray-100">
|
||||
📎 添付
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={onFile}
|
||||
disabled={loading}
|
||||
className="hidden"
|
||||
data-testid="attachment-input"
|
||||
/>
|
||||
</label>
|
||||
{loading && (
|
||||
<p className="text-xs text-gray-500" data-testid="attachment-loading">
|
||||
アップロード中...
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{files.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{files.map((f) => {
|
||||
const isImage = f.mimeType.startsWith('image/');
|
||||
const url = `/api/files/${f.fileId}/download`;
|
||||
return (
|
||||
<li
|
||||
key={f.fileId}
|
||||
className="relative overflow-hidden rounded border bg-gray-50"
|
||||
data-testid={`attachment-picked-${f.fileId}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={f.originalName}
|
||||
className="h-16 w-16 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex h-16 w-24 items-center justify-center px-1 text-center text-[10px] text-blue-600">
|
||||
<span className="truncate" title={f.originalName}>
|
||||
{f.originalName}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(f.fileId)}
|
||||
className="absolute right-0 top-0 rounded-bl bg-black/50 px-1 text-xs text-white hover:bg-black/70"
|
||||
aria-label={`${f.originalName} を削除`}
|
||||
data-testid={`attachment-remove-${f.fileId}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@ -2,6 +2,8 @@
|
||||
|
||||
import { useCallback, useState, type DragEvent } from 'react';
|
||||
import type { TodoColumn, TodoItem } from '@/lib/types';
|
||||
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
|
||||
import { TodoDialog } from '@/components/todo/TodoDialog';
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
high: 'bg-red-100 text-red-700',
|
||||
@ -13,13 +15,16 @@ export function KanbanBoard({
|
||||
projectId,
|
||||
columns,
|
||||
initialItems,
|
||||
members,
|
||||
}: {
|
||||
projectId: number;
|
||||
columns: TodoColumn[];
|
||||
initialItems: TodoItem[];
|
||||
members: ProjectMemberWithUser[];
|
||||
}) {
|
||||
const [items, setItems] = useState<TodoItem[]>(initialItems);
|
||||
const [draggingId, setDraggingId] = useState<number | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<TodoItem | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const res = await fetch(`/api/projects/${projectId}/todos/items`);
|
||||
@ -44,7 +49,7 @@ export function KanbanBoard({
|
||||
setDraggingId(null);
|
||||
return;
|
||||
}
|
||||
// 楽観的にローカル更新したあとAPIで移動
|
||||
// 楽観的にローカル更新したあとAPIで移動(失敗時は再取得で巻き戻す)
|
||||
setItems((prev) =>
|
||||
prev.map((i) => (i.id === itemId ? { ...i, columnId: column.id } : i))
|
||||
);
|
||||
@ -52,7 +57,9 @@ export function KanbanBoard({
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ columnId: column.id, orderIndex: 0 }),
|
||||
}).then(() => reload());
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => reload());
|
||||
setDraggingId(null);
|
||||
}
|
||||
|
||||
@ -91,48 +98,99 @@ export function KanbanBoard({
|
||||
{column.name} ({colItems.length})
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{colItems.map((item) => (
|
||||
<li
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, item.id)}
|
||||
className={`cursor-grab rounded border bg-white p-2 shadow-sm ${
|
||||
draggingId === item.id ? 'opacity-50' : ''
|
||||
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
|
||||
data-testid={`todo-card-${item.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm">{item.title}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleComplete(item.id)}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
data-testid={`todo-complete-${item.id}`}
|
||||
>
|
||||
{item.completedAt ? '戻す' : '完了'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-1">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
PRIORITY_COLORS[item.priority] ?? PRIORITY_COLORS.normal
|
||||
}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
{item.dueDate && (
|
||||
<span className="text-xs text-gray-500">
|
||||
期限: {item.dueDate}
|
||||
{colItems.map((item) => {
|
||||
const tags = item.tags
|
||||
?.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
const assignee = members.find(
|
||||
(m) => m.user.id === item.assigneeId
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={item.id}
|
||||
draggable
|
||||
onDragStart={(e) => onDragStart(e, item.id)}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className={`cursor-grab rounded border bg-white p-2 shadow-sm ${
|
||||
draggingId === item.id ? 'opacity-50' : ''
|
||||
} ${item.completedAt ? 'opacity-60 line-through' : ''}`}
|
||||
data-testid={`todo-card-${item.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span
|
||||
className="text-sm hover:text-blue-600 hover:underline"
|
||||
data-testid={`todo-open-${item.id}`}
|
||||
>
|
||||
{item.title}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleComplete(item.id);
|
||||
}}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
data-testid={`todo-complete-${item.id}`}
|
||||
>
|
||||
{item.completedAt ? '戻す' : '完了'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
className={`rounded px-1.5 py-0.5 text-xs ${
|
||||
PRIORITY_COLORS[item.priority] ??
|
||||
PRIORITY_COLORS.normal
|
||||
}`}
|
||||
>
|
||||
{item.priority}
|
||||
</span>
|
||||
{item.dueDate && (
|
||||
<span className="text-xs text-gray-500">
|
||||
期限: {item.dueDate}
|
||||
</span>
|
||||
)}
|
||||
{item.startDate && (
|
||||
<span className="text-xs text-gray-400">
|
||||
開始: {item.startDate}
|
||||
</span>
|
||||
)}
|
||||
{assignee && (
|
||||
<span className="text-xs text-gray-500">
|
||||
@{assignee.user.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<NewTaskInput onAdd={(title) => addTask(column.id, title)} />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{selectedItem && (
|
||||
<TodoDialog
|
||||
projectId={projectId}
|
||||
item={selectedItem}
|
||||
members={members}
|
||||
onClose={() => setSelectedItem(null)}
|
||||
onSaved={reload}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
311
components/todo/TodoDialog.tsx
Normal file
311
components/todo/TodoDialog.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { AttachmentView, TodoItem, TodoPriority } from '@/lib/types';
|
||||
import type { ProjectMemberWithUser } from '@/repositories/ProjectMemberRepository';
|
||||
import { AttachmentList } from '@/components/files/AttachmentList';
|
||||
import {
|
||||
AttachmentPicker,
|
||||
type AttachmentPickerHandle,
|
||||
} from '@/components/files/AttachmentPicker';
|
||||
|
||||
const PRIORITIES: TodoPriority[] = ['low', 'normal', 'high'];
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
return tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* ToDo編集/詳細ダイアログ。
|
||||
* カードクリックで開き、タイトル/説明/担当/優先度/開始日/期限/タグ/添付を
|
||||
* 編集・閲覧できる。完了日(完了時刻)・作成/更新日時は読み取り専用で表示。
|
||||
*/
|
||||
export function TodoDialog({
|
||||
projectId,
|
||||
item,
|
||||
members,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
projectId: number;
|
||||
item: TodoItem;
|
||||
members: ProjectMemberWithUser[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void | Promise<void>;
|
||||
}) {
|
||||
const [title, setTitle] = useState(item.title);
|
||||
const [description, setDescription] = useState(item.description ?? '');
|
||||
const [assigneeId, setAssigneeId] = useState<string>(
|
||||
item.assigneeId ? String(item.assigneeId) : ''
|
||||
);
|
||||
const [priority, setPriority] = useState<TodoPriority>(item.priority);
|
||||
const [startDate, setStartDate] = useState(item.startDate ?? '');
|
||||
const [dueDate, setDueDate] = useState(item.dueDate ?? '');
|
||||
const [tags, setTags] = useState(item.tags ?? '');
|
||||
const [attachments, setAttachments] = useState<AttachmentView[]>([]);
|
||||
const [current, setCurrent] = useState<TodoItem>(item);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pickerLoading, setPickerLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pickerRef = useRef<AttachmentPickerHandle>(null);
|
||||
|
||||
// 開封時に最新のアイテム+添付を取得
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
fetch(`/api/projects/${projectId}/todos/items/${item.id}`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => {
|
||||
if (!alive || !data) return;
|
||||
const fetched = data.item as TodoItem;
|
||||
setTitle(fetched.title);
|
||||
setDescription(fetched.description ?? '');
|
||||
setAssigneeId(fetched.assigneeId ? String(fetched.assigneeId) : '');
|
||||
setPriority(fetched.priority);
|
||||
setStartDate(fetched.startDate ?? '');
|
||||
setDueDate(fetched.dueDate ?? '');
|
||||
setTags(fetched.tags ?? '');
|
||||
setAttachments((data.attachments as AttachmentView[]) ?? []);
|
||||
setCurrent(fetched);
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => alive && setLoading(false));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [projectId, item.id]);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
async function onSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const fileIds = pickerRef.current?.getFileIds() ?? [];
|
||||
const res = await fetch(
|
||||
`/api/projects/${projectId}/todos/items/${item.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
assigneeId: assigneeId ? Number(assigneeId) : null,
|
||||
priority,
|
||||
startDate: startDate || null,
|
||||
dueDate: dueDate || null,
|
||||
tags: tags || null,
|
||||
fileIds,
|
||||
}),
|
||||
}
|
||||
);
|
||||
setSaving(false);
|
||||
if (res.ok) {
|
||||
pickerRef.current?.clear();
|
||||
await onSaved();
|
||||
onClose();
|
||||
} else {
|
||||
const b = (await res.json().catch(() => null)) as {
|
||||
error?: { message?: string };
|
||||
} | null;
|
||||
setError(b?.error?.message ?? '保存に失敗しました');
|
||||
}
|
||||
}
|
||||
|
||||
const tagList = parseTags(tags);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center bg-black/40 p-4"
|
||||
onClick={onClose}
|
||||
data-testid="todo-dialog-backdrop"
|
||||
>
|
||||
<div
|
||||
className="mt-8 w-full max-w-xl rounded-lg bg-white shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`ToDo ${item.title} の編集`}
|
||||
data-testid="todo-dialog"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<h2 className="text-lg font-bold text-gray-800">ToDoの編集</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
aria-label="閉じる"
|
||||
data-testid="todo-dialog-close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="p-6 text-sm text-gray-400">読み込み中...</p>
|
||||
) : (
|
||||
<div className="max-h-[70vh] space-y-3 overflow-y-auto p-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">タイトル</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-title"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">説明</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="mt-1 min-h-[80px] w-full rounded border px-3 py-2"
|
||||
data-testid="todo-description"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">担当者</label>
|
||||
<select
|
||||
value={assigneeId}
|
||||
onChange={(e) => setAssigneeId(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-assignee"
|
||||
>
|
||||
<option value="">未割当</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.user.id} value={String(m.user.id)}>
|
||||
{m.user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">優先度</label>
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as TodoPriority)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-priority"
|
||||
>
|
||||
{PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium">開始日</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-start-date"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">
|
||||
期限 (deadline)
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dueDate}
|
||||
onChange={(e) => setDueDate(e.target.value)}
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-due-date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium">
|
||||
タグ (カンマ区切り)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="frontend, urgent"
|
||||
className="mt-1 w-full rounded border px-3 py-2"
|
||||
data-testid="todo-tags"
|
||||
/>
|
||||
{tagList.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{tagList.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-sm font-medium">添付ファイル</p>
|
||||
{attachments.length > 0 ? (
|
||||
<AttachmentList attachments={attachments} />
|
||||
) : (
|
||||
<p className="text-xs text-gray-400">添付なし</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<AttachmentPicker
|
||||
ref={pickerRef}
|
||||
projectId={projectId}
|
||||
onLoadingChange={setPickerLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 border-t pt-3 text-xs text-gray-500">
|
||||
<p>完了日時: {current.completedAt ?? '未完了'}</p>
|
||||
<p>作成: {current.createdAt}</p>
|
||||
<p>更新: {current.updatedAt}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 border-t p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded border px-4 py-2 text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={saving || pickerLoading || loading}
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
data-testid="todo-save"
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -200,6 +200,7 @@ interface TodoItem {
|
||||
completedAt: string | null;
|
||||
orderIndex: number;
|
||||
milestoneId: number | null; // FK milestones.id
|
||||
tags: string | null; // カンマ区切りのタグ(project_notes.tags と同じ方式)
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
@ -219,9 +220,31 @@ interface FileAsset {
|
||||
mimeType: string; // MIMEタイプ(アップロード時チェック)
|
||||
size: number; // バイト数
|
||||
path: string; // ローカルパス(uploads/...)
|
||||
source: FileAssetSource; // 'library'(Files一覧公開) | 'attachment'(添付専用)
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
type FileAssetSource = 'library' | 'attachment';
|
||||
```
|
||||
|
||||
### attachments
|
||||
|
||||
```typescript
|
||||
// file_assets とチャット/掲示板/ToDo を多対多で紐付ける
|
||||
interface Attachment {
|
||||
id: number;
|
||||
projectId: number; // FK projects.id ON DELETE CASCADE
|
||||
fileId: number; // FK file_assets.id
|
||||
targetType: AttachmentTargetType;
|
||||
targetId: number; // targetType に応じた chat_message/board_thread/board_comment/todo_item の id
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
type AttachmentTargetType =
|
||||
| 'chat_message'
|
||||
| 'board_thread'
|
||||
| 'board_comment'
|
||||
| 'todo_item';
|
||||
```
|
||||
|
||||
### project_notes
|
||||
@ -410,6 +433,7 @@ erDiagram
|
||||
integer assignee_id FK
|
||||
integer milestone_id FK
|
||||
text due_date
|
||||
text tags
|
||||
}
|
||||
meetings {
|
||||
integer id PK
|
||||
|
||||
@ -181,6 +181,7 @@ repositories/
|
||||
├── ChatRepository.ts
|
||||
├── TodoRepository.ts
|
||||
├── FileRepository.ts
|
||||
├── AttachmentRepository.ts
|
||||
├── CalendarRepository.ts
|
||||
├── MeetingRepository.ts
|
||||
├── ProjectNoteRepository.ts
|
||||
@ -208,6 +209,7 @@ services/
|
||||
├── MeetingService.ts
|
||||
├── ScheduleService.ts
|
||||
├── FileStorageService.ts
|
||||
├── AttachmentService.ts
|
||||
├── BackupService.ts
|
||||
├── TodoService.ts
|
||||
├── BoardService.ts
|
||||
@ -233,9 +235,9 @@ components/
|
||||
├── project/ # ProjectCard, DashboardWidget
|
||||
├── board/ # ThreadList, ThreadForm, CommentList
|
||||
├── chat/ # ChatWindow, MessageInput, MessageList
|
||||
├── todo/ # KanbanBoard, KanbanColumn, TodoCard
|
||||
├── files/ # FileList, Uploader, Lightbox
|
||||
├── calendar/ # CalendarView, EventBadge
|
||||
├── todo/ # KanbanBoard, TodoDialog
|
||||
├── files/ # FileList, Uploader, Lightbox, AttachmentList, AttachmentPicker
|
||||
├── calendar/ # CalendarView, MonthView, WeekView, DayView, EventDetailDialog, CalendarEventForm
|
||||
├── meetings/ # MeetingForm, ConflictWarning
|
||||
├── notes/ # NoteEditor, MarkdownPreview
|
||||
└── notifications/ # NotificationList, NotificationBadge
|
||||
|
||||
@ -18,6 +18,8 @@ import { TodoService } from '@/services/TodoService';
|
||||
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { FileStorageService } from '@/services/FileStorageService';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
|
||||
import { ScheduleService } from '@/services/ScheduleService';
|
||||
import { CalendarRepository } from '@/repositories/CalendarRepository';
|
||||
import { MilestoneRepository } from '@/repositories/MilestoneRepository';
|
||||
@ -51,11 +53,18 @@ export function createActivityLogService(): ActivityLogService {
|
||||
|
||||
export function createBoardService(): BoardService {
|
||||
const db = getDb();
|
||||
const memberRepo = new ProjectMemberRepository(db);
|
||||
return new BoardService(
|
||||
new BoardRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
memberRepo,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db))
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
memberRepo
|
||||
),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
@ -71,23 +80,37 @@ export function createNoteService(): NoteService {
|
||||
|
||||
export function createChatService(): ChatService {
|
||||
const db = getDb();
|
||||
const memberRepo = new ProjectMemberRepository(db);
|
||||
return new ChatService(
|
||||
new ChatRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
memberRepo,
|
||||
new UserRepository(db),
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
getSseHub()
|
||||
getSseHub(),
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
memberRepo
|
||||
),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
export function createTodoService(): TodoService {
|
||||
const db = getDb();
|
||||
const memberRepo = new ProjectMemberRepository(db);
|
||||
return new TodoService(
|
||||
new TodoRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
memberRepo,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
getSseHub()
|
||||
getSseHub(),
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
memberRepo
|
||||
),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
159
lib/calendar/grid.ts
Normal file
159
lib/calendar/grid.ts
Normal file
@ -0,0 +1,159 @@
|
||||
/**
|
||||
* カレンダー表示用の純粋な日付計算ヘルパー群。
|
||||
* React にも DB にも依存しないので Unit Test が容易。
|
||||
*
|
||||
* 週の開始は日曜日(Sunday=0)。`WEEK_START` を変更すれば月曜始めにも対応可能。
|
||||
* イベントは構造的部分型 `{ startAt: string }` を要求するだけなので
|
||||
* `CalendarEventView` に依存せず、レイヤ依存規則(lib → services 禁止)を守る。
|
||||
*/
|
||||
|
||||
/** 週の開始曜日 (0=日曜)。 */
|
||||
export const WEEK_START = 0;
|
||||
|
||||
export type CalendarViewMode = 'month' | 'week' | 'day';
|
||||
|
||||
/** Event 風オブジェクト。startAt だけ必須。 */
|
||||
export interface DateableEvent {
|
||||
startAt: string;
|
||||
}
|
||||
|
||||
/** Date を `YYYY-MM-DD` へ変換する(ローカルタイム)。 */
|
||||
export function toISODate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` をローカル Date(真夜中) へパースする。 */
|
||||
export function parseISODate(s: string): Date {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
return new Date(y, (m ?? 1) - 1, d ?? 1);
|
||||
}
|
||||
|
||||
/** n 日加算した新しい Date を返す。 */
|
||||
export function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + n);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** n 週加算した新しい Date を返す。 */
|
||||
export function addWeeks(d: Date, n: number): Date {
|
||||
return addDays(d, n * 7);
|
||||
}
|
||||
|
||||
/** n 月加算した新しい Date を返す(日付は維持、月跨ぎ時は月末日に丸める)。 */
|
||||
export function addMonths(d: Date, n: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setMonth(r.getMonth() + n);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** 引数の日を含む週の開始日(WEEK_START 基準)を返す。 */
|
||||
export function startOfWeek(d: Date): Date {
|
||||
const r = new Date(d);
|
||||
r.setHours(0, 0, 0, 0);
|
||||
const delta = (r.getDay() - WEEK_START + 7) % 7;
|
||||
r.setDate(r.getDate() - delta);
|
||||
return r;
|
||||
}
|
||||
|
||||
/**
|
||||
* 月カレンダーのグリッド(週の配列)を返す。
|
||||
* 前月/翌月の日を含めて日曜始まり・土曜終わりで埋める。
|
||||
*/
|
||||
export function getMonthGrid(year: number, month: number): Date[][] {
|
||||
const first = new Date(year, month, 1);
|
||||
const gridStart = startOfWeek(first);
|
||||
const last = new Date(year, month + 1, 0);
|
||||
const weeks: Date[][] = [];
|
||||
let cursor = new Date(gridStart);
|
||||
while (cursor <= last) {
|
||||
const week: Date[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
week.push(new Date(cursor));
|
||||
cursor = addDays(cursor, 1);
|
||||
}
|
||||
weeks.push(week);
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
|
||||
/** 引数の日を含む週の 7 日分(日〜土)を返す。 */
|
||||
export function getWeekDays(anchor: Date): Date[] {
|
||||
const start = startOfWeek(anchor);
|
||||
return Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||
}
|
||||
|
||||
/** 1日の時間グリッド 0..23 を返す。 */
|
||||
export function getDayHours(): number[] {
|
||||
return Array.from({ length: 24 }, (_, i) => i);
|
||||
}
|
||||
|
||||
/** イベントの開始日を `YYYY-MM-DD` で取り出す(startAt の先頭10文字)。 */
|
||||
export function eventDateKey(startAt: string): string {
|
||||
return startAt.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* イベントの開始「時」を返す。時刻なし(日付のみ)なら null(終日扱い)。
|
||||
* `2026-06-15T10:00:00` → 10 / `2026-06-15` → null
|
||||
*/
|
||||
export function eventHour(startAt: string): number | null {
|
||||
if (startAt.length <= 10) return null;
|
||||
const h = parseInt(startAt.slice(11, 13), 10);
|
||||
return Number.isNaN(h) ? null : h;
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DDTHH:mm:ss` または `YYYY-MM-DD` から `HH:mm` を取り出す。 */
|
||||
export function formatTime(startAt: string): string {
|
||||
if (startAt.length <= 10) return '終日';
|
||||
const hh = startAt.slice(11, 13);
|
||||
const mm = startAt.slice(14, 16);
|
||||
return mm ? `${hh}:${mm}` : `${hh}:00`;
|
||||
}
|
||||
|
||||
/** 指定日のイベントだけを抽出する。 */
|
||||
export function eventsOnDay<T extends DateableEvent>(
|
||||
events: T[],
|
||||
dateKey: string
|
||||
): T[] {
|
||||
return events.filter((e) => eventDateKey(e.startAt) === dateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* ビューと基準日から ScheduleService に渡す { from, to } を計算する。
|
||||
* `to` は `T23:59:59` 付きにし、サービスの文字列比較で当日深夜のイベントも取りこぼさない。
|
||||
* month はグリッド全体(前月/翌月の溢れ日を含む)をカバーする。
|
||||
*/
|
||||
export function rangeForView(
|
||||
view: CalendarViewMode,
|
||||
anchor: Date
|
||||
): { from: string; to: string } {
|
||||
if (view === 'day') {
|
||||
const d = toISODate(anchor);
|
||||
return { from: d, to: `${d}T23:59:59` };
|
||||
}
|
||||
if (view === 'week') {
|
||||
const days = getWeekDays(anchor);
|
||||
const from = toISODate(days[0]);
|
||||
const to = `${toISODate(days[6])}T23:59:59`;
|
||||
return { from, to };
|
||||
}
|
||||
const weeks = getMonthGrid(anchor.getFullYear(), anchor.getMonth());
|
||||
const from = toISODate(weeks[0][0]);
|
||||
const to = `${toISODate(weeks[weeks.length - 1][6])}T23:59:59`;
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
/** ビューに応じて基準日を1単位進める/戻す。 */
|
||||
export function stepAnchor(
|
||||
view: CalendarViewMode,
|
||||
anchor: Date,
|
||||
direction: 1 | -1
|
||||
): Date {
|
||||
if (view === 'month') return addMonths(anchor, direction);
|
||||
if (view === 'week') return addWeeks(anchor, direction);
|
||||
return addDays(anchor, direction);
|
||||
}
|
||||
22
lib/db/migrations/002_attachments.sql
Normal file
22
lib/db/migrations/002_attachments.sql
Normal file
@ -0,0 +1,22 @@
|
||||
-- 002_attachments.sql
|
||||
-- チャット/掲示板への添付ファイル関連付けテーブルと、file_assets の来源区分。
|
||||
|
||||
-- 11. attachments: メッセージ/スレッド/コメント と file_assets の多対多関連
|
||||
CREATE TABLE attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
deleted_at TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (file_id) REFERENCES file_assets(id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_attachments_target ON attachments(target_type, target_id);
|
||||
CREATE INDEX idx_attachments_file ON attachments(file_id);
|
||||
|
||||
-- file_assets に source 列を追加(ライブラリ公開 vs 添付専用)。
|
||||
-- 既存レコードは 'library' となり Files 一覧にそのまま表示される。
|
||||
ALTER TABLE file_assets ADD COLUMN source TEXT NOT NULL DEFAULT 'library';
|
||||
4
lib/db/migrations/003_todo_tags.sql
Normal file
4
lib/db/migrations/003_todo_tags.sql
Normal file
@ -0,0 +1,4 @@
|
||||
-- 003_todo_tags.sql
|
||||
-- ToDoアイテムにタグ列を追加(カンマ区切り、project_notes.tags と同じ方式)。
|
||||
|
||||
ALTER TABLE todo_items ADD COLUMN tags TEXT;
|
||||
@ -127,6 +127,7 @@ export interface TodoItem {
|
||||
completedAt: string | null;
|
||||
orderIndex: number;
|
||||
milestoneId: number | null;
|
||||
tags: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
@ -141,10 +142,48 @@ export interface FileAsset {
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
source: FileAssetSource;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
/** ファイルの来源。library=Files一覧公開、attachment=添付専用(一覧に出さない)。 */
|
||||
export type FileAssetSource = 'library' | 'attachment';
|
||||
|
||||
/** 添付ファイルの関連付け対象。 */
|
||||
export type AttachmentTargetType =
|
||||
| 'chat_message'
|
||||
| 'board_thread'
|
||||
| 'board_comment'
|
||||
| 'todo_item';
|
||||
|
||||
/** attachments エンティティ。 */
|
||||
export interface Attachment {
|
||||
id: number;
|
||||
projectId: number;
|
||||
fileId: number;
|
||||
targetType: AttachmentTargetType;
|
||||
targetId: number;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
}
|
||||
|
||||
/** UI表示用の添付ファイルビュー(ダウンロードURLは fileId から組み立てる)。 */
|
||||
export interface AttachmentView {
|
||||
id: number;
|
||||
fileId: number;
|
||||
targetType: AttachmentTargetType;
|
||||
targetId: number;
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
/** チャットメッセージ + 添付ファイル。 */
|
||||
export interface ChatMessageWithAttachments extends ChatMessage {
|
||||
attachments: AttachmentView[];
|
||||
}
|
||||
|
||||
export interface ProjectNote {
|
||||
id: number;
|
||||
projectId: number;
|
||||
@ -281,7 +320,10 @@ export type NotificationEvent =
|
||||
export type SseEvent =
|
||||
| {
|
||||
type: 'chat.message.created';
|
||||
data: { projectId: number; message: ChatMessage };
|
||||
data: {
|
||||
projectId: number;
|
||||
message: ChatMessageWithAttachments;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'chat.message.updated';
|
||||
|
||||
135
repositories/AttachmentRepository.ts
Normal file
135
repositories/AttachmentRepository.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type {
|
||||
Attachment,
|
||||
AttachmentTargetType,
|
||||
AttachmentView,
|
||||
} from '@/lib/types';
|
||||
|
||||
interface AttachmentRow {
|
||||
id: number;
|
||||
project_id: number;
|
||||
file_id: number;
|
||||
target_type: string;
|
||||
target_id: number;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
|
||||
interface AttachmentViewRow {
|
||||
id: number;
|
||||
file_id: number;
|
||||
target_type: string;
|
||||
target_id: number;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
function mapAttachment(row: AttachmentRow): Attachment {
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.project_id,
|
||||
fileId: row.file_id,
|
||||
targetType: row.target_type as AttachmentTargetType,
|
||||
targetId: row.target_id,
|
||||
createdAt: row.created_at,
|
||||
deletedAt: row.deleted_at,
|
||||
};
|
||||
}
|
||||
|
||||
function mapView(row: AttachmentViewRow): AttachmentView {
|
||||
return {
|
||||
id: row.id,
|
||||
fileId: row.file_id,
|
||||
targetType: row.target_type as AttachmentTargetType,
|
||||
targetId: row.target_id,
|
||||
originalName: row.original_name,
|
||||
mimeType: row.mime_type,
|
||||
size: row.size,
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateAttachmentInput {
|
||||
projectId: number;
|
||||
fileId: number;
|
||||
targetType: AttachmentTargetType;
|
||||
targetId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* attachments テーブルのCRUDを担うRepository。
|
||||
* file_assets とJOINして表示用ビューを返す。
|
||||
*/
|
||||
export class AttachmentRepository {
|
||||
constructor(private readonly db: SqliteDatabase) {}
|
||||
|
||||
create(input: CreateAttachmentInput): Attachment {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.execute(
|
||||
`INSERT INTO attachments (project_id, file_id, target_type, target_id, created_at, deleted_at)
|
||||
VALUES (@projectId, @fileId, @targetType, @targetId, @createdAt, NULL)`,
|
||||
{
|
||||
projectId: input.projectId,
|
||||
fileId: input.fileId,
|
||||
targetType: input.targetType,
|
||||
targetId: input.targetId,
|
||||
createdAt: now,
|
||||
}
|
||||
);
|
||||
const created = this.findById(Number(result.lastInsertRowid));
|
||||
if (!created) throw new Error('Failed to create attachment');
|
||||
return created;
|
||||
}
|
||||
|
||||
findById(id: number): Attachment | null {
|
||||
const row = this.db.get<AttachmentRow>(
|
||||
'SELECT * FROM attachments WHERE id = @id AND deleted_at IS NULL',
|
||||
{ id }
|
||||
);
|
||||
return row ? mapAttachment(row) : null;
|
||||
}
|
||||
|
||||
findByTarget(
|
||||
targetType: AttachmentTargetType,
|
||||
targetId: number
|
||||
): Attachment[] {
|
||||
const rows = this.db.query<AttachmentRow>(
|
||||
`SELECT * FROM attachments
|
||||
WHERE target_type = @targetType AND target_id = @targetId AND deleted_at IS NULL
|
||||
ORDER BY id ASC`,
|
||||
{ targetType, targetId }
|
||||
);
|
||||
return rows.map(mapAttachment);
|
||||
}
|
||||
|
||||
/** 複数ターゲットの添付ビューを一括取得(N+1回避)。 */
|
||||
findViewsByTargets(
|
||||
targetType: AttachmentTargetType,
|
||||
targetIds: number[]
|
||||
): AttachmentView[] {
|
||||
if (targetIds.length === 0) return [];
|
||||
const placeholders = targetIds.map(() => '?').join(',');
|
||||
const rows = this.db.query<AttachmentViewRow>(
|
||||
`SELECT a.id AS id, a.file_id AS file_id, a.target_type AS target_type,
|
||||
a.target_id AS target_id, f.original_name AS original_name,
|
||||
f.mime_type AS mime_type, f.size AS size
|
||||
FROM attachments a
|
||||
JOIN file_assets f ON f.id = a.file_id
|
||||
WHERE a.target_type = ? AND a.deleted_at IS NULL
|
||||
AND f.deleted_at IS NULL
|
||||
AND a.target_id IN (${placeholders})
|
||||
ORDER BY a.target_id ASC, a.id ASC`,
|
||||
[targetType, ...targetIds]
|
||||
);
|
||||
return rows.map(mapView);
|
||||
}
|
||||
|
||||
/** 対象の添付を論理削除(メッセージ/スレッド/コメント削除時のクリーンアップ)。 */
|
||||
deleteByTarget(targetType: AttachmentTargetType, targetId: number): boolean {
|
||||
const result = this.db.execute(
|
||||
'UPDATE attachments SET deleted_at = @now WHERE target_type = @targetType AND target_id = @targetId AND deleted_at IS NULL',
|
||||
{ now: new Date().toISOString(), targetType, targetId }
|
||||
);
|
||||
return result.changes > 0;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type { FileAsset } from '@/lib/types';
|
||||
import type { FileAsset, FileAssetSource } from '@/lib/types';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
@ -17,6 +17,7 @@ interface FileAssetRow {
|
||||
mime_type: string;
|
||||
size: number;
|
||||
path: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
deleted_at: string | null;
|
||||
}
|
||||
@ -31,6 +32,7 @@ function mapFile(row: FileAssetRow): FileAsset {
|
||||
mimeType: row.mime_type,
|
||||
size: row.size,
|
||||
path: row.path,
|
||||
source: row.source as FileAssetSource,
|
||||
createdAt: row.created_at,
|
||||
deletedAt: row.deleted_at,
|
||||
};
|
||||
@ -44,6 +46,7 @@ export interface CreateFileInput {
|
||||
mimeType: string;
|
||||
size: number;
|
||||
path: string;
|
||||
source?: FileAssetSource;
|
||||
}
|
||||
|
||||
export class FileRepository {
|
||||
@ -55,15 +58,17 @@ export class FileRepository {
|
||||
pageSize: number = DEFAULT_PAGE_SIZE
|
||||
): Paginated<FileAsset> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
// 添付専用ファイル(source='attachment')はFiles一覧に出さない
|
||||
const items = this.db.query<FileAssetRow>(
|
||||
`SELECT * FROM file_assets
|
||||
WHERE project_id = @projectId AND deleted_at IS NULL
|
||||
WHERE project_id = @projectId AND deleted_at IS NULL AND source = 'library'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT @pageSize OFFSET @offset`,
|
||||
{ projectId, pageSize, offset }
|
||||
);
|
||||
const total = this.db.get<{ count: number }>(
|
||||
'SELECT COUNT(*) AS count FROM file_assets WHERE project_id = @projectId AND deleted_at IS NULL',
|
||||
`SELECT COUNT(*) AS count FROM file_assets
|
||||
WHERE project_id = @projectId AND deleted_at IS NULL AND source = 'library'`,
|
||||
{ projectId }
|
||||
);
|
||||
return { items: items.map(mapFile), total: total?.count ?? 0 };
|
||||
@ -79,9 +84,10 @@ export class FileRepository {
|
||||
|
||||
create(input: CreateFileInput): FileAsset {
|
||||
const now = new Date().toISOString();
|
||||
const source = input.source ?? 'library';
|
||||
const result = this.db.execute(
|
||||
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, created_at, deleted_at)
|
||||
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @createdAt, NULL)`,
|
||||
`INSERT INTO file_assets (project_id, uploader_id, filename, original_name, mime_type, size, path, source, created_at, deleted_at)
|
||||
VALUES (@projectId, @uploaderId, @filename, @originalName, @mimeType, @size, @path, @source, @createdAt, NULL)`,
|
||||
{
|
||||
projectId: input.projectId,
|
||||
uploaderId: input.uploaderId,
|
||||
@ -90,6 +96,7 @@ export class FileRepository {
|
||||
mimeType: input.mimeType,
|
||||
size: input.size,
|
||||
path: input.path,
|
||||
source,
|
||||
createdAt: now,
|
||||
}
|
||||
);
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import type { Milestone, MilestoneStatus, TodoItem } from '@/lib/types';
|
||||
import type {
|
||||
Milestone,
|
||||
MilestoneStatus,
|
||||
TodoItem,
|
||||
TodoPriority,
|
||||
} from '@/lib/types';
|
||||
|
||||
interface MilestoneRow {
|
||||
id: number;
|
||||
@ -27,6 +32,7 @@ interface TodoItemRow {
|
||||
completed_at: string | null;
|
||||
order_index: number;
|
||||
milestone_id: number | null;
|
||||
tags: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
@ -55,12 +61,13 @@ function mapTodo(row: TodoItemRow): TodoItem {
|
||||
description: row.description,
|
||||
assigneeId: row.assignee_id,
|
||||
creatorId: row.creator_id,
|
||||
priority: row.priority as never,
|
||||
priority: row.priority as TodoPriority,
|
||||
startDate: row.start_date,
|
||||
dueDate: row.due_date,
|
||||
completedAt: row.completed_at,
|
||||
orderIndex: row.order_index,
|
||||
milestoneId: row.milestone_id,
|
||||
tags: row.tags,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at,
|
||||
|
||||
@ -24,6 +24,7 @@ interface TodoItemRow {
|
||||
completed_at: string | null;
|
||||
order_index: number;
|
||||
milestone_id: number | null;
|
||||
tags: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
@ -55,6 +56,7 @@ function mapItem(row: TodoItemRow): TodoItem {
|
||||
completedAt: row.completed_at,
|
||||
orderIndex: row.order_index,
|
||||
milestoneId: row.milestone_id,
|
||||
tags: row.tags,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
deletedAt: row.deleted_at,
|
||||
@ -75,7 +77,9 @@ export interface CreateItemInput {
|
||||
description?: string | null;
|
||||
assigneeId?: number | null;
|
||||
priority?: TodoPriority;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
tags?: string | null;
|
||||
orderIndex: number;
|
||||
}
|
||||
|
||||
@ -84,7 +88,9 @@ export interface UpdateItemInput {
|
||||
description?: string | null;
|
||||
assigneeId?: number | null;
|
||||
priority?: TodoPriority;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
tags?: string | null;
|
||||
completedAt?: string | null;
|
||||
columnId?: number;
|
||||
orderIndex?: number;
|
||||
@ -193,8 +199,8 @@ export class TodoRepository {
|
||||
createItem(input: CreateItemInput): TodoItem {
|
||||
const now = new Date().toISOString();
|
||||
const result = this.db.execute(
|
||||
`INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, NULL, @dueDate, NULL, @orderIndex, @milestoneId, @createdAt, @updatedAt, NULL)`,
|
||||
`INSERT INTO todo_items (project_id, column_id, title, description, assignee_id, creator_id, priority, start_date, due_date, completed_at, order_index, milestone_id, tags, created_at, updated_at, deleted_at)
|
||||
VALUES (@projectId, @columnId, @title, @description, @assigneeId, @creatorId, @priority, @startDate, @dueDate, NULL, @orderIndex, @milestoneId, @tags, @createdAt, @updatedAt, NULL)`,
|
||||
{
|
||||
projectId: input.projectId,
|
||||
columnId: input.columnId,
|
||||
@ -203,9 +209,11 @@ export class TodoRepository {
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
creatorId: input.creatorId,
|
||||
priority: input.priority ?? 'normal',
|
||||
startDate: input.startDate ?? null,
|
||||
dueDate: input.dueDate ?? null,
|
||||
orderIndex: input.orderIndex,
|
||||
milestoneId: null,
|
||||
tags: input.tags ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
@ -237,10 +245,18 @@ export class TodoRepository {
|
||||
fields.push('priority = @priority');
|
||||
params.priority = input.priority;
|
||||
}
|
||||
if (input.startDate !== undefined) {
|
||||
fields.push('start_date = @startDate');
|
||||
params.startDate = input.startDate;
|
||||
}
|
||||
if (input.dueDate !== undefined) {
|
||||
fields.push('due_date = @dueDate');
|
||||
params.dueDate = input.dueDate;
|
||||
}
|
||||
if (input.tags !== undefined) {
|
||||
fields.push('tags = @tags');
|
||||
params.tags = input.tags;
|
||||
}
|
||||
if (input.completedAt !== undefined) {
|
||||
fields.push('completed_at = @completedAt');
|
||||
params.completedAt = input.completedAt;
|
||||
|
||||
77
services/AttachmentService.ts
Normal file
77
services/AttachmentService.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
import type { AttachmentTargetType, AttachmentView } from '@/lib/types';
|
||||
|
||||
/**
|
||||
* 添付ファイルの関連付けを担うService。
|
||||
* チャット/掲示板から呼ばれ、file_assets と対象(メッセージ/スレッド/コメント)を紐付ける。
|
||||
* 権限チェック(プロジェクト参加 + 自身のアップロードのみ)とクリーンアップを行う。
|
||||
*/
|
||||
export class AttachmentService {
|
||||
constructor(
|
||||
private readonly attachmentRepository: AttachmentRepository,
|
||||
private readonly fileRepository: FileRepository,
|
||||
private readonly projectMemberRepository: ProjectMemberRepository
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 指定ターゲットへファイルを添付する。
|
||||
* 自身が当該プロジェクトにアップロードしたファイルのみ添付可能。
|
||||
*/
|
||||
attach(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
targetType: AttachmentTargetType,
|
||||
targetId: number,
|
||||
fileIds: number[]
|
||||
): AttachmentView[] {
|
||||
this.requireMember(projectId, actorId);
|
||||
const ids = Array.from(new Set(fileIds ?? []));
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
for (const fileId of ids) {
|
||||
const file = this.fileRepository.findFileById(fileId);
|
||||
if (!file) throw new NotFoundError('FileAsset', fileId);
|
||||
if (file.projectId !== projectId) {
|
||||
throw new ForbiddenError('プロジェクト外のファイルは添付できません');
|
||||
}
|
||||
if (file.uploaderId !== actorId) {
|
||||
throw new ForbiddenError('自身のアップロードファイルのみ添付できます');
|
||||
}
|
||||
this.attachmentRepository.create({
|
||||
projectId,
|
||||
fileId,
|
||||
targetType,
|
||||
targetId,
|
||||
});
|
||||
}
|
||||
return this.attachmentRepository.findViewsByTargets(targetType, [targetId]);
|
||||
}
|
||||
|
||||
listViews(
|
||||
targetType: AttachmentTargetType,
|
||||
targetId: number
|
||||
): AttachmentView[] {
|
||||
return this.attachmentRepository.findViewsByTargets(targetType, [targetId]);
|
||||
}
|
||||
|
||||
listViewsBatch(
|
||||
targetType: AttachmentTargetType,
|
||||
targetIds: number[]
|
||||
): AttachmentView[] {
|
||||
return this.attachmentRepository.findViewsByTargets(targetType, targetIds);
|
||||
}
|
||||
|
||||
/** 対象の添付を論理削除(メッセージ/スレッド/コメント削除時)。 */
|
||||
detach(targetType: AttachmentTargetType, targetId: number): void {
|
||||
this.attachmentRepository.deleteByTarget(targetType, targetId);
|
||||
}
|
||||
|
||||
private requireMember(projectId: number, actorId: number): void {
|
||||
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
|
||||
throw new ForbiddenError('プロジェクトに参加していません');
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,15 @@ import type { Paginated } from '@/repositories/BoardRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import type { BoardCategory, BoardComment, BoardThread } from '@/lib/types';
|
||||
import type {
|
||||
AttachmentView,
|
||||
BoardCategory,
|
||||
BoardComment,
|
||||
BoardThread,
|
||||
} from '@/lib/types';
|
||||
|
||||
const VALID_CATEGORIES: BoardCategory[] = [
|
||||
'notice',
|
||||
@ -23,6 +30,7 @@ export interface CreateThreadInput {
|
||||
title: string;
|
||||
bodyMd: string;
|
||||
category?: BoardCategory;
|
||||
fileIds?: number[];
|
||||
}
|
||||
|
||||
export interface UpdateThreadInput {
|
||||
@ -36,14 +44,17 @@ export interface UpdateThreadInput {
|
||||
/**
|
||||
* 掲示板の業務ロジックを担うService。
|
||||
* 権限チェック(プロジェクト参加者のみ)、スレッド/コメントCRUD、
|
||||
* コメント追加時の通知(board_commented)とアクティビティログ記録を行う。
|
||||
* コメント追加時の通知(board_commented)、アクティビティログ記録、
|
||||
* 添付ファイル紐付けを行う。
|
||||
*/
|
||||
export class BoardService {
|
||||
constructor(
|
||||
private readonly boardRepository: BoardRepository,
|
||||
private readonly projectMemberRepository: ProjectMemberRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly activityLogService: ActivityLogService
|
||||
private readonly activityLogService: ActivityLogService,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly db: SqliteDatabase
|
||||
) {}
|
||||
|
||||
listThreads(
|
||||
@ -69,12 +80,24 @@ export class BoardService {
|
||||
): BoardThread {
|
||||
this.requireMember(projectId, actorId);
|
||||
this.validateThreadInput(input.title, input.bodyMd, input.category);
|
||||
const thread = this.boardRepository.createThread({
|
||||
projectId,
|
||||
title: input.title,
|
||||
bodyMd: input.bodyMd,
|
||||
authorId: actorId,
|
||||
category: input.category ?? null,
|
||||
const thread = this.db.transaction(() => {
|
||||
const t = this.boardRepository.createThread({
|
||||
projectId,
|
||||
title: input.title,
|
||||
bodyMd: input.bodyMd,
|
||||
authorId: actorId,
|
||||
category: input.category ?? null,
|
||||
});
|
||||
if (input.fileIds && input.fileIds.length > 0) {
|
||||
this.attachmentService.attach(
|
||||
actorId,
|
||||
projectId,
|
||||
'board_thread',
|
||||
t.id,
|
||||
input.fileIds
|
||||
);
|
||||
}
|
||||
return t;
|
||||
});
|
||||
this.activityLogService.logActivity({
|
||||
projectId,
|
||||
@ -118,7 +141,10 @@ export class BoardService {
|
||||
deleteThread(actorId: number, threadId: number): void {
|
||||
const thread = this.getThread(actorId, threadId);
|
||||
this.requireAuthorOrAdmin(thread.projectId, actorId, thread.authorId);
|
||||
this.boardRepository.deleteThread(threadId);
|
||||
this.db.transaction(() => {
|
||||
this.attachmentService.detach('board_thread', threadId);
|
||||
this.boardRepository.deleteThread(threadId);
|
||||
});
|
||||
}
|
||||
|
||||
listComments(
|
||||
@ -133,16 +159,29 @@ export class BoardService {
|
||||
createComment(
|
||||
actorId: number,
|
||||
threadId: number,
|
||||
bodyMd: string
|
||||
bodyMd: string,
|
||||
fileIds?: number[]
|
||||
): BoardComment {
|
||||
const thread = this.getThread(actorId, threadId);
|
||||
if (!bodyMd.trim()) {
|
||||
throw new ValidationError('コメント本文を入力してください', 'bodyMd');
|
||||
}
|
||||
const comment = this.boardRepository.createComment({
|
||||
threadId: thread.id,
|
||||
authorId: actorId,
|
||||
bodyMd,
|
||||
const comment = this.db.transaction(() => {
|
||||
const c = this.boardRepository.createComment({
|
||||
threadId: thread.id,
|
||||
authorId: actorId,
|
||||
bodyMd,
|
||||
});
|
||||
if (fileIds && fileIds.length > 0) {
|
||||
this.attachmentService.attach(
|
||||
actorId,
|
||||
thread.projectId,
|
||||
'board_comment',
|
||||
c.id,
|
||||
fileIds
|
||||
);
|
||||
}
|
||||
return c;
|
||||
});
|
||||
// スレッド投稿者へ通知(自分自身へのコメントは通知しない)
|
||||
if (thread.authorId !== actorId) {
|
||||
@ -188,7 +227,29 @@ export class BoardService {
|
||||
if (!comment) throw new NotFoundError('BoardComment', commentId);
|
||||
const thread = this.getThread(actorId, comment.threadId);
|
||||
this.requireAuthorOrAdmin(thread.projectId, actorId, comment.authorId);
|
||||
this.boardRepository.deleteComment(commentId);
|
||||
this.db.transaction(() => {
|
||||
this.attachmentService.detach('board_comment', commentId);
|
||||
this.boardRepository.deleteComment(commentId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* スレッド本文と指定コメントの添付ファイルを一括取得する。
|
||||
* メンバーシップは getThread で検証する。
|
||||
*/
|
||||
getAttachments(
|
||||
actorId: number,
|
||||
threadId: number,
|
||||
commentIds: number[]
|
||||
): { thread: AttachmentView[]; comments: AttachmentView[] } {
|
||||
const thread = this.getThread(actorId, threadId);
|
||||
return {
|
||||
thread: this.attachmentService.listViews('board_thread', thread.id),
|
||||
comments: this.attachmentService.listViewsBatch(
|
||||
'board_comment',
|
||||
commentIds
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
private requireMember(projectId: number, actorId: number): void {
|
||||
|
||||
@ -6,15 +6,21 @@ import type { Paginated } from '@/repositories/ChatRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { SseHub } from '@/lib/sse/hub';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import type { ChatMessage } from '@/lib/types';
|
||||
import type {
|
||||
AttachmentView,
|
||||
ChatMessage,
|
||||
ChatMessageWithAttachments,
|
||||
} from '@/lib/types';
|
||||
|
||||
const MENTION_RE = /@([^\s@]+@[^\s@]+\.[^\s@]+)/g;
|
||||
|
||||
/**
|
||||
* チャットの業務ロジックを担うService。
|
||||
* 権限チェック、メッセージ送信/編集/削除、メンション通知、SSE配信を行う。
|
||||
* 権限チェック、メッセージ送信/編集/削除、メンション通知、添付ファイル紐付け、SSE配信を行う。
|
||||
*/
|
||||
export class ChatService {
|
||||
constructor(
|
||||
@ -22,27 +28,56 @@ export class ChatService {
|
||||
private readonly projectMemberRepository: ProjectMemberRepository,
|
||||
private readonly userRepository: UserRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly sseHub: SseHub
|
||||
private readonly sseHub: SseHub,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly db: SqliteDatabase
|
||||
) {}
|
||||
|
||||
getHistory(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
opts: ListMessagesOptions = {}
|
||||
): Paginated<ChatMessage> {
|
||||
): Paginated<ChatMessageWithAttachments> {
|
||||
this.requireMember(projectId, actorId);
|
||||
return this.chatRepository.findMessages(projectId, opts);
|
||||
const page = this.chatRepository.findMessages(projectId, opts);
|
||||
const byTarget = this.indexAttachments(
|
||||
'chat_message',
|
||||
page.items.map((m) => m.id)
|
||||
);
|
||||
return {
|
||||
items: page.items.map((m) => this.withAttachments(m, byTarget.get(m.id))),
|
||||
total: page.total,
|
||||
};
|
||||
}
|
||||
|
||||
sendMessage(actorId: number, projectId: number, body: string): ChatMessage {
|
||||
sendMessage(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
body: string,
|
||||
fileIds?: number[]
|
||||
): ChatMessageWithAttachments {
|
||||
this.requireMember(projectId, actorId);
|
||||
if (!body.trim()) {
|
||||
throw new ValidationError('メッセージを入力してください', 'body');
|
||||
}
|
||||
const message = this.chatRepository.create({
|
||||
projectId,
|
||||
authorId: actorId,
|
||||
body,
|
||||
// メッセージ本体と添付紐付けはトランザクション内で一貫保存する
|
||||
const { message, attachments } = this.db.transaction(() => {
|
||||
const msg = this.chatRepository.create({
|
||||
projectId,
|
||||
authorId: actorId,
|
||||
body,
|
||||
});
|
||||
const atts =
|
||||
fileIds && fileIds.length > 0
|
||||
? this.attachmentService.attach(
|
||||
actorId,
|
||||
projectId,
|
||||
'chat_message',
|
||||
msg.id,
|
||||
fileIds
|
||||
)
|
||||
: [];
|
||||
return { message: msg, attachments: atts };
|
||||
});
|
||||
|
||||
// メンション検出(@email) → 通知
|
||||
@ -64,11 +99,12 @@ export class ChatService {
|
||||
}
|
||||
}
|
||||
|
||||
const messageWithAttachments = this.withAttachments(message, attachments);
|
||||
this.sseHub.broadcast(projectId, {
|
||||
type: 'chat.message.created',
|
||||
data: { projectId, message },
|
||||
data: { projectId, message: messageWithAttachments },
|
||||
});
|
||||
return message;
|
||||
return messageWithAttachments;
|
||||
}
|
||||
|
||||
editMessage(actorId: number, messageId: number, body: string): ChatMessage {
|
||||
@ -101,13 +137,38 @@ export class ChatService {
|
||||
if (message.authorId !== actorId && role !== 'admin') {
|
||||
throw new ForbiddenError('投稿者または管理者のみ削除できます');
|
||||
}
|
||||
this.chatRepository.delete(messageId);
|
||||
this.db.transaction(() => {
|
||||
this.chatRepository.delete(messageId);
|
||||
this.attachmentService.detach('chat_message', messageId);
|
||||
});
|
||||
this.sseHub.broadcast(message.projectId, {
|
||||
type: 'chat.message.deleted',
|
||||
data: { projectId: message.projectId, id: messageId },
|
||||
});
|
||||
}
|
||||
|
||||
private withAttachments(
|
||||
message: ChatMessage,
|
||||
attachments: AttachmentView[] | undefined
|
||||
): ChatMessageWithAttachments {
|
||||
return { ...message, attachments: attachments ?? [] };
|
||||
}
|
||||
|
||||
private indexAttachments(
|
||||
targetType: 'chat_message',
|
||||
targetIds: number[]
|
||||
): Map<number, AttachmentView[]> {
|
||||
const map = new Map<number, AttachmentView[]>();
|
||||
if (targetIds.length === 0) return map;
|
||||
const views = this.attachmentService.listViewsBatch(targetType, targetIds);
|
||||
for (const v of views) {
|
||||
const list = map.get(v.targetId) ?? [];
|
||||
list.push(v);
|
||||
map.set(v.targetId, list);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private requireMember(projectId: number, actorId: number): void {
|
||||
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
|
||||
throw new ForbiddenError('プロジェクトに参加していません');
|
||||
|
||||
@ -7,7 +7,7 @@ import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { SseHub } from '@/lib/sse/hub';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import type { FileAsset } from '@/lib/types';
|
||||
import type { FileAsset, FileAssetSource } from '@/lib/types';
|
||||
|
||||
const ALLOWED_MIME_PREFIXES = [
|
||||
'image/',
|
||||
@ -62,33 +62,7 @@ export class FileStorageService {
|
||||
projectId: number,
|
||||
input: UploadFileInput
|
||||
): FileAsset {
|
||||
this.requireMember(projectId, actorId);
|
||||
if (!input.data || input.data.length === 0) {
|
||||
throw new ValidationError('ファイルが空です', 'file');
|
||||
}
|
||||
if (!this.isAllowedMime(input.mimeType)) {
|
||||
throw new ValidationError('許可されていないファイル形式です', 'mimeType');
|
||||
}
|
||||
|
||||
const ext =
|
||||
this.sanitizeExt(input.originalName) ??
|
||||
EXT_BY_MIME[input.mimeType] ??
|
||||
'bin';
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const dir = path.join(this.uploadsDir, String(projectId));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const filePath = path.join(dir, filename);
|
||||
fs.writeFileSync(filePath, input.data);
|
||||
|
||||
const fileAsset = this.fileRepository.create({
|
||||
projectId,
|
||||
uploaderId: actorId,
|
||||
filename,
|
||||
originalName: this.sanitizeName(input.originalName) || 'file',
|
||||
mimeType: input.mimeType,
|
||||
size: input.data.length,
|
||||
path: filePath,
|
||||
});
|
||||
const fileAsset = this.persistFile(actorId, projectId, input, 'library');
|
||||
|
||||
const memberIds = this.projectMemberRepository
|
||||
.findByProject(projectId)
|
||||
@ -117,6 +91,58 @@ export class FileStorageService {
|
||||
return fileAsset;
|
||||
}
|
||||
|
||||
/**
|
||||
* チャット/掲示板の添付ファイル用アップロード。
|
||||
* file_shared 通知・file.uploaded SSE・file_uploaded アクティビティを行わず、
|
||||
* source='attachment' で保存する(Files一覧には出さない)。
|
||||
*/
|
||||
uploadForAttachment(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
input: UploadFileInput
|
||||
): FileAsset {
|
||||
return this.persistFile(actorId, projectId, input, 'attachment');
|
||||
}
|
||||
|
||||
/**
|
||||
* 共通のファイル保存処理。権限チェック・MIMEチェック・FS保存・file_assets登録を行う。
|
||||
*/
|
||||
private persistFile(
|
||||
actorId: number,
|
||||
projectId: number,
|
||||
input: UploadFileInput,
|
||||
source: FileAssetSource
|
||||
): FileAsset {
|
||||
this.requireMember(projectId, actorId);
|
||||
if (!input.data || input.data.length === 0) {
|
||||
throw new ValidationError('ファイルが空です', 'file');
|
||||
}
|
||||
if (!this.isAllowedMime(input.mimeType)) {
|
||||
throw new ValidationError('許可されていないファイル形式です', 'mimeType');
|
||||
}
|
||||
|
||||
const ext =
|
||||
this.sanitizeExt(input.originalName) ??
|
||||
EXT_BY_MIME[input.mimeType] ??
|
||||
'bin';
|
||||
const filename = `${crypto.randomUUID()}.${ext}`;
|
||||
const dir = path.join(this.uploadsDir, String(projectId));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const filePath = path.join(dir, filename);
|
||||
fs.writeFileSync(filePath, input.data);
|
||||
|
||||
return this.fileRepository.create({
|
||||
projectId,
|
||||
uploaderId: actorId,
|
||||
filename,
|
||||
originalName: this.sanitizeName(input.originalName) || 'file',
|
||||
mimeType: input.mimeType,
|
||||
size: input.data.length,
|
||||
path: filePath,
|
||||
source,
|
||||
});
|
||||
}
|
||||
|
||||
listFiles(actorId: number, projectId: number, page: number = 1) {
|
||||
this.requireMember(projectId, actorId);
|
||||
return this.fileRepository.findFilesByProject(projectId, page);
|
||||
|
||||
@ -2,9 +2,16 @@ import { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { SseHub } from '@/lib/sse/hub';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import type { TodoColumn, TodoItem, TodoPriority } from '@/lib/types';
|
||||
import type {
|
||||
AttachmentView,
|
||||
TodoColumn,
|
||||
TodoItem,
|
||||
TodoPriority,
|
||||
} from '@/lib/types';
|
||||
|
||||
const STANDARD_COLUMNS = ['Backlog', 'To Do', 'In Progress', 'Review', 'Done'];
|
||||
const VALID_PRIORITIES: TodoPriority[] = ['low', 'normal', 'high'];
|
||||
@ -15,7 +22,10 @@ export interface CreateItemInput {
|
||||
description?: string;
|
||||
assigneeId?: number | null;
|
||||
priority?: TodoPriority;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
tags?: string | null;
|
||||
fileIds?: number[];
|
||||
}
|
||||
|
||||
export interface UpdateItemRequest {
|
||||
@ -23,16 +33,20 @@ export interface UpdateItemRequest {
|
||||
description?: string | null;
|
||||
assigneeId?: number | null;
|
||||
priority?: TodoPriority;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
tags?: string | null;
|
||||
columnId?: number;
|
||||
orderIndex?: number;
|
||||
milestoneId?: number | null;
|
||||
fileIds?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ToDo/Kanbanの業務ロジックを担うService。
|
||||
* 標準カラム初期生成、権限チェック、タスクCRUD/移動/完了、
|
||||
* 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、SSE配信を行う。
|
||||
* 担当者割当時の通知(todo_assigned)とアクティビティログ(todo_*)、
|
||||
* 添付ファイル紐付け、SSE配信を行う。
|
||||
*/
|
||||
export class TodoService {
|
||||
constructor(
|
||||
@ -40,7 +54,9 @@ export class TodoService {
|
||||
private readonly projectMemberRepository: ProjectMemberRepository,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly activityLogService: ActivityLogService,
|
||||
private readonly sseHub: SseHub
|
||||
private readonly sseHub: SseHub,
|
||||
private readonly attachmentService: AttachmentService,
|
||||
private readonly db: SqliteDatabase
|
||||
) {}
|
||||
|
||||
getColumns(actorId: number, projectId: number): TodoColumn[] {
|
||||
@ -119,20 +135,33 @@ export class TodoService {
|
||||
if (input.priority && !VALID_PRIORITIES.includes(input.priority)) {
|
||||
throw new ValidationError('無効な優先度です', 'priority');
|
||||
}
|
||||
const orderIndex =
|
||||
input.columnId !== undefined
|
||||
? this.todoRepository.maxItemOrderIndex(input.columnId) + 1
|
||||
: 0;
|
||||
const item = this.todoRepository.createItem({
|
||||
projectId,
|
||||
columnId: input.columnId,
|
||||
title: input.title,
|
||||
creatorId: actorId,
|
||||
description: input.description ?? null,
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
priority: input.priority,
|
||||
dueDate: input.dueDate ?? null,
|
||||
orderIndex,
|
||||
// タスク本体と添付紐付けはトランザクション内で一貫保存する
|
||||
const item = this.db.transaction(() => {
|
||||
const orderIndex =
|
||||
this.todoRepository.maxItemOrderIndex(input.columnId) + 1;
|
||||
const created = this.todoRepository.createItem({
|
||||
projectId,
|
||||
columnId: input.columnId,
|
||||
title: input.title,
|
||||
creatorId: actorId,
|
||||
description: input.description ?? null,
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
priority: input.priority,
|
||||
startDate: input.startDate ?? null,
|
||||
dueDate: input.dueDate ?? null,
|
||||
tags: input.tags ?? null,
|
||||
orderIndex,
|
||||
});
|
||||
if (input.fileIds && input.fileIds.length > 0) {
|
||||
this.attachmentService.attach(
|
||||
actorId,
|
||||
projectId,
|
||||
'todo_item',
|
||||
created.id,
|
||||
input.fileIds
|
||||
);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
|
||||
if (input.assigneeId && input.assigneeId !== actorId) {
|
||||
@ -155,7 +184,19 @@ export class TodoService {
|
||||
throw new ValidationError('無効な優先度です', 'priority');
|
||||
}
|
||||
const previousAssignee = item.assigneeId;
|
||||
const updated = this.todoRepository.updateItem(itemId, input);
|
||||
const updated = this.db.transaction(() => {
|
||||
const u = this.todoRepository.updateItem(itemId, input);
|
||||
if (u && input.fileIds && input.fileIds.length > 0) {
|
||||
this.attachmentService.attach(
|
||||
actorId,
|
||||
item.projectId,
|
||||
'todo_item',
|
||||
itemId,
|
||||
input.fileIds
|
||||
);
|
||||
}
|
||||
return u;
|
||||
});
|
||||
if (!updated) throw new NotFoundError('TodoItem', itemId);
|
||||
|
||||
if (
|
||||
@ -220,10 +261,29 @@ export class TodoService {
|
||||
if (item.creatorId !== actorId && role !== 'admin') {
|
||||
throw new ForbiddenError('作成者または管理者のみ削除できます');
|
||||
}
|
||||
this.todoRepository.deleteItem(itemId);
|
||||
this.db.transaction(() => {
|
||||
this.attachmentService.detach('todo_item', itemId);
|
||||
this.todoRepository.deleteItem(itemId);
|
||||
});
|
||||
this.broadcastTodo(item.projectId);
|
||||
}
|
||||
|
||||
/** 単一のToDoアイテムを取得(メンバーシップチェック付き)。 */
|
||||
getItem(actorId: number, itemId: number): TodoItem {
|
||||
const item = this.todoRepository.findItemById(itemId);
|
||||
if (!item) throw new NotFoundError('TodoItem', itemId);
|
||||
this.requireMember(item.projectId, actorId);
|
||||
return item;
|
||||
}
|
||||
|
||||
/** ToDoに紐付く添付ファイル一覧を取得(メンバーシップチェック付き)。 */
|
||||
getItemAttachments(actorId: number, itemId: number): AttachmentView[] {
|
||||
const item = this.todoRepository.findItemById(itemId);
|
||||
if (!item) throw new NotFoundError('TodoItem', itemId);
|
||||
this.requireMember(item.projectId, actorId);
|
||||
return this.attachmentService.listViews('todo_item', itemId);
|
||||
}
|
||||
|
||||
private requireMember(projectId: number, actorId: number): void {
|
||||
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
|
||||
throw new ForbiddenError('プロジェクトに参加していません');
|
||||
|
||||
@ -77,4 +77,44 @@ test.describe('board', () => {
|
||||
page.getByRole('link', { name: new RegExp(title) })
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('thread and comment can carry file attachments', async ({ page }) => {
|
||||
const projectId = await setupOwner(page);
|
||||
|
||||
// 添付ファイル(1x1 PNG)をアップロード
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
const upRes = await page.request.post(
|
||||
`/api/projects/${projectId}/attachments`,
|
||||
{
|
||||
multipart: {
|
||||
file: { name: 'pic.png', mimeType: 'image/png', buffer: png },
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(upRes.ok()).toBeTruthy();
|
||||
const { file } = (await upRes.json()) as { file: { id: number } };
|
||||
|
||||
// ファイル付きスレッド作成
|
||||
const title = unique('AttachThread');
|
||||
const res = await page.request.post(
|
||||
`/api/projects/${projectId}/board/threads`,
|
||||
{ data: { title, bodyMd: 'body', fileIds: [file.id] } }
|
||||
);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
const { thread } = (await res.json()) as { thread: { id: number } };
|
||||
|
||||
// ファイル付きコメント
|
||||
const cRes = await page.request.post(
|
||||
`/api/projects/${projectId}/board/threads/${thread.id}/comments`,
|
||||
{ data: { bodyMd: 'with file', fileIds: [file.id] } }
|
||||
);
|
||||
expect(cRes.ok()).toBeTruthy();
|
||||
|
||||
// 詳細ページでスレッド・コメントの添付が表示される
|
||||
await page.goto(`/projects/${projectId}/board/${thread.id}`);
|
||||
await expect(page.getByTestId('attachment-list').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,6 +19,24 @@ async function setupOwner(page: import('@playwright/test').Page) {
|
||||
return Number(page.url().match(/\/projects\/(\d+)/)![1]);
|
||||
}
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function addDaysISO(iso: string, n: number): string {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
const dt = new Date(y, m - 1, d);
|
||||
dt.setDate(dt.getDate() + n);
|
||||
return isoDate(dt);
|
||||
}
|
||||
|
||||
function urlDate(url: string): string | null {
|
||||
return new URL(url).searchParams.get('date');
|
||||
}
|
||||
|
||||
test.describe('calendar & milestones', () => {
|
||||
test('milestone progress reflects related todo completion; calendar aggregates', async ({
|
||||
page,
|
||||
@ -91,9 +109,104 @@ test.describe('calendar & milestones', () => {
|
||||
);
|
||||
expect(evRes.ok()).toBeTruthy();
|
||||
|
||||
// カレンダー画面にイベント+マイルストーン+ToDoが集約表示される
|
||||
// カレンダー画面(月表示)にイベント+マイルストーン+ToDoがグリッド表示される
|
||||
await page.goto(`/projects/${projectId}/calendar`);
|
||||
await expect(page.getByTestId('calendar-view-month')).toBeVisible();
|
||||
await expect(page.getByText(/マイルストーン:/)).toBeVisible();
|
||||
await expect(page.getByText(/ToDo:/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('switches between month/week/day views and opens date detail', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectId = await setupOwner(page);
|
||||
const todayKey = isoDate(new Date());
|
||||
const eventTitle = unique('Evt');
|
||||
|
||||
// 今日のイベントを作成(デフォルトの月表示で今日のセルに表示される)
|
||||
const evRes = await page.request.post(
|
||||
`/api/projects/${projectId}/calendar/events`,
|
||||
{
|
||||
data: {
|
||||
title: eventTitle,
|
||||
type: 'custom',
|
||||
startAt: `${todayKey}T10:00:00`,
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(evRes.ok()).toBeTruthy();
|
||||
|
||||
await page.goto(`/projects/${projectId}/calendar`);
|
||||
|
||||
// 月表示: 今日のセルにイベントチップ
|
||||
await expect(page.getByTestId('calendar-view-month')).toBeVisible();
|
||||
await expect(page.getByTestId(`calendar-day-${todayKey}`)).toBeVisible();
|
||||
await expect(page.getByText(eventTitle)).toBeVisible();
|
||||
|
||||
// 週表示へ切替
|
||||
await page.getByTestId('calendar-view-week').click();
|
||||
await expect(
|
||||
page.getByTestId(`calendar-week-cell-${todayKey}`)
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(eventTitle)).toBeVisible();
|
||||
|
||||
// 日表示(時間グリッド)へ切替: 10時行にイベント
|
||||
await page.getByTestId('calendar-view-day').click();
|
||||
await expect(page.getByTestId('calendar-hour-10')).toBeVisible();
|
||||
await expect(page.getByText(eventTitle)).toBeVisible();
|
||||
|
||||
// 月表示へ戻す
|
||||
await page.getByTestId('calendar-view-month').click();
|
||||
await expect(page.getByTestId('calendar-view-month')).toBeVisible();
|
||||
|
||||
// 日付をクリックして詳細ダイアログを開く
|
||||
await page.getByLabel(`${todayKey} の詳細を開く`).click();
|
||||
const dialog = page.getByTestId('calendar-detail-dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText(eventTitle)).toBeVisible();
|
||||
|
||||
// ダイアログを閉じる
|
||||
await page.getByTestId('calendar-detail-close').click();
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
|
||||
test('prev/next/today navigation moves the anchor by the view unit', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectId = await setupOwner(page);
|
||||
await page.goto(`/projects/${projectId}/calendar`);
|
||||
|
||||
const title = page.getByTestId('calendar-title');
|
||||
await expect(title).toContainText('月');
|
||||
|
||||
// 月表示: next でタイトル変化、prev で元に戻る
|
||||
const before = (await title.textContent()) ?? '';
|
||||
await page.getByTestId('calendar-next').click();
|
||||
await expect(title).not.toHaveText(before);
|
||||
await page.getByTestId('calendar-prev').click();
|
||||
await expect(title).toHaveText(before);
|
||||
|
||||
// 今日ボタンで今日に戻る
|
||||
await page.getByTestId('calendar-next').click();
|
||||
await page.getByTestId('calendar-today').click();
|
||||
await expect(title).toHaveText(before);
|
||||
|
||||
// 週表示: next で7日進む
|
||||
await page.getByTestId('calendar-view-week').click();
|
||||
const weekStart = urlDate(page.url());
|
||||
expect(weekStart).not.toBeNull();
|
||||
await page.getByTestId('calendar-next').click();
|
||||
expect(urlDate(page.url())).toBe(addDaysISO(weekStart!, 7));
|
||||
await page.getByTestId('calendar-prev').click();
|
||||
expect(urlDate(page.url())).toBe(weekStart);
|
||||
|
||||
// 日表示: next で1日進む
|
||||
await page.getByTestId('calendar-view-day').click();
|
||||
const dayStart = urlDate(page.url());
|
||||
expect(dayStart).not.toBeNull();
|
||||
await page.getByTestId('calendar-next').click();
|
||||
expect(urlDate(page.url())).toBe(addDaysISO(dayStart!, 1));
|
||||
await page.getByTestId('calendar-prev').click();
|
||||
expect(urlDate(page.url())).toBe(dayStart);
|
||||
});
|
||||
});
|
||||
|
||||
@ -65,4 +65,50 @@ test.describe('chat (SSE realtime)', () => {
|
||||
await ownerContext.close();
|
||||
await memberContext.close();
|
||||
});
|
||||
|
||||
test('a message can carry file attachments rendered in the chat', async ({
|
||||
page,
|
||||
}) => {
|
||||
const ownerEmail = unique('owner') + '@example.com';
|
||||
await registerAndLogin(page, ownerEmail, 'Owner');
|
||||
await page.getByLabel('プロジェクト名').fill(unique('Proj'));
|
||||
await page.getByRole('button', { name: '新規プロジェクト' }).click();
|
||||
await expect(page).toHaveURL(/\/projects\/\d+$/);
|
||||
const projectId = Number(page.url().match(/\/projects\/(\d+)/)![1]);
|
||||
|
||||
// 添付ファイル(1x1 PNG)をアップロード
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
const upRes = await page.request.post(
|
||||
`/api/projects/${projectId}/attachments`,
|
||||
{
|
||||
multipart: {
|
||||
file: { name: 'pic.png', mimeType: 'image/png', buffer: png },
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(upRes.ok()).toBeTruthy();
|
||||
const { file } = (await upRes.json()) as { file: { id: number } };
|
||||
|
||||
// ファイル付きメッセージ送信
|
||||
const text = unique('with-attach');
|
||||
const sendRes = await page.request.post(
|
||||
`/api/projects/${projectId}/chat/messages`,
|
||||
{ data: { body: text, fileIds: [file.id] } }
|
||||
);
|
||||
expect(sendRes.ok()).toBeTruthy();
|
||||
const { message } = (await sendRes.json()) as {
|
||||
message: { attachments: { fileId: number }[] };
|
||||
};
|
||||
expect(message.attachments).toHaveLength(1);
|
||||
|
||||
// チャット画面に添付画像が表示される
|
||||
await page.goto(`/projects/${projectId}/chat`);
|
||||
await expect(page.getByTestId('chat-form')).toBeVisible();
|
||||
await expect(page.getByTestId('attachment-list')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -76,4 +76,99 @@ test.describe('todo / kanban', () => {
|
||||
page.getByTestId(`kanban-column-${done.id}`).getByText(title)
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('edit dialog shows and persists metadata + file attachments', async ({
|
||||
page,
|
||||
}) => {
|
||||
const projectId = await setupOwner(page);
|
||||
|
||||
const colsRes = await page.request.get(
|
||||
`/api/projects/${projectId}/todos/columns`
|
||||
);
|
||||
const cols = (
|
||||
(await colsRes.json()) as { columns: { id: number; name: string }[] }
|
||||
).columns;
|
||||
const backlog = cols.find((c) => c.name === 'Backlog')!;
|
||||
|
||||
// オーナー(担当者候補)のuser idを取得
|
||||
const members = (
|
||||
(await (
|
||||
await page.request.get(`/api/projects/${projectId}/members`)
|
||||
).json()) as { members: { user: { id: number; name: string } }[] }
|
||||
).members;
|
||||
const ownerId = members[0].user.id;
|
||||
|
||||
// タスク作成(API)
|
||||
const title = unique('Task');
|
||||
const createRes = await page.request.post(
|
||||
`/api/projects/${projectId}/todos/items`,
|
||||
{ data: { title, columnId: backlog.id } }
|
||||
);
|
||||
const { item } = (await createRes.json()) as { item: { id: number } };
|
||||
|
||||
// 添付ファイル(1x1 PNG)をアップロード
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=',
|
||||
'base64'
|
||||
);
|
||||
const upRes = await page.request.post(
|
||||
`/api/projects/${projectId}/attachments`,
|
||||
{
|
||||
multipart: {
|
||||
file: { name: 'pic.png', mimeType: 'image/png', buffer: png },
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(upRes.ok()).toBeTruthy();
|
||||
const { file } = (await upRes.json()) as { file: { id: number } };
|
||||
|
||||
// ダイアログを開いてメタデータを編集
|
||||
await page.goto(`/projects/${projectId}/todos`);
|
||||
await page.getByTestId(`todo-card-${item.id}`).click();
|
||||
await expect(page.getByTestId('todo-dialog')).toBeVisible();
|
||||
await page.getByTestId('todo-description').fill('detailed desc');
|
||||
await page.getByTestId('todo-tags').fill('frontend, urgent');
|
||||
await page.getByTestId('todo-start-date').fill('2026-07-01');
|
||||
await page.getByTestId('todo-due-date').fill('2026-07-31');
|
||||
await page.getByTestId('todo-assignee').selectOption(String(ownerId));
|
||||
await page.getByTestId('todo-save').click();
|
||||
await expect(page.getByTestId('todo-dialog')).toBeHidden();
|
||||
|
||||
// カードにタグが表示される
|
||||
await expect(page.getByText('frontend')).toBeVisible();
|
||||
|
||||
// APIでファイルを添付付け
|
||||
const attachRes = await page.request.patch(
|
||||
`/api/projects/${projectId}/todos/items/${item.id}`,
|
||||
{ data: { fileIds: [file.id] } }
|
||||
);
|
||||
expect(attachRes.ok()).toBeTruthy();
|
||||
|
||||
// GET でアイテム+添付が取得できる
|
||||
const getRes = await page.request.get(
|
||||
`/api/projects/${projectId}/todos/items/${item.id}`
|
||||
);
|
||||
const body = (await getRes.json()) as {
|
||||
item: {
|
||||
description: string;
|
||||
tags: string;
|
||||
startDate: string;
|
||||
dueDate: string;
|
||||
assigneeId: number;
|
||||
};
|
||||
attachments: { fileId: number }[];
|
||||
};
|
||||
expect(body.item.description).toBe('detailed desc');
|
||||
expect(body.item.tags).toBe('frontend, urgent');
|
||||
expect(body.item.startDate).toBe('2026-07-01');
|
||||
expect(body.item.dueDate).toBe('2026-07-31');
|
||||
expect(body.item.assigneeId).toBe(ownerId);
|
||||
expect(body.attachments).toHaveLength(1);
|
||||
|
||||
// ダイアログ再オープンで添付画像が表示される
|
||||
await page.reload();
|
||||
await page.getByTestId(`todo-card-${item.id}`).click();
|
||||
await expect(page.getByTestId('todo-dialog')).toBeVisible();
|
||||
await expect(page.getByTestId('attachment-list')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,8 +5,11 @@ import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { ChatRepository } from '@/repositories/ChatRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { ChatService } from '@/services/ChatService';
|
||||
import { SseHub, type SseClient } from '@/lib/sse/hub';
|
||||
|
||||
@ -37,7 +40,13 @@ describe('chat → SSE broadcast (integration)', () => {
|
||||
members,
|
||||
users,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
hub
|
||||
hub,
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
members
|
||||
),
|
||||
db
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
175
tests/unit/lib/calendar/grid.test.ts
Normal file
175
tests/unit/lib/calendar/grid.test.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
toISODate,
|
||||
parseISODate,
|
||||
addDays,
|
||||
addMonths,
|
||||
startOfWeek,
|
||||
getMonthGrid,
|
||||
getWeekDays,
|
||||
getDayHours,
|
||||
eventDateKey,
|
||||
eventHour,
|
||||
formatTime,
|
||||
eventsOnDay,
|
||||
rangeForView,
|
||||
stepAnchor,
|
||||
} from '@/lib/calendar/grid';
|
||||
|
||||
describe('calendar/grid', () => {
|
||||
describe('toISODate / parseISODate', () => {
|
||||
it('toISODate formats a local date as YYYY-MM-DD', () => {
|
||||
expect(toISODate(new Date(2026, 5, 15))).toBe('2026-06-15');
|
||||
expect(toISODate(new Date(2026, 0, 9))).toBe('2026-01-09');
|
||||
});
|
||||
|
||||
it('parseISODate round-trips through toISODate', () => {
|
||||
const d = parseISODate('2026-06-15');
|
||||
expect(toISODate(d)).toBe('2026-06-15');
|
||||
});
|
||||
});
|
||||
|
||||
describe('startOfWeek', () => {
|
||||
it('returns the Sunday of the given date week', () => {
|
||||
// 2026-06-17 is a Wednesday
|
||||
expect(toISODate(startOfWeek(new Date(2026, 5, 17)))).toBe('2026-06-14');
|
||||
});
|
||||
|
||||
it('returns the same date when already on Sunday', () => {
|
||||
expect(toISODate(startOfWeek(new Date(2026, 5, 14)))).toBe('2026-06-14');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMonthGrid', () => {
|
||||
it('produces full Sun-Sat weeks covering the month', () => {
|
||||
// June 2026: 1st is Monday, 30th is Tuesday
|
||||
const weeks = getMonthGrid(2026, 5);
|
||||
for (const week of weeks) {
|
||||
expect(week).toHaveLength(7);
|
||||
expect(week[0].getDay()).toBe(0); // Sunday
|
||||
expect(week[6].getDay()).toBe(6); // Saturday
|
||||
}
|
||||
// First cell is the Sunday on/before June 1 (May 31)
|
||||
expect(toISODate(weeks[0][0])).toBe('2026-05-31');
|
||||
// Last cell is the Saturday on/after June 30 (July 4)
|
||||
expect(toISODate(weeks[weeks.length - 1][6])).toBe('2026-07-04');
|
||||
});
|
||||
|
||||
it('starts on the 1st when the month begins on Sunday', () => {
|
||||
// February 2026: 1st is a Sunday
|
||||
const weeks = getMonthGrid(2026, 1);
|
||||
expect(toISODate(weeks[0][0])).toBe('2026-02-01');
|
||||
});
|
||||
|
||||
it('produces 4 to 6 weeks', () => {
|
||||
const weeks = getMonthGrid(2026, 5);
|
||||
expect(weeks.length).toBeGreaterThanOrEqual(4);
|
||||
expect(weeks.length).toBeLessThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getWeekDays / getDayHours', () => {
|
||||
it('returns 7 consecutive days Sunday-Saturday', () => {
|
||||
const days = getWeekDays(new Date(2026, 5, 17));
|
||||
expect(days).toHaveLength(7);
|
||||
expect(toISODate(days[0])).toBe('2026-06-14');
|
||||
expect(toISODate(days[6])).toBe('2026-06-20');
|
||||
});
|
||||
|
||||
it('returns hours 0..23', () => {
|
||||
expect(getDayHours()).toEqual(Array.from({ length: 24 }, (_, i) => i));
|
||||
});
|
||||
});
|
||||
|
||||
describe('addDays / addMonths', () => {
|
||||
it('addDays moves the date', () => {
|
||||
expect(toISODate(addDays(new Date(2026, 5, 15), 10))).toBe('2026-06-25');
|
||||
expect(toISODate(addDays(new Date(2026, 5, 15), -15))).toBe('2026-05-31');
|
||||
});
|
||||
|
||||
it('addMonths rolls over the year', () => {
|
||||
expect(toISODate(addMonths(new Date(2026, 11, 15), 1))).toBe(
|
||||
'2027-01-15'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventDateKey / eventHour / formatTime', () => {
|
||||
it('eventDateKey takes the first 10 chars', () => {
|
||||
expect(eventDateKey('2026-06-15T10:30:00')).toBe('2026-06-15');
|
||||
expect(eventDateKey('2026-06-15')).toBe('2026-06-15');
|
||||
});
|
||||
|
||||
it('eventHour returns the hour for timed events and null otherwise', () => {
|
||||
expect(eventHour('2026-06-15T10:30:00')).toBe(10);
|
||||
expect(eventHour('2026-06-15')).toBeNull();
|
||||
expect(eventHour('2026-06-15T09:00:00')).toBe(9);
|
||||
});
|
||||
|
||||
it('formatTime renders HH:mm or 終日', () => {
|
||||
expect(formatTime('2026-06-15T10:30:00')).toBe('10:30');
|
||||
expect(formatTime('2026-06-15')).toBe('終日');
|
||||
});
|
||||
});
|
||||
|
||||
describe('eventsOnDay', () => {
|
||||
const events = [
|
||||
{ startAt: '2026-06-15T10:00:00', title: 'A' },
|
||||
{ startAt: '2026-06-15', title: 'B' },
|
||||
{ startAt: '2026-06-16T09:00:00', title: 'C' },
|
||||
];
|
||||
|
||||
it('filters events whose start date matches the day key', () => {
|
||||
const result = eventsOnDay(events, '2026-06-15');
|
||||
expect(result.map((e) => e.title)).toEqual(['A', 'B']);
|
||||
});
|
||||
|
||||
it('returns empty when nothing matches', () => {
|
||||
expect(eventsOnDay(events, '2026-07-01')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rangeForView', () => {
|
||||
it('day range covers a single day ending at T23:59:59', () => {
|
||||
const r = rangeForView('day', new Date(2026, 5, 15));
|
||||
expect(r).toEqual({ from: '2026-06-15', to: '2026-06-15T23:59:59' });
|
||||
});
|
||||
|
||||
it('week range covers Sun-Sat ending at T23:59:59', () => {
|
||||
const r = rangeForView('week', new Date(2026, 5, 17));
|
||||
expect(r).toEqual({ from: '2026-06-14', to: '2026-06-20T23:59:59' });
|
||||
});
|
||||
|
||||
it('month range covers the whole grid including overflow days', () => {
|
||||
const r = rangeForView('month', new Date(2026, 5, 15));
|
||||
expect(r.from).toBe('2026-05-31');
|
||||
expect(r.to).toBe('2026-07-04T23:59:59');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stepAnchor', () => {
|
||||
it('steps months for month view', () => {
|
||||
expect(toISODate(stepAnchor('month', new Date(2026, 5, 15), 1))).toBe(
|
||||
'2026-07-15'
|
||||
);
|
||||
expect(toISODate(stepAnchor('month', new Date(2026, 5, 15), -1))).toBe(
|
||||
'2026-05-15'
|
||||
);
|
||||
});
|
||||
|
||||
it('steps weeks for week view', () => {
|
||||
expect(toISODate(stepAnchor('week', new Date(2026, 5, 17), 1))).toBe(
|
||||
'2026-06-24'
|
||||
);
|
||||
});
|
||||
|
||||
it('steps days for day view', () => {
|
||||
expect(toISODate(stepAnchor('day', new Date(2026, 5, 15), 1))).toBe(
|
||||
'2026-06-16'
|
||||
);
|
||||
expect(toISODate(stepAnchor('day', new Date(2026, 5, 15), -1))).toBe(
|
||||
'2026-06-14'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -158,6 +158,7 @@ describe('Migrator', () => {
|
||||
|
||||
const expectedTables = [
|
||||
'activity_logs',
|
||||
'attachments',
|
||||
'board_comments',
|
||||
'board_threads',
|
||||
'calendar_events',
|
||||
@ -180,6 +181,8 @@ describe('Migrator', () => {
|
||||
}
|
||||
expect(migrator.getAppliedMigrations().map((m) => m.filename)).toEqual([
|
||||
'001_initial.sql',
|
||||
'002_attachments.sql',
|
||||
'003_todo_tags.sql',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -44,7 +44,10 @@ describe('SseHub', () => {
|
||||
|
||||
hub.broadcast(1, {
|
||||
type: 'chat.message.created',
|
||||
data: { projectId: 1, message: { id: 1 } as never },
|
||||
data: {
|
||||
projectId: 1,
|
||||
message: { id: 1, attachments: [] } as never,
|
||||
},
|
||||
});
|
||||
|
||||
expect(a1.received).toHaveLength(1);
|
||||
|
||||
152
tests/unit/repositories/AttachmentRepository.test.ts
Normal file
152
tests/unit/repositories/AttachmentRepository.test.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createMigratedTestDb } from '@/tests/helpers/db';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
|
||||
|
||||
describe('AttachmentRepository', () => {
|
||||
let db: SqliteDatabase;
|
||||
let repo: AttachmentRepository;
|
||||
let fileRepo: FileRepository;
|
||||
let projectId: number;
|
||||
let userId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
repo = new AttachmentRepository(db);
|
||||
fileRepo = new FileRepository(db);
|
||||
userId = new UserRepository(db).create({
|
||||
name: 'U',
|
||||
email: 'u@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
projectId = new ProjectRepository(db).create({
|
||||
name: 'P',
|
||||
ownerId: userId,
|
||||
}).id;
|
||||
new ProjectMemberRepository(db).add(projectId, userId, 'admin');
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
function createFile(name: string) {
|
||||
return fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: userId,
|
||||
filename: `${name}.bin`,
|
||||
originalName: name,
|
||||
mimeType: 'image/png',
|
||||
size: 10,
|
||||
path: `/tmp/${name}.bin`,
|
||||
source: 'attachment',
|
||||
});
|
||||
}
|
||||
|
||||
it('creates and finds an attachment by target', () => {
|
||||
const file = createFile('a');
|
||||
const att = repo.create({
|
||||
projectId,
|
||||
fileId: file.id,
|
||||
targetType: 'chat_message',
|
||||
targetId: 1,
|
||||
});
|
||||
expect(att.id).toBeGreaterThan(0);
|
||||
expect(att.targetType).toBe('chat_message');
|
||||
const list = repo.findByTarget('chat_message', 1);
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].fileId).toBe(file.id);
|
||||
});
|
||||
|
||||
it('findViewsByTargets joins file_assets and returns view fields', () => {
|
||||
const f1 = createFile('one');
|
||||
const f2 = createFile('two');
|
||||
repo.create({
|
||||
projectId,
|
||||
fileId: f1.id,
|
||||
targetType: 'board_comment',
|
||||
targetId: 10,
|
||||
});
|
||||
repo.create({
|
||||
projectId,
|
||||
fileId: f2.id,
|
||||
targetType: 'board_comment',
|
||||
targetId: 11,
|
||||
});
|
||||
const views = repo.findViewsByTargets('board_comment', [10, 11]);
|
||||
expect(views).toHaveLength(2);
|
||||
const v1 = views.find((v) => v.targetId === 10);
|
||||
expect(v1?.originalName).toBe('one');
|
||||
expect(v1?.mimeType).toBe('image/png');
|
||||
expect(v1?.fileId).toBe(f1.id);
|
||||
});
|
||||
|
||||
it('returns empty array for empty target id list', () => {
|
||||
expect(repo.findViewsByTargets('chat_message', [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('isolates by target_type (same target_id, different type)', () => {
|
||||
const file = createFile('a');
|
||||
repo.create({
|
||||
projectId,
|
||||
fileId: file.id,
|
||||
targetType: 'chat_message',
|
||||
targetId: 5,
|
||||
});
|
||||
repo.create({
|
||||
projectId,
|
||||
fileId: file.id,
|
||||
targetType: 'board_thread',
|
||||
targetId: 5,
|
||||
});
|
||||
expect(repo.findByTarget('chat_message', 5)).toHaveLength(1);
|
||||
expect(repo.findByTarget('board_thread', 5)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('excludes soft-deleted attachments and files', () => {
|
||||
const f1 = createFile('keep');
|
||||
const f2 = createFile('drop');
|
||||
repo.create({
|
||||
projectId,
|
||||
fileId: f1.id,
|
||||
targetType: 'chat_message',
|
||||
targetId: 1,
|
||||
});
|
||||
repo.create({
|
||||
projectId,
|
||||
fileId: f2.id,
|
||||
targetType: 'chat_message',
|
||||
targetId: 1,
|
||||
});
|
||||
// ファイルを論理削除するとビューから消える
|
||||
fileRepo.delete(f2.id);
|
||||
const views = repo.findViewsByTargets('chat_message', [1]);
|
||||
expect(views).toHaveLength(1);
|
||||
expect(views[0].originalName).toBe('keep');
|
||||
});
|
||||
|
||||
it('deleteByTarget soft-deletes all attachments for the target', () => {
|
||||
createFile('a');
|
||||
createFile('b');
|
||||
const f1 = createFile('one');
|
||||
const f2 = createFile('two');
|
||||
repo.create({
|
||||
projectId,
|
||||
fileId: f1.id,
|
||||
targetType: 'board_thread',
|
||||
targetId: 7,
|
||||
});
|
||||
repo.create({
|
||||
projectId,
|
||||
fileId: f2.id,
|
||||
targetType: 'board_thread',
|
||||
targetId: 7,
|
||||
});
|
||||
expect(repo.deleteByTarget('board_thread', 7)).toBe(true);
|
||||
expect(repo.findByTarget('board_thread', 7)).toHaveLength(0);
|
||||
// 既に削除済みなら false
|
||||
expect(repo.deleteByTarget('board_thread', 7)).toBe(false);
|
||||
});
|
||||
});
|
||||
@ -85,4 +85,37 @@ describe('FileRepository', () => {
|
||||
expect(repo.findFilesByProject(projectId, 1, 2).items).toHaveLength(2);
|
||||
expect(repo.findFilesByProject(projectId, 1, 2).total).toBe(5);
|
||||
});
|
||||
|
||||
it('excludes attachment-source files from the library list but findFileById still returns them', () => {
|
||||
createFile('library-file');
|
||||
repo.create({
|
||||
projectId,
|
||||
uploaderId: userId,
|
||||
filename: 'att.bin',
|
||||
originalName: 'attachment-file',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/att.bin',
|
||||
source: 'attachment',
|
||||
});
|
||||
const list = repo.findFilesByProject(projectId);
|
||||
expect(list.total).toBe(1);
|
||||
expect(list.items[0].originalName).toBe('library-file');
|
||||
// 添付ファイルも findFileById では取得可能(ダウンロード用)
|
||||
const att = repo.findFilesByProject(projectId, 1, 100).items;
|
||||
expect(att).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('defaults source to library when not specified', () => {
|
||||
const f = repo.create({
|
||||
projectId,
|
||||
uploaderId: userId,
|
||||
filename: 'd.bin',
|
||||
originalName: 'd',
|
||||
mimeType: 'text/plain',
|
||||
size: 1,
|
||||
path: '/tmp/d.bin',
|
||||
});
|
||||
expect(f.source).toBe('library');
|
||||
});
|
||||
});
|
||||
|
||||
@ -114,6 +114,49 @@ describe('TodoRepository', () => {
|
||||
expect(updated?.completedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('persists startDate and tags on create', () => {
|
||||
const item = repo.createItem({
|
||||
projectId,
|
||||
columnId,
|
||||
title: 't',
|
||||
creatorId: userId,
|
||||
orderIndex: 0,
|
||||
startDate: '2026-07-01',
|
||||
tags: 'frontend,urgent',
|
||||
});
|
||||
const found = repo.findItemById(item.id);
|
||||
expect(found?.startDate).toBe('2026-07-01');
|
||||
expect(found?.tags).toBe('frontend,urgent');
|
||||
});
|
||||
|
||||
it('updates startDate and tags', () => {
|
||||
const item = repo.createItem({
|
||||
projectId,
|
||||
columnId,
|
||||
title: 't',
|
||||
creatorId: userId,
|
||||
orderIndex: 0,
|
||||
});
|
||||
const updated = repo.updateItem(item.id, {
|
||||
startDate: '2026-08-01',
|
||||
tags: 'backend',
|
||||
});
|
||||
expect(updated?.startDate).toBe('2026-08-01');
|
||||
expect(updated?.tags).toBe('backend');
|
||||
});
|
||||
|
||||
it('defaults startDate and tags to null when omitted', () => {
|
||||
const item = repo.createItem({
|
||||
projectId,
|
||||
columnId,
|
||||
title: 't',
|
||||
creatorId: userId,
|
||||
orderIndex: 0,
|
||||
});
|
||||
expect(item.startDate).toBeNull();
|
||||
expect(item.tags).toBeNull();
|
||||
});
|
||||
|
||||
it('isolates items by project', () => {
|
||||
const p2 = new ProjectRepository(db).create({
|
||||
name: 'P2',
|
||||
|
||||
168
tests/unit/services/AttachmentService.test.ts
Normal file
168
tests/unit/services/AttachmentService.test.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createMigratedTestDb } from '@/tests/helpers/db';
|
||||
import type { SqliteDatabase } from '@/lib/db/sqlite';
|
||||
import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
|
||||
import { FileStorageService } from '@/services/FileStorageService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||
import { SseHub } from '@/lib/sse/hub';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
|
||||
describe('AttachmentService', () => {
|
||||
let db: SqliteDatabase;
|
||||
let service: AttachmentService;
|
||||
let fileStorage: FileStorageService;
|
||||
let attachmentRepo: AttachmentRepository;
|
||||
let uploadsDir: string;
|
||||
let projectId: number;
|
||||
let authorId: number;
|
||||
let memberId: number;
|
||||
let outsiderId: number;
|
||||
|
||||
beforeEach(() => {
|
||||
db = createMigratedTestDb();
|
||||
uploadsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uploads-'));
|
||||
const users = new UserRepository(db);
|
||||
authorId = users.create({
|
||||
name: 'A',
|
||||
email: 'a@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
memberId = users.create({
|
||||
name: 'M',
|
||||
email: 'm@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
outsiderId = users.create({
|
||||
name: 'O',
|
||||
email: 'o@example.com',
|
||||
passwordHash: 'h',
|
||||
}).id;
|
||||
projectId = new ProjectRepository(db).create({
|
||||
name: 'P',
|
||||
ownerId: authorId,
|
||||
}).id;
|
||||
const members = new ProjectMemberRepository(db);
|
||||
members.add(projectId, authorId, 'admin');
|
||||
members.add(projectId, memberId, 'member');
|
||||
attachmentRepo = new AttachmentRepository(db);
|
||||
fileStorage = new FileStorageService(
|
||||
new FileRepository(db),
|
||||
members,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
new SseHub(),
|
||||
uploadsDir
|
||||
);
|
||||
service = new AttachmentService(
|
||||
attachmentRepo,
|
||||
new FileRepository(db),
|
||||
members
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
fs.rmSync(uploadsDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function uploadAttachment(actorId: number) {
|
||||
return fileStorage.uploadForAttachment(actorId, projectId, {
|
||||
originalName: 'pic.png',
|
||||
mimeType: 'image/png',
|
||||
data: Buffer.from('img'),
|
||||
});
|
||||
}
|
||||
|
||||
it('attaches multiple files and returns their views', () => {
|
||||
const f1 = uploadAttachment(authorId);
|
||||
const f2 = uploadAttachment(authorId);
|
||||
const views = service.attach(authorId, projectId, 'chat_message', 1, [
|
||||
f1.id,
|
||||
f2.id,
|
||||
]);
|
||||
expect(views).toHaveLength(2);
|
||||
expect(views.map((v) => v.fileId).sort()).toEqual([f1.id, f2.id].sort());
|
||||
});
|
||||
|
||||
it('returns empty for no fileIds', () => {
|
||||
expect(service.attach(authorId, projectId, 'chat_message', 1, [])).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates repeated fileIds', () => {
|
||||
const f1 = uploadAttachment(authorId);
|
||||
const views = service.attach(authorId, projectId, 'chat_message', 1, [
|
||||
f1.id,
|
||||
f1.id,
|
||||
]);
|
||||
expect(views).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('forbids a non-member from attaching', () => {
|
||||
const f1 = uploadAttachment(authorId);
|
||||
expect(() =>
|
||||
service.attach(outsiderId, projectId, 'chat_message', 1, [f1.id])
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('throws NotFoundError for a missing file', () => {
|
||||
expect(() =>
|
||||
service.attach(authorId, projectId, 'chat_message', 1, [99999])
|
||||
).toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('forbids attaching a file from another project', () => {
|
||||
const otherProject = new ProjectRepository(db).create({
|
||||
name: 'P2',
|
||||
ownerId: authorId,
|
||||
}).id;
|
||||
const members = new ProjectMemberRepository(db);
|
||||
members.add(otherProject, authorId, 'admin');
|
||||
const otherFile = fileStorage.uploadForAttachment(authorId, otherProject, {
|
||||
originalName: 'x.png',
|
||||
mimeType: 'image/png',
|
||||
data: Buffer.from('x'),
|
||||
});
|
||||
expect(() =>
|
||||
service.attach(authorId, projectId, 'chat_message', 1, [otherFile.id])
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('forbids attaching a file uploaded by another member', () => {
|
||||
const otherMemberFile = uploadAttachment(memberId);
|
||||
expect(() =>
|
||||
service.attach(authorId, projectId, 'chat_message', 1, [
|
||||
otherMemberFile.id,
|
||||
])
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('listViewsBatch returns views grouped by targetId', () => {
|
||||
const f1 = uploadAttachment(authorId);
|
||||
const f2 = uploadAttachment(authorId);
|
||||
service.attach(authorId, projectId, 'board_comment', 10, [f1.id]);
|
||||
service.attach(authorId, projectId, 'board_comment', 11, [f2.id]);
|
||||
const views = service.listViewsBatch('board_comment', [10, 11]);
|
||||
expect(views).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('detach soft-deletes attachments for the target', () => {
|
||||
const f1 = uploadAttachment(authorId);
|
||||
service.attach(authorId, projectId, 'chat_message', 5, [f1.id]);
|
||||
expect(service.listViews('chat_message', 5)).toHaveLength(1);
|
||||
service.detach('chat_message', 5);
|
||||
expect(service.listViews('chat_message', 5)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@ -5,19 +5,29 @@ import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { BoardRepository } from '@/repositories/BoardRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { BoardService } from '@/services/BoardService';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
|
||||
function makeService(db: SqliteDatabase) {
|
||||
const members = new ProjectMemberRepository(db);
|
||||
return new BoardService(
|
||||
new BoardRepository(db),
|
||||
new ProjectMemberRepository(db),
|
||||
members,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db))
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
members
|
||||
),
|
||||
db
|
||||
);
|
||||
}
|
||||
|
||||
@ -167,4 +177,76 @@ describe('BoardService', () => {
|
||||
it('throws NotFoundError for a non-existent thread', () => {
|
||||
expect(() => service.getThread(memberId, 99999)).toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('createThread with fileIds attaches files to the thread', () => {
|
||||
const fileRepo = new FileRepository(db);
|
||||
const f1 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: memberId,
|
||||
filename: 'a.png',
|
||||
originalName: 'a.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/a.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
const thread = service.createThread(memberId, projectId, {
|
||||
title: 'T',
|
||||
bodyMd: 'b',
|
||||
fileIds: [f1.id],
|
||||
});
|
||||
const atts = service.getAttachments(memberId, thread.id, []);
|
||||
expect(atts.thread).toHaveLength(1);
|
||||
expect(atts.thread[0].fileId).toBe(f1.id);
|
||||
});
|
||||
|
||||
it('createComment with fileIds attaches files to the comment', () => {
|
||||
const fileRepo = new FileRepository(db);
|
||||
const f1 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: memberId,
|
||||
filename: 'c.png',
|
||||
originalName: 'c.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/c.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
const thread = service.createThread(authorId, projectId, {
|
||||
title: 'T',
|
||||
bodyMd: 'b',
|
||||
});
|
||||
const comment = service.createComment(memberId, thread.id, 'c', [f1.id]);
|
||||
const atts = service.getAttachments(memberId, thread.id, [comment.id]);
|
||||
expect(atts.comments).toHaveLength(1);
|
||||
expect(atts.comments[0].targetId).toBe(comment.id);
|
||||
});
|
||||
|
||||
it('deleteThread and deleteComment detach attachments', () => {
|
||||
const fileRepo = new FileRepository(db);
|
||||
const f1 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: memberId,
|
||||
filename: 'a.png',
|
||||
originalName: 'a.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/a.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
const thread = service.createThread(memberId, projectId, {
|
||||
title: 'T',
|
||||
bodyMd: 'b',
|
||||
fileIds: [f1.id],
|
||||
});
|
||||
// 削除前は添付あり
|
||||
expect(
|
||||
new AttachmentRepository(db).findByTarget('board_thread', thread.id)
|
||||
).toHaveLength(1);
|
||||
service.deleteThread(memberId, thread.id);
|
||||
// 削除後は論理削除されて取得できない
|
||||
expect(
|
||||
new AttachmentRepository(db).findByTarget('board_thread', thread.id)
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,8 +5,11 @@ import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { ChatRepository } from '@/repositories/ChatRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { ChatService } from '@/services/ChatService';
|
||||
import { SseHub, type SseClient } from '@/lib/sse/hub';
|
||||
import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
@ -56,7 +59,13 @@ describe('ChatService', () => {
|
||||
members,
|
||||
users,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
hub
|
||||
hub,
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
members
|
||||
),
|
||||
db
|
||||
);
|
||||
});
|
||||
|
||||
@ -135,4 +144,102 @@ describe('ChatService', () => {
|
||||
it('throws NotFoundError for a non-existent message', () => {
|
||||
expect(() => service.deleteMessage(authorId, 99999)).toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('sendMessage with fileIds attaches files and broadcasts them', () => {
|
||||
const client = makeClient();
|
||||
hub.addClient(projectId, client);
|
||||
const fileRepo = new FileRepository(db);
|
||||
const f1 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: authorId,
|
||||
filename: 'a.png',
|
||||
originalName: 'a.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/a.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
const f2 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: authorId,
|
||||
filename: 'b.png',
|
||||
originalName: 'b.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/b.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
|
||||
const message = service.sendMessage(authorId, projectId, 'see this', [
|
||||
f1.id,
|
||||
f2.id,
|
||||
]);
|
||||
|
||||
expect(message.attachments).toHaveLength(2);
|
||||
expect(client.received[0]).toContain('chat.message.created');
|
||||
expect(client.received[0]).toContain('"attachments"');
|
||||
});
|
||||
|
||||
it('getHistory returns messages with their attachments', () => {
|
||||
const fileRepo = new FileRepository(db);
|
||||
const f1 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: authorId,
|
||||
filename: 'a.png',
|
||||
originalName: 'a.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/a.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
service.sendMessage(authorId, projectId, 'with file', [f1.id]);
|
||||
service.sendMessage(authorId, projectId, 'no file');
|
||||
|
||||
const history = service.getHistory(authorId, projectId);
|
||||
const withFile = history.items.find((m) => m.body === 'with file');
|
||||
const noFile = history.items.find((m) => m.body === 'no file');
|
||||
expect(withFile?.attachments).toHaveLength(1);
|
||||
expect(noFile?.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it('deleteMessage soft-deletes its attachments', () => {
|
||||
const fileRepo = new FileRepository(db);
|
||||
const f1 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: authorId,
|
||||
filename: 'a.png',
|
||||
originalName: 'a.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/a.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
const m = service.sendMessage(authorId, projectId, 'x', [f1.id]);
|
||||
expect(m.attachments).toHaveLength(1);
|
||||
service.deleteMessage(authorId, m.id);
|
||||
const history = service.getHistory(authorId, projectId);
|
||||
// 削除されたメッセージは履歴に無く、添付も残らない
|
||||
expect(history.items.find((msg) => msg.id === m.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('editMessage preserves attachments in history (regression)', () => {
|
||||
const fileRepo = new FileRepository(db);
|
||||
const f1 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: authorId,
|
||||
filename: 'a.png',
|
||||
originalName: 'a.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/a.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
const m = service.sendMessage(authorId, projectId, 'orig', [f1.id]);
|
||||
service.editMessage(authorId, m.id, 'edited');
|
||||
const history = service.getHistory(authorId, projectId);
|
||||
const edited = history.items.find((msg) => msg.id === m.id);
|
||||
expect(edited?.body).toBe('edited');
|
||||
// 編集後も添付は履歴に残る
|
||||
expect(edited?.attachments).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@ -157,4 +157,49 @@ describe('FileStorageService', () => {
|
||||
service.delete(authorId, file.id); // authorId is admin
|
||||
expect(() => service.getFileInfo(memberId, file.id)).toThrow();
|
||||
});
|
||||
|
||||
it('uploadForAttachment is silent: no notification/SSE/activity, source=attachment', () => {
|
||||
const file = service.uploadForAttachment(authorId, projectId, {
|
||||
originalName: 'pic.png',
|
||||
mimeType: 'image/png',
|
||||
data: Buffer.from('img'),
|
||||
});
|
||||
expect(file.source).toBe('attachment');
|
||||
expect(fs.existsSync(file.path)).toBe(true);
|
||||
// メンバーへの file_shared 通知は飛ばない
|
||||
expect(new NotificationRepository(db).countUnreadByUser(memberId)).toBe(0);
|
||||
// file_uploaded アクティビティは記録されない
|
||||
expect(
|
||||
new ActivityLogRepository(db)
|
||||
.findByProject(projectId)
|
||||
.items.some((l) => l.action === 'file_uploaded')
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uploadForAttachment result is excluded from the library list', () => {
|
||||
service.uploadForAttachment(authorId, projectId, {
|
||||
originalName: 'pic.png',
|
||||
mimeType: 'image/png',
|
||||
data: Buffer.from('img'),
|
||||
});
|
||||
const list = service.listFiles(authorId, projectId);
|
||||
expect(list.total).toBe(0);
|
||||
});
|
||||
|
||||
it('uploadForAttachment still enforces MIME and membership', () => {
|
||||
expect(() =>
|
||||
service.uploadForAttachment(authorId, projectId, {
|
||||
originalName: 'evil.exe',
|
||||
mimeType: 'application/x-msdownload',
|
||||
data: Buffer.from('x'),
|
||||
})
|
||||
).toThrow(ValidationError);
|
||||
expect(() =>
|
||||
service.uploadForAttachment(outsiderId, projectId, {
|
||||
originalName: 'x.png',
|
||||
mimeType: 'image/png',
|
||||
data: Buffer.from('x'),
|
||||
})
|
||||
).toThrow(ForbiddenError);
|
||||
});
|
||||
});
|
||||
|
||||
@ -5,10 +5,13 @@ import { UserRepository } from '@/repositories/UserRepository';
|
||||
import { ProjectRepository } from '@/repositories/ProjectRepository';
|
||||
import { ProjectMemberRepository } from '@/repositories/ProjectMemberRepository';
|
||||
import { TodoRepository } from '@/repositories/TodoRepository';
|
||||
import { FileRepository } from '@/repositories/FileRepository';
|
||||
import { AttachmentRepository } from '@/repositories/AttachmentRepository';
|
||||
import { NotificationRepository } from '@/repositories/NotificationRepository';
|
||||
import { ActivityLogRepository } from '@/repositories/ActivityLogRepository';
|
||||
import { NotificationService } from '@/services/NotificationService';
|
||||
import { ActivityLogService } from '@/services/ActivityLogService';
|
||||
import { AttachmentService } from '@/services/AttachmentService';
|
||||
import { TodoService } from '@/services/TodoService';
|
||||
import { SseHub } from '@/lib/sse/hub';
|
||||
import { ForbiddenError, NotFoundError } from '@/lib/errors';
|
||||
@ -24,7 +27,13 @@ function makeService(db: SqliteDatabase) {
|
||||
members,
|
||||
new NotificationService(new NotificationRepository(db)),
|
||||
new ActivityLogService(new ActivityLogRepository(db)),
|
||||
hub
|
||||
hub,
|
||||
new AttachmentService(
|
||||
new AttachmentRepository(db),
|
||||
new FileRepository(db),
|
||||
members
|
||||
),
|
||||
db
|
||||
),
|
||||
members,
|
||||
users,
|
||||
@ -161,4 +170,142 @@ describe('TodoService', () => {
|
||||
NotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
it('createItem persists startDate/tags and attaches files', () => {
|
||||
const col = service.getColumns(authorId, projectId)[0];
|
||||
const fileRepo = new FileRepository(db);
|
||||
const f1 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: authorId,
|
||||
filename: 'a.png',
|
||||
originalName: 'a.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/a.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
const item = service.createItem(authorId, projectId, {
|
||||
title: 'rich',
|
||||
columnId: col.id,
|
||||
startDate: '2026-07-01',
|
||||
dueDate: '2026-07-31',
|
||||
tags: 'frontend,urgent',
|
||||
fileIds: [f1.id],
|
||||
});
|
||||
expect(item.startDate).toBe('2026-07-01');
|
||||
expect(item.tags).toBe('frontend,urgent');
|
||||
expect(service.getItemAttachments(authorId, item.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updateItem updates startDate/tags and attaches new files', () => {
|
||||
const col = service.getColumns(authorId, projectId)[0];
|
||||
const item = service.createItem(authorId, projectId, {
|
||||
title: 't',
|
||||
columnId: col.id,
|
||||
});
|
||||
const fileRepo = new FileRepository(db);
|
||||
const f1 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: authorId,
|
||||
filename: 'b.png',
|
||||
originalName: 'b.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/b.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
const updated = service.updateItem(authorId, item.id, {
|
||||
description: 'desc',
|
||||
startDate: '2026-08-01',
|
||||
tags: 'backend',
|
||||
fileIds: [f1.id],
|
||||
});
|
||||
expect(updated.description).toBe('desc');
|
||||
expect(updated.startDate).toBe('2026-08-01');
|
||||
expect(updated.tags).toBe('backend');
|
||||
expect(service.getItemAttachments(authorId, item.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deleteItem detaches its attachments', () => {
|
||||
const col = service.getColumns(authorId, projectId)[0];
|
||||
const fileRepo = new FileRepository(db);
|
||||
const f1 = fileRepo.create({
|
||||
projectId,
|
||||
uploaderId: authorId,
|
||||
filename: 'a.png',
|
||||
originalName: 'a.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1,
|
||||
path: '/tmp/a.png',
|
||||
source: 'attachment',
|
||||
});
|
||||
const item = service.createItem(authorId, projectId, {
|
||||
title: 't',
|
||||
columnId: col.id,
|
||||
fileIds: [f1.id],
|
||||
});
|
||||
expect(service.getItemAttachments(authorId, item.id)).toHaveLength(1);
|
||||
service.deleteItem(authorId, item.id);
|
||||
expect(
|
||||
new AttachmentRepository(db).findByTarget('todo_item', item.id)
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('getItem and getItemAttachments enforce membership', () => {
|
||||
const col = service.getColumns(authorId, projectId)[0];
|
||||
const item = service.createItem(authorId, projectId, {
|
||||
title: 't',
|
||||
columnId: col.id,
|
||||
});
|
||||
expect(service.getItem(authorId, item.id).id).toBe(item.id);
|
||||
expect(() => service.getItem(outsiderId, item.id)).toThrow(ForbiddenError);
|
||||
expect(() => service.getItemAttachments(outsiderId, item.id)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
});
|
||||
|
||||
it('only the creator or an admin can delete an item (member-but-not-creator cannot)', () => {
|
||||
const col = service.getColumns(authorId, projectId)[0];
|
||||
// authorId(admin)が作成したアイテムを memberId(非管理者・非作成者)は削除できない
|
||||
const item = service.createItem(authorId, projectId, {
|
||||
title: 't',
|
||||
columnId: col.id,
|
||||
});
|
||||
expect(() => service.deleteItem(memberId, item.id)).toThrow(ForbiddenError);
|
||||
// admin(作成者)は削除可
|
||||
service.deleteItem(authorId, item.id);
|
||||
expect(() => service.getItem(authorId, item.id)).toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it('deleteColumn: admin can delete, non-admin member cannot', () => {
|
||||
const col = service.getColumns(authorId, projectId)[0];
|
||||
// memberId(非管理者)はカラム削除不可
|
||||
expect(() => service.deleteColumn(memberId, col.id)).toThrow(
|
||||
ForbiddenError
|
||||
);
|
||||
// authorId(admin)は削除可
|
||||
service.deleteColumn(authorId, col.id);
|
||||
expect(() => service.updateColumn(authorId, col.id, { name: 'x' })).toThrow(
|
||||
NotFoundError
|
||||
);
|
||||
});
|
||||
|
||||
it('clears startDate/tags/assigneeId via null on update', () => {
|
||||
const col = service.getColumns(authorId, projectId)[0];
|
||||
const item = service.createItem(authorId, projectId, {
|
||||
title: 't',
|
||||
columnId: col.id,
|
||||
startDate: '2026-07-01',
|
||||
tags: 'a,b',
|
||||
assigneeId: memberId,
|
||||
});
|
||||
const cleared = service.updateItem(authorId, item.id, {
|
||||
startDate: null,
|
||||
tags: null,
|
||||
assigneeId: null,
|
||||
});
|
||||
expect(cleared.startDate).toBeNull();
|
||||
expect(cleared.tags).toBeNull();
|
||||
expect(cleared.assigneeId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user