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

View File

@ -0,0 +1,45 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { Notification, NotificationType } from '@/lib/types';
export interface CreateNotificationInput {
userId: number;
projectId: number | null;
type: NotificationType;
title: string;
body: string | null;
}
/**
* notificationsテーブルへのデータアクセスを担うRepository。
* M4ではメンバー追加通知のために create のみ実装する。
* 一覧取得・既読化は M5 で拡張する。
*/
export class NotificationRepository {
constructor(private readonly db: SqliteDatabase) {}
create(input: CreateNotificationInput): Notification {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO notifications (user_id, project_id, type, title, body, read_at, created_at)
VALUES (@userId, @projectId, @type, @title, @body, NULL, @createdAt)`,
{
userId: input.userId,
projectId: input.projectId,
type: input.type,
title: input.title,
body: input.body,
createdAt: now,
}
);
return {
id: Number(result.lastInsertRowid),
userId: input.userId,
projectId: input.projectId,
type: input.type,
title: input.title,
body: input.body,
readAt: null,
createdAt: now,
};
}
}

View File

@ -0,0 +1,123 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type {
ProjectMember,
ProjectMemberRole,
User,
UserRole,
UserStatus,
} from '@/lib/types';
interface ProjectMemberRow {
id: number;
project_id: number;
user_id: number;
role: string;
joined_at: string;
}
interface MemberWithUserRow extends ProjectMemberRow {
name: string;
email: string;
avatar_url: string | null;
user_role: string;
user_status: string;
}
export interface ProjectMemberWithUser extends ProjectMember {
user: Pick<User, 'id' | 'name' | 'email' | 'avatarUrl' | 'role' | 'status'>;
}
function mapMember(row: ProjectMemberRow): ProjectMember {
return {
id: row.id,
projectId: row.project_id,
userId: row.user_id,
role: row.role as ProjectMemberRole,
joinedAt: row.joined_at,
};
}
function mapMemberWithUser(row: MemberWithUserRow): ProjectMemberWithUser {
return {
...mapMember(row),
user: {
id: row.user_id,
name: row.name,
email: row.email,
avatarUrl: row.avatar_url,
role: row.user_role as UserRole,
status: row.user_status as UserStatus,
},
};
}
/**
* project_membersテーブルへのデータアクセスを担うRepository。
*/
export class ProjectMemberRepository {
constructor(private readonly db: SqliteDatabase) {}
findByProject(projectId: number): ProjectMemberWithUser[] {
const rows = this.db.query<MemberWithUserRow>(
`SELECT pm.*, u.name, u.email, u.avatar_url, u.role AS user_role, u.status AS user_status
FROM project_members pm
INNER JOIN users u ON u.id = pm.user_id
WHERE pm.project_id = @projectId
ORDER BY pm.id`,
{ projectId }
);
return rows.map(mapMemberWithUser);
}
findByUser(userId: number): ProjectMember[] {
const rows = this.db.query<ProjectMemberRow>(
'SELECT * FROM project_members WHERE user_id = @userId ORDER BY id',
{ userId }
);
return rows.map(mapMember);
}
add(
projectId: number,
userId: number,
role: ProjectMemberRole
): ProjectMember {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO project_members (project_id, user_id, role, joined_at)
VALUES (@projectId, @userId, @role, @joinedAt)`,
{ projectId, userId, role, joinedAt: now }
);
return {
id: Number(result.lastInsertRowid),
projectId,
userId,
role,
joinedAt: now,
};
}
remove(projectId: number, userId: number): boolean {
const result = this.db.execute(
'DELETE FROM project_members WHERE project_id = @projectId AND user_id = @userId',
{ projectId, userId }
);
return result.changes > 0;
}
isMember(projectId: number, userId: number): boolean {
const row = this.db.get<{ id: number }>(
'SELECT id FROM project_members WHERE project_id = @projectId AND user_id = @userId',
{ projectId, userId }
);
return row !== null;
}
getRole(projectId: number, userId: number): ProjectMemberRole | null {
const row = this.db.get<{ role: string }>(
'SELECT role FROM project_members WHERE project_id = @projectId AND user_id = @userId',
{ projectId, userId }
);
return row ? (row.role as ProjectMemberRole) : null;
}
}

View File

@ -0,0 +1,126 @@
import type { SqliteDatabase } from '@/lib/db/sqlite';
import type { Project, ProjectStatus } from '@/lib/types';
interface ProjectRow {
id: number;
name: string;
description: string | null;
status: string;
owner_id: number;
created_at: string;
updated_at: string;
}
function mapProject(row: ProjectRow): Project {
return {
id: row.id,
name: row.name,
description: row.description,
status: row.status as ProjectStatus,
ownerId: row.owner_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export interface CreateProjectInput {
name: string;
description?: string;
ownerId: number;
}
export interface UpdateProjectInput {
name?: string;
description?: string | null;
status?: ProjectStatus;
}
/**
* projectsテーブルへのデータアクセスを担うRepository。
*/
export class ProjectRepository {
constructor(private readonly db: SqliteDatabase) {}
findById(id: number): Project | null {
const row = this.db.get<ProjectRow>(
'SELECT * FROM projects WHERE id = @id',
{ id }
);
return row ? mapProject(row) : null;
}
findByOwner(ownerId: number): Project[] {
const rows = this.db.query<ProjectRow>(
'SELECT * FROM projects WHERE owner_id = @ownerId ORDER BY id',
{ ownerId }
);
return rows.map(mapProject);
}
/** ユーザーが参加しているプロジェクト一覧を取得する */
findProjectsByUserId(userId: number): Project[] {
const rows = this.db.query<ProjectRow>(
`SELECT p.* FROM projects p
INNER JOIN project_members pm ON pm.project_id = p.id
WHERE pm.user_id = @userId
ORDER BY p.id`,
{ userId }
);
return rows.map(mapProject);
}
create(input: CreateProjectInput): Project {
const now = new Date().toISOString();
const result = this.db.execute(
`INSERT INTO projects (name, description, status, owner_id, created_at, updated_at)
VALUES (@name, @description, @status, @ownerId, @createdAt, @updatedAt)`,
{
name: input.name,
description: input.description ?? null,
status: 'active',
ownerId: input.ownerId,
createdAt: now,
updatedAt: now,
}
);
const created = this.findById(Number(result.lastInsertRowid));
if (!created) {
throw new Error('Failed to create project');
}
return created;
}
update(id: number, input: UpdateProjectInput): Project | null {
const fields: string[] = ['updated_at = @updatedAt'];
const params: Record<string, unknown> = {
updatedAt: new Date().toISOString(),
id,
};
if (input.name !== undefined) {
fields.push('name = @name');
params.name = input.name;
}
if (input.description !== undefined) {
fields.push('description = @description');
params.description = input.description;
}
if (input.status !== undefined) {
fields.push('status = @status');
params.status = input.status;
}
this.db.execute(
`UPDATE projects SET ${fields.join(', ')} WHERE id = @id`,
params
);
return this.findById(id);
}
delete(id: number): boolean {
const result = this.db.execute('DELETE FROM projects WHERE id = @id', {
id,
});
return result.changes > 0;
}
}