feat: introduce Teams & OKRs feature with models, types, and UI components for team management and objective tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 12m53s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 12m53s
This commit is contained in:
308
src/services/okrs.ts
Normal file
308
src/services/okrs.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import { prisma } from '@/services/database';
|
||||
import type { CreateOKRInput, UpdateOKRInput, UpdateKeyResultInput, OKRStatus, KeyResultStatus } from '@/lib/types';
|
||||
|
||||
export async function createOKR(
|
||||
teamMemberId: string,
|
||||
objective: string,
|
||||
description: string | null,
|
||||
period: string,
|
||||
startDate: Date,
|
||||
endDate: Date,
|
||||
keyResults: Array<{ title: string; targetValue: number; unit: string; order: number }>
|
||||
) {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Create OKR
|
||||
const okr = await tx.oKR.create({
|
||||
data: {
|
||||
teamMemberId,
|
||||
objective,
|
||||
description,
|
||||
period,
|
||||
startDate,
|
||||
endDate,
|
||||
status: 'NOT_STARTED',
|
||||
},
|
||||
});
|
||||
|
||||
// Create Key Results
|
||||
const createdKeyResults = await Promise.all(
|
||||
keyResults.map((kr, index) =>
|
||||
tx.keyResult.create({
|
||||
data: {
|
||||
okrId: okr.id,
|
||||
title: kr.title,
|
||||
targetValue: kr.targetValue,
|
||||
currentValue: 0,
|
||||
unit: kr.unit || '%',
|
||||
status: 'NOT_STARTED',
|
||||
order: kr.order !== undefined ? kr.order : index,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
...okr,
|
||||
keyResults: createdKeyResults,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOKR(okrId: string) {
|
||||
const okr = await prisma.oKR.findUnique({
|
||||
where: { id: okrId },
|
||||
include: {
|
||||
keyResults: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
teamMember: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!okr) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate progress
|
||||
const progress = calculateOKRProgressFromKeyResults(okr.keyResults);
|
||||
|
||||
return {
|
||||
...okr,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTeamMemberOKRs(teamMemberId: string) {
|
||||
const okrs = await prisma.oKR.findMany({
|
||||
where: { teamMemberId },
|
||||
include: {
|
||||
keyResults: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return okrs.map((okr) => ({
|
||||
...okr,
|
||||
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getTeamOKRs(teamId: string) {
|
||||
// Get all team members
|
||||
const teamMembers = await prisma.teamMember.findMany({
|
||||
where: { teamId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
okrs: {
|
||||
include: {
|
||||
keyResults: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return teamMembers.map((tm) => ({
|
||||
...tm,
|
||||
okrs: tm.okrs.map((okr) => ({
|
||||
...okr,
|
||||
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getUserOKRs(userId: string) {
|
||||
// Get all team members for this user
|
||||
const teamMembers = await prisma.teamMember.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
okrs: {
|
||||
include: {
|
||||
keyResults: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Flatten and return OKRs with team info
|
||||
return teamMembers.flatMap((tm) =>
|
||||
tm.okrs.map((okr) => ({
|
||||
...okr,
|
||||
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
|
||||
team: tm.team,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateOKR(okrId: string, data: UpdateOKRInput) {
|
||||
const okr = await prisma.oKR.update({
|
||||
where: { id: okrId },
|
||||
data: {
|
||||
...(data.objective !== undefined && { objective: data.objective }),
|
||||
...(data.description !== undefined && { description: data.description || null }),
|
||||
...(data.period !== undefined && { period: data.period }),
|
||||
...(data.startDate !== undefined && { startDate: data.startDate }),
|
||||
...(data.endDate !== undefined && { endDate: data.endDate }),
|
||||
...(data.status !== undefined && { status: data.status }),
|
||||
},
|
||||
include: {
|
||||
keyResults: {
|
||||
orderBy: {
|
||||
order: 'asc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...okr,
|
||||
progress: calculateOKRProgressFromKeyResults(okr.keyResults),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteOKR(okrId: string) {
|
||||
// Cascade delete will handle key results
|
||||
return prisma.oKR.delete({
|
||||
where: { id: okrId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateKeyResult(krId: string, currentValue: number, notes: string | null) {
|
||||
// Auto-update status based on progress
|
||||
const kr = await prisma.keyResult.findUnique({
|
||||
where: { id: krId },
|
||||
include: {
|
||||
okr: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!kr) {
|
||||
throw new Error('Key Result not found');
|
||||
}
|
||||
|
||||
const progress = (currentValue / kr.targetValue) * 100;
|
||||
let status: KeyResultStatus = kr.status;
|
||||
|
||||
if (progress >= 100) {
|
||||
status = 'COMPLETED';
|
||||
} else if (progress > 0) {
|
||||
status = progress < 50 ? 'AT_RISK' : 'IN_PROGRESS';
|
||||
} else {
|
||||
status = 'NOT_STARTED';
|
||||
}
|
||||
|
||||
const updated = await prisma.keyResult.update({
|
||||
where: { id: krId },
|
||||
data: {
|
||||
currentValue,
|
||||
notes: notes !== undefined ? notes : kr.notes,
|
||||
status,
|
||||
},
|
||||
include: {
|
||||
okr: {
|
||||
include: {
|
||||
keyResults: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Update OKR status based on key results
|
||||
const okrProgress = calculateOKRProgressFromKeyResults(updated.okr.keyResults);
|
||||
let okrStatus: OKRStatus = updated.okr.status;
|
||||
|
||||
if (okrProgress >= 100) {
|
||||
okrStatus = 'COMPLETED';
|
||||
} else if (okrProgress > 0) {
|
||||
okrStatus = 'IN_PROGRESS';
|
||||
}
|
||||
|
||||
// Update OKR status if needed
|
||||
if (okrStatus !== updated.okr.status) {
|
||||
await prisma.oKR.update({
|
||||
where: { id: updated.okr.id },
|
||||
data: { status: okrStatus },
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function calculateOKRProgress(okrId: string): Promise<number> {
|
||||
return prisma.oKR
|
||||
.findUnique({
|
||||
where: { id: okrId },
|
||||
include: {
|
||||
keyResults: true,
|
||||
},
|
||||
})
|
||||
.then((okr) => {
|
||||
if (!okr) {
|
||||
return 0;
|
||||
}
|
||||
return calculateOKRProgressFromKeyResults(okr.keyResults);
|
||||
});
|
||||
}
|
||||
|
||||
function calculateOKRProgressFromKeyResults(keyResults: Array<{ currentValue: number; targetValue: number }>): number {
|
||||
if (keyResults.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const totalProgress = keyResults.reduce((sum, kr) => {
|
||||
const progress = kr.targetValue > 0 ? (kr.currentValue / kr.targetValue) * 100 : 0;
|
||||
return sum + Math.min(progress, 100); // Cap at 100%
|
||||
}, 0);
|
||||
|
||||
return Math.round(totalProgress / keyResults.length);
|
||||
}
|
||||
|
||||
267
src/services/teams.ts
Normal file
267
src/services/teams.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { prisma } from '@/services/database';
|
||||
import type { CreateTeamInput, UpdateTeamInput, AddTeamMemberInput, UpdateMemberRoleInput, TeamRole } from '@/lib/types';
|
||||
|
||||
export async function createTeam(name: string, description: string | null, createdById: string) {
|
||||
// Create team and add creator as admin in a transaction
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const team = await tx.team.create({
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
createdById,
|
||||
},
|
||||
});
|
||||
|
||||
// Add creator as admin
|
||||
await tx.teamMember.create({
|
||||
data: {
|
||||
teamId: team.id,
|
||||
userId: createdById,
|
||||
role: 'ADMIN',
|
||||
},
|
||||
});
|
||||
|
||||
return team;
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTeam(teamId: string) {
|
||||
return prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
okrs: {
|
||||
include: {
|
||||
keyResults: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
joinedAt: 'asc',
|
||||
},
|
||||
},
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserTeams(userId: string) {
|
||||
// Get teams where user is a member (admin or regular member)
|
||||
const teamMembers = await prisma.teamMember.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
okrs: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
joinedAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return teamMembers.map((tm) => ({
|
||||
...tm.team,
|
||||
userRole: tm.role,
|
||||
userOkrCount: tm._count.okrs,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateTeam(teamId: string, data: UpdateTeamInput) {
|
||||
return prisma.team.update({
|
||||
where: { id: teamId },
|
||||
data: {
|
||||
...(data.name !== undefined && { name: data.name }),
|
||||
...(data.description !== undefined && { description: data.description || null }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTeam(teamId: string) {
|
||||
// Cascade delete will handle members and OKRs
|
||||
return prisma.team.delete({
|
||||
where: { id: teamId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function addTeamMember(teamId: string, userId: string, role: TeamRole = 'MEMBER') {
|
||||
// Check if member already exists
|
||||
const existing = await prisma.teamMember.findUnique({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new Error('Cet utilisateur est déjà membre de cette équipe');
|
||||
}
|
||||
|
||||
return prisma.teamMember.create({
|
||||
data: {
|
||||
teamId,
|
||||
userId,
|
||||
role,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTeamMember(teamId: string, userId: string) {
|
||||
return prisma.teamMember.delete({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateMemberRole(teamId: string, userId: string, role: TeamRole) {
|
||||
return prisma.teamMember.update({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
data: { role },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function isTeamAdmin(teamId: string, userId: string): Promise<boolean> {
|
||||
const member = await prisma.teamMember.findUnique({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
return member?.role === 'ADMIN';
|
||||
}
|
||||
|
||||
export async function isTeamMember(teamId: string, userId: string): Promise<boolean> {
|
||||
const member = await prisma.teamMember.findUnique({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!member;
|
||||
}
|
||||
|
||||
export async function getTeamMember(teamId: string, userId: string) {
|
||||
return prisma.teamMember.findUnique({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
okrs: {
|
||||
include: {
|
||||
keyResults: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTeamMemberById(teamMemberId: string) {
|
||||
return prisma.teamMember.findUnique({
|
||||
where: { id: teamMemberId },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user