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:
45
repositories/NotificationRepository.ts
Normal file
45
repositories/NotificationRepository.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
123
repositories/ProjectMemberRepository.ts
Normal file
123
repositories/ProjectMemberRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
126
repositories/ProjectRepository.ts
Normal file
126
repositories/ProjectRepository.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user