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