Files
opengroupware/services/ProjectService.ts
Ken Yasue 9eb391ea8d 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
2026-06-25 01:02:10 +02:00

163 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}