feat(m4): project & member management with tests and e2e

- ProjectRepository, ProjectMemberRepository, minimal NotificationRepository
- ProjectService: create/update/archive/delete project, add/remove member,
  permission checks (isMember/admin), member-added notification, getDashboard
  skeleton, getMyProjects, getMemberRole
- projectValidator, API services factory
- API routes: projects CRUD, members list/add/remove
- Screens: dashboard (project list + create form), project overview, members,
  settings; layout (Header/Sidebar/ProjectNav) and project components
- Unit tests (Project/ProjectMember repositories, ProjectService),
  integration project-member-permission, e2e project-management
This commit is contained in:
Ken Yasue
2026-06-25 01:02:10 +02:00
parent 8a67523c0e
commit 9eb391ea8d
30 changed files with 2230 additions and 14 deletions

162
services/ProjectService.ts Normal file
View File

@ -0,0 +1,162 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import { ProjectRepository } from '@/repositories/ProjectRepository';
import {
ProjectMemberRepository,
type ProjectMemberWithUser,
} from '@/repositories/ProjectMemberRepository';
import { NotificationRepository } from '@/repositories/NotificationRepository';
import {
validateProjectCreate,
validateProjectUpdate,
type ProjectUpdateInput,
} from '@/lib/validators/projectValidator';
import { NotFoundError, ForbiddenError, ConflictError } from '@/lib/errors';
import type { Project, ProjectMember, ProjectMemberRole } from '@/lib/types';
export interface CreateProjectInput {
name: string;
description?: string;
}
export interface ProjectDashboard {
project: Project;
members: ProjectMemberWithUser[];
}
/**
* プロジェクト・メンバー管理の業務ロジックを担うService。
* 権限チェック(isMember/ロール)・トランザクション境界・メンバー追加通知を扱う。
*/
export class ProjectService {
constructor(
private readonly projectRepository: ProjectRepository,
private readonly projectMemberRepository: ProjectMemberRepository,
private readonly notificationRepository: NotificationRepository,
private readonly db: SqliteDatabase
) {}
createProject(actorId: number, input: CreateProjectInput): Project {
validateProjectCreate(input);
return this.db.transaction(() => {
const project = this.projectRepository.create({
name: input.name,
description: input.description,
ownerId: actorId,
});
// プロジェクト作成者を管理者(admin)メンバーとして登録
this.projectMemberRepository.add(project.id, actorId, 'admin');
return project;
});
}
getProject(actorId: number, projectId: number): Project {
return this.requireProjectMember(projectId, actorId);
}
getMyProjects(userId: number): Project[] {
return this.projectRepository.findProjectsByUserId(userId);
}
updateProject(
actorId: number,
projectId: number,
input: ProjectUpdateInput
): Project {
validateProjectUpdate(input);
this.requireProjectAdmin(projectId, actorId);
const updated = this.projectRepository.update(projectId, input);
if (!updated) {
throw new NotFoundError('Project', projectId);
}
return updated;
}
archiveProject(actorId: number, projectId: number): Project {
this.requireProjectAdmin(projectId, actorId);
const updated = this.projectRepository.update(projectId, {
status: 'archived',
});
if (!updated) {
throw new NotFoundError('Project', projectId);
}
return updated;
}
deleteProject(actorId: number, projectId: number): void {
this.requireProjectAdmin(projectId, actorId);
this.projectRepository.delete(projectId);
}
getMembers(actorId: number, projectId: number): ProjectMemberWithUser[] {
this.requireProjectMember(projectId, actorId);
return this.projectMemberRepository.findByProject(projectId);
}
/** 操作者のプロジェクト内ロールを取得する(非参加者・非存在プロジェクトは null */
getMemberRole(actorId: number, projectId: number): ProjectMemberRole | null {
return this.projectMemberRepository.getRole(projectId, actorId);
}
addMember(
actorId: number,
projectId: number,
userId: number,
role: ProjectMemberRole
): ProjectMember {
this.requireProjectAdmin(projectId, actorId);
if (this.projectMemberRepository.isMember(projectId, userId)) {
throw new ConflictError('このユーザーは既にメンバーです');
}
return this.db.transaction(() => {
const member = this.projectMemberRepository.add(projectId, userId, role);
// 追加されたユーザーへ通知(M5のNotificationService連携相当を直接記録)
this.notificationRepository.create({
userId,
projectId,
type: 'project_added',
title: 'プロジェクトに追加されました',
body: `プロジェクトID ${projectId} のメンバーに追加されました`,
});
return member;
});
}
removeMember(actorId: number, projectId: number, userId: number): void {
this.requireProjectMember(projectId, actorId);
const actorRole = this.projectMemberRepository.getRole(projectId, actorId);
// 管理者は誰でも削除可能。一般メンバーは自分自身のみ削除可能。
if (actorId !== userId && actorRole !== 'admin') {
throw new ForbiddenError('他のメンバーの削除には管理者権限が必要です');
}
const removed = this.projectMemberRepository.remove(projectId, userId);
if (!removed) {
throw new NotFoundError('Member', userId);
}
}
getDashboard(actorId: number, projectId: number): ProjectDashboard {
const project = this.requireProjectMember(projectId, actorId);
const members = this.projectMemberRepository.findByProject(projectId);
return { project, members };
}
private requireProjectMember(projectId: number, actorId: number): Project {
const project = this.projectRepository.findById(projectId);
if (!project) {
throw new NotFoundError('Project', projectId);
}
if (!this.projectMemberRepository.isMember(projectId, actorId)) {
throw new ForbiddenError('プロジェクトに参加していません');
}
return project;
}
private requireProjectAdmin(projectId: number, actorId: number): Project {
const project = this.requireProjectMember(projectId, actorId);
const role = this.projectMemberRepository.getRole(projectId, actorId);
if (role !== 'admin') {
throw new ForbiddenError('プロジェクト管理者権限が必要です');
}
return project;
}
}