521 lines
19 KiB
JavaScript
521 lines
19 KiB
JavaScript
import { prisma } from '../prisma.js';
|
|
export class KanbanService {
|
|
/**
|
|
* Create a new column in a board.
|
|
*/
|
|
static async createColumn(boardId, title, maxWipLimit = 0, autoStaleHours = 0) {
|
|
const lastCol = await prisma.column.findFirst({
|
|
where: { boardId },
|
|
orderBy: { order: 'desc' },
|
|
});
|
|
const nextOrder = lastCol ? lastCol.order + 1 : 0;
|
|
const col = await prisma.column.create({
|
|
data: {
|
|
boardId,
|
|
title: title.trim() || 'New Column',
|
|
order: nextOrder,
|
|
maxWipLimit: Math.max(0, maxWipLimit),
|
|
autoStaleHours: Math.max(0, autoStaleHours),
|
|
},
|
|
});
|
|
// Touch board updatedAt
|
|
await prisma.board.update({
|
|
where: { id: boardId },
|
|
data: { updatedAt: new Date() },
|
|
});
|
|
return {
|
|
id: col.id,
|
|
boardId: col.boardId,
|
|
title: col.title,
|
|
order: col.order,
|
|
maxWipLimit: col.maxWipLimit,
|
|
autoStaleHours: col.autoStaleHours,
|
|
tickets: [],
|
|
createdAt: col.createdAt.toISOString(),
|
|
updatedAt: col.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
/**
|
|
* Update a column (title, maxWipLimit, autoStaleHours).
|
|
*/
|
|
static async updateColumn(columnId, updates) {
|
|
const data = {};
|
|
if (updates.title !== undefined)
|
|
data.title = updates.title.trim();
|
|
if (updates.maxWipLimit !== undefined)
|
|
data.maxWipLimit = Math.max(0, updates.maxWipLimit);
|
|
if (updates.autoStaleHours !== undefined)
|
|
data.autoStaleHours = Math.max(0, updates.autoStaleHours);
|
|
const col = await prisma.column.update({
|
|
where: { id: columnId },
|
|
data,
|
|
include: {
|
|
tickets: {
|
|
orderBy: { order: 'asc' },
|
|
},
|
|
},
|
|
});
|
|
await prisma.board.update({
|
|
where: { id: col.boardId },
|
|
data: { updatedAt: new Date() },
|
|
});
|
|
return {
|
|
id: col.id,
|
|
boardId: col.boardId,
|
|
title: col.title,
|
|
order: col.order,
|
|
maxWipLimit: col.maxWipLimit,
|
|
autoStaleHours: col.autoStaleHours,
|
|
tickets: col.tickets.map((t) => ({
|
|
id: t.id,
|
|
columnId: t.columnId,
|
|
title: t.title,
|
|
description: t.description,
|
|
order: t.order,
|
|
priority: t.priority,
|
|
tags: JSON.parse(t.tags || '[]'),
|
|
subtasks: JSON.parse(t.subtasks || '[]'),
|
|
dueDate: t.dueDate,
|
|
isBlocked: t.isBlocked,
|
|
blockedReason: t.blockedReason,
|
|
assignee: t.assignee,
|
|
movedAt: t.movedAt.toISOString(),
|
|
createdAt: t.createdAt.toISOString(),
|
|
updatedAt: t.updatedAt.toISOString(),
|
|
})),
|
|
createdAt: col.createdAt.toISOString(),
|
|
updatedAt: col.updatedAt.toISOString(),
|
|
};
|
|
}
|
|
/**
|
|
* Delete a column and its tickets.
|
|
*/
|
|
static async deleteColumn(columnId) {
|
|
const col = await prisma.column.delete({
|
|
where: { id: columnId },
|
|
});
|
|
await prisma.board.update({
|
|
where: { id: col.boardId },
|
|
data: { updatedAt: new Date() },
|
|
});
|
|
return col.boardId;
|
|
}
|
|
/**
|
|
* Create a new ticket in a column and record activity log.
|
|
*/
|
|
static async createTicket(columnId, title, description = '', priority = 'MEDIUM', tags = [], assignee = '', dueDate = '', subtasks = [], isBlocked = false, blockedReason = '', userName) {
|
|
const lastTicket = await prisma.ticket.findFirst({
|
|
where: { columnId },
|
|
orderBy: { order: 'desc' },
|
|
});
|
|
const nextOrder = lastTicket ? lastTicket.order + 1 : 0;
|
|
const ticket = await prisma.ticket.create({
|
|
data: {
|
|
columnId,
|
|
title: title.trim() || 'New Task',
|
|
description: description || '',
|
|
order: nextOrder,
|
|
priority,
|
|
tags: JSON.stringify(tags),
|
|
subtasks: JSON.stringify(subtasks),
|
|
dueDate: dueDate || null,
|
|
isBlocked: isBlocked || false,
|
|
blockedReason: blockedReason || null,
|
|
assignee: assignee || null,
|
|
movedAt: new Date(),
|
|
},
|
|
include: {
|
|
column: true,
|
|
},
|
|
});
|
|
// Create activity log in DB
|
|
let activityPayload;
|
|
try {
|
|
const act = await prisma.activityLog.create({
|
|
data: {
|
|
boardId: ticket.column.boardId,
|
|
ticketId: ticket.id,
|
|
ticketTitle: ticket.title,
|
|
action: 'CREATED',
|
|
toColumnTitle: ticket.column.title,
|
|
userName: userName || 'User',
|
|
details: `Tugas "${ticket.title}" dibuat di kolom "${ticket.column.title}"`,
|
|
},
|
|
});
|
|
activityPayload = {
|
|
id: act.id,
|
|
boardId: act.boardId,
|
|
ticketId: act.ticketId,
|
|
ticketTitle: act.ticketTitle,
|
|
action: act.action,
|
|
fromColumnTitle: act.fromColumnTitle,
|
|
toColumnTitle: act.toColumnTitle,
|
|
userName: act.userName,
|
|
details: act.details,
|
|
createdAt: act.createdAt.toISOString(),
|
|
};
|
|
}
|
|
catch (err) {
|
|
console.error('Failed to log ticket creation activity:', err);
|
|
}
|
|
// Touch board updatedAt
|
|
await prisma.board.update({
|
|
where: { id: ticket.column.boardId },
|
|
data: { updatedAt: new Date() },
|
|
});
|
|
return {
|
|
ticket: {
|
|
id: ticket.id,
|
|
columnId: ticket.columnId,
|
|
title: ticket.title,
|
|
description: ticket.description,
|
|
order: ticket.order,
|
|
priority: ticket.priority,
|
|
tags,
|
|
subtasks,
|
|
dueDate: ticket.dueDate,
|
|
isBlocked: ticket.isBlocked,
|
|
blockedReason: ticket.blockedReason,
|
|
assignee: ticket.assignee,
|
|
movedAt: ticket.movedAt.toISOString(),
|
|
createdAt: ticket.createdAt.toISOString(),
|
|
updatedAt: ticket.updatedAt.toISOString(),
|
|
},
|
|
activity: activityPayload,
|
|
};
|
|
}
|
|
/**
|
|
* Update ticket fields.
|
|
*/
|
|
static async updateTicket(ticketId, updates, userName) {
|
|
const data = {};
|
|
if (updates.title !== undefined)
|
|
data.title = updates.title;
|
|
if (updates.description !== undefined)
|
|
data.description = updates.description;
|
|
if (updates.priority !== undefined)
|
|
data.priority = updates.priority;
|
|
if (updates.tags !== undefined)
|
|
data.tags = JSON.stringify(updates.tags);
|
|
if (updates.subtasks !== undefined)
|
|
data.subtasks = JSON.stringify(updates.subtasks);
|
|
if (updates.dueDate !== undefined)
|
|
data.dueDate = updates.dueDate;
|
|
if (updates.isBlocked !== undefined)
|
|
data.isBlocked = updates.isBlocked;
|
|
if (updates.blockedReason !== undefined)
|
|
data.blockedReason = updates.blockedReason;
|
|
if (updates.assignee !== undefined)
|
|
data.assignee = updates.assignee;
|
|
const existingTicket = await prisma.ticket.findUnique({
|
|
where: { id: ticketId },
|
|
include: { column: true },
|
|
});
|
|
if (!existingTicket)
|
|
throw new Error('Ticket not found');
|
|
const ticket = await prisma.ticket.update({
|
|
where: { id: ticketId },
|
|
data,
|
|
include: { column: true },
|
|
});
|
|
// Touch board updatedAt
|
|
await prisma.board.update({
|
|
where: { id: ticket.column.boardId },
|
|
data: { updatedAt: new Date() },
|
|
});
|
|
let activityPayload;
|
|
if (updates.isBlocked !== undefined && updates.isBlocked !== existingTicket.isBlocked) {
|
|
try {
|
|
const act = await prisma.activityLog.create({
|
|
data: {
|
|
boardId: ticket.column.boardId,
|
|
ticketId: ticket.id,
|
|
ticketTitle: ticket.title,
|
|
action: updates.isBlocked ? 'BLOCKED' : 'UNBLOCKED',
|
|
toColumnTitle: ticket.column.title,
|
|
userName: userName || 'User',
|
|
details: updates.isBlocked
|
|
? `Status tugas ditandai MACET / BLOCKED (${updates.blockedReason || 'Tanpa keterangan'})`
|
|
: 'Status macet diatasi (Unblocked)',
|
|
},
|
|
});
|
|
activityPayload = {
|
|
id: act.id,
|
|
boardId: act.boardId,
|
|
ticketId: act.ticketId,
|
|
ticketTitle: act.ticketTitle,
|
|
action: act.action,
|
|
fromColumnTitle: act.fromColumnTitle,
|
|
toColumnTitle: act.toColumnTitle,
|
|
userName: act.userName,
|
|
details: act.details,
|
|
createdAt: act.createdAt.toISOString(),
|
|
};
|
|
}
|
|
catch (err) {
|
|
console.error('Failed to log blocked status activity:', err);
|
|
}
|
|
}
|
|
let parsedTags = [];
|
|
let parsedSubtasks = [];
|
|
try {
|
|
parsedTags = JSON.parse(ticket.tags || '[]');
|
|
}
|
|
catch {
|
|
parsedTags = [];
|
|
}
|
|
try {
|
|
parsedSubtasks = JSON.parse(ticket.subtasks || '[]');
|
|
}
|
|
catch {
|
|
parsedSubtasks = [];
|
|
}
|
|
return {
|
|
ticket: {
|
|
id: ticket.id,
|
|
columnId: ticket.columnId,
|
|
title: ticket.title,
|
|
description: ticket.description,
|
|
order: ticket.order,
|
|
priority: ticket.priority,
|
|
tags: parsedTags,
|
|
subtasks: parsedSubtasks,
|
|
dueDate: ticket.dueDate,
|
|
isBlocked: ticket.isBlocked,
|
|
blockedReason: ticket.blockedReason,
|
|
assignee: ticket.assignee,
|
|
movedAt: ticket.movedAt.toISOString(),
|
|
createdAt: ticket.createdAt.toISOString(),
|
|
updatedAt: ticket.updatedAt.toISOString(),
|
|
},
|
|
activity: activityPayload,
|
|
};
|
|
}
|
|
/**
|
|
* Delete a ticket.
|
|
*/
|
|
static async deleteTicket(ticketId, userName) {
|
|
const ticket = await prisma.ticket.findUnique({
|
|
where: { id: ticketId },
|
|
include: { column: true },
|
|
});
|
|
if (!ticket)
|
|
throw new Error('Ticket not found');
|
|
const boardId = ticket.column.boardId;
|
|
let activityPayload;
|
|
try {
|
|
const act = await prisma.activityLog.create({
|
|
data: {
|
|
boardId,
|
|
ticketId: ticket.id,
|
|
ticketTitle: ticket.title,
|
|
action: 'DELETED',
|
|
fromColumnTitle: ticket.column.title,
|
|
userName: userName || 'User',
|
|
details: `Tugas "${ticket.title}" dihapus dari kolom "${ticket.column.title}"`,
|
|
},
|
|
});
|
|
activityPayload = {
|
|
id: act.id,
|
|
boardId: act.boardId,
|
|
ticketId: act.ticketId,
|
|
ticketTitle: act.ticketTitle,
|
|
action: act.action,
|
|
fromColumnTitle: act.fromColumnTitle,
|
|
toColumnTitle: act.toColumnTitle,
|
|
userName: act.userName,
|
|
details: act.details,
|
|
createdAt: act.createdAt.toISOString(),
|
|
};
|
|
}
|
|
catch (err) {
|
|
console.error('Failed to log delete activity:', err);
|
|
}
|
|
await prisma.ticket.delete({
|
|
where: { id: ticketId },
|
|
});
|
|
await prisma.board.update({
|
|
where: { id: boardId },
|
|
data: { updatedAt: new Date() },
|
|
});
|
|
return { boardId, activity: activityPayload };
|
|
}
|
|
/**
|
|
* Move ticket: changes column and/or reorders within a column.
|
|
* Updates orders in batch transaction, resets movedAt timestamp, and records movement log in DB.
|
|
*/
|
|
static async moveTicket(ticketId, targetColumnId, newOrder, ticketIdsInTargetColumn, movedBy) {
|
|
const ticket = await prisma.ticket.findUnique({
|
|
where: { id: ticketId },
|
|
include: { column: true },
|
|
});
|
|
if (!ticket)
|
|
throw new Error('Ticket not found');
|
|
const sourceBoardId = ticket.column.boardId;
|
|
const isChangingColumn = ticket.columnId !== targetColumnId;
|
|
let targetColumn = ticket.column;
|
|
if (isChangingColumn) {
|
|
const foundTarget = await prisma.column.findUnique({
|
|
where: { id: targetColumnId },
|
|
});
|
|
if (foundTarget)
|
|
targetColumn = foundTarget;
|
|
}
|
|
let createdActivity = null;
|
|
await prisma.$transaction(async (tx) => {
|
|
// 1. Move target ticket to new column and update movedAt timestamp if changing column
|
|
await tx.ticket.update({
|
|
where: { id: ticketId },
|
|
data: {
|
|
columnId: targetColumnId,
|
|
order: newOrder,
|
|
movedAt: isChangingColumn ? new Date() : ticket.movedAt,
|
|
},
|
|
});
|
|
// 2. Re-assign sequential orders to all tickets in target column
|
|
if (ticketIdsInTargetColumn && ticketIdsInTargetColumn.length > 0) {
|
|
for (let i = 0; i < ticketIdsInTargetColumn.length; i++) {
|
|
const id = ticketIdsInTargetColumn[i];
|
|
await tx.ticket.update({
|
|
where: { id },
|
|
data: {
|
|
columnId: targetColumnId,
|
|
order: i,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
// 3. Save Movement Log to DB if column changed
|
|
if (isChangingColumn) {
|
|
const act = await tx.activityLog.create({
|
|
data: {
|
|
boardId: sourceBoardId,
|
|
ticketId: ticket.id,
|
|
ticketTitle: ticket.title,
|
|
action: 'MOVED',
|
|
fromColumnTitle: ticket.column.title,
|
|
toColumnTitle: targetColumn.title,
|
|
userName: movedBy || 'User',
|
|
details: `Dipindahkan dari "${ticket.column.title}" ke "${targetColumn.title}"`,
|
|
},
|
|
});
|
|
createdActivity = {
|
|
id: act.id,
|
|
boardId: act.boardId,
|
|
ticketId: act.ticketId,
|
|
ticketTitle: act.ticketTitle,
|
|
action: act.action,
|
|
fromColumnTitle: act.fromColumnTitle,
|
|
toColumnTitle: act.toColumnTitle,
|
|
userName: act.userName,
|
|
details: act.details,
|
|
createdAt: act.createdAt.toISOString(),
|
|
};
|
|
}
|
|
// Touch board
|
|
await tx.board.update({
|
|
where: { id: sourceBoardId },
|
|
data: { updatedAt: new Date() },
|
|
});
|
|
});
|
|
return createdActivity;
|
|
}
|
|
/**
|
|
* Get recent activity logs for a board.
|
|
*/
|
|
static async getBoardActivityLogs(boardId, limit = 50) {
|
|
const logs = await prisma.activityLog.findMany({
|
|
where: { boardId },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: Math.min(100, Math.max(1, limit)),
|
|
});
|
|
return logs.map((log) => ({
|
|
id: log.id,
|
|
boardId: log.boardId,
|
|
ticketId: log.ticketId,
|
|
ticketTitle: log.ticketTitle,
|
|
action: log.action,
|
|
fromColumnTitle: log.fromColumnTitle,
|
|
toColumnTitle: log.toColumnTitle,
|
|
userName: log.userName,
|
|
details: log.details,
|
|
createdAt: log.createdAt.toISOString(),
|
|
}));
|
|
}
|
|
/**
|
|
* Get activity logs for a specific ticket.
|
|
*/
|
|
static async getTicketActivityLogs(ticketId, limit = 30) {
|
|
const logs = await prisma.activityLog.findMany({
|
|
where: { ticketId },
|
|
orderBy: { createdAt: 'desc' },
|
|
take: Math.min(50, Math.max(1, limit)),
|
|
});
|
|
return logs.map((log) => ({
|
|
id: log.id,
|
|
boardId: log.boardId,
|
|
ticketId: log.ticketId,
|
|
ticketTitle: log.ticketTitle,
|
|
action: log.action,
|
|
fromColumnTitle: log.fromColumnTitle,
|
|
toColumnTitle: log.toColumnTitle,
|
|
userName: log.userName,
|
|
details: log.details,
|
|
createdAt: log.createdAt.toISOString(),
|
|
}));
|
|
}
|
|
/**
|
|
* Reorder columns in a board.
|
|
*/
|
|
static async reorderColumns(boardId, columnIdsInOrder) {
|
|
await prisma.$transaction(async (tx) => {
|
|
for (let i = 0; i < columnIdsInOrder.length; i++) {
|
|
const id = columnIdsInOrder[i];
|
|
await tx.column.update({
|
|
where: { id },
|
|
data: { order: i },
|
|
});
|
|
}
|
|
await tx.board.update({
|
|
where: { id: boardId },
|
|
data: { updatedAt: new Date() },
|
|
});
|
|
});
|
|
}
|
|
/**
|
|
* Check for stale tickets in active columns and auto-move them to Backlog if enabled.
|
|
*/
|
|
static async processStaleTickets(boardId) {
|
|
const board = await prisma.board.findUnique({
|
|
where: { id: boardId },
|
|
include: {
|
|
columns: {
|
|
orderBy: { order: 'asc' },
|
|
include: { tickets: true },
|
|
},
|
|
},
|
|
});
|
|
if (!board || board.columns.length < 2)
|
|
return { movedTicketIds: [] };
|
|
const backlogColumn = board.columns[0];
|
|
const movedTicketIds = [];
|
|
const now = Date.now();
|
|
for (let i = 1; i < board.columns.length; i++) {
|
|
const col = board.columns[i];
|
|
if (col.autoStaleHours && col.autoStaleHours > 0) {
|
|
const thresholdMs = col.autoStaleHours * 60 * 60 * 1000;
|
|
for (const ticket of col.tickets) {
|
|
const ticketMovedAt = new Date(ticket.movedAt || ticket.createdAt).getTime();
|
|
const ageMs = now - ticketMovedAt;
|
|
if (ageMs >= thresholdMs && board.autoMoveStale) {
|
|
// Auto move to backlog
|
|
await this.moveTicket(ticket.id, backlogColumn.id, 0, [ticket.id, ...backlogColumn.tickets.map((t) => t.id)], 'Sistem (Auto-Stale)');
|
|
movedTicketIds.push(ticket.id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return { movedTicketIds };
|
|
}
|
|
}
|