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:
162
services/ProjectService.ts
Normal file
162
services/ProjectService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user