Files
Power BI Dev 88c7c653c2 first init
2026-08-17 10:52:09 +07:00

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