first init
This commit is contained in:
+36
@@ -0,0 +1,36 @@
|
||||
import { BoardCreationResult, BoardPayload, Role } from '../types/index.js';
|
||||
export declare class BoardService {
|
||||
/**
|
||||
* Create a new Kanban board with admin and member keys and standard agile columns with WIP limits.
|
||||
*/
|
||||
static createBoard(title?: string): Promise<BoardCreationResult>;
|
||||
/**
|
||||
* Authenticate a key against a board ID. Returns the role ('admin' | 'member') or null if invalid.
|
||||
*/
|
||||
static authenticateKey(boardId: string, key: string): Promise<{
|
||||
role: Role;
|
||||
board: any;
|
||||
} | null>;
|
||||
/**
|
||||
* Fetch full board data with columns and tickets ordered properly.
|
||||
*/
|
||||
static getBoardDetails(boardId: string, role: Role): Promise<BoardPayload | null>;
|
||||
/**
|
||||
* Regenerate member key (Key Rotation). Only superadmin can perform this.
|
||||
*/
|
||||
static rotateMemberKey(boardId: string): Promise<{
|
||||
newMemberKey: string;
|
||||
memberUrlFragment: string;
|
||||
}>;
|
||||
/**
|
||||
* Delete a board. Only superadmin can perform this.
|
||||
*/
|
||||
static deleteBoard(boardId: string): Promise<void>;
|
||||
/**
|
||||
* Update board title & settings.
|
||||
*/
|
||||
static updateBoard(boardId: string, updates: {
|
||||
title?: string;
|
||||
autoMoveStale?: boolean;
|
||||
}): Promise<void>;
|
||||
}
|
||||
Vendored
+169
@@ -0,0 +1,169 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
import { generateSecureKey, hashKey, verifyKeyHash } from '../utils/crypto.js';
|
||||
export class BoardService {
|
||||
/**
|
||||
* Create a new Kanban board with admin and member keys and standard agile columns with WIP limits.
|
||||
*/
|
||||
static async createBoard(title = 'Agile Kanban Board') {
|
||||
const adminKey = generateSecureKey(32);
|
||||
const memberKey = generateSecureKey(32);
|
||||
const adminKeyHash = hashKey(adminKey);
|
||||
const memberKeyHash = hashKey(memberKey);
|
||||
const board = await prisma.board.create({
|
||||
data: {
|
||||
title: title.trim() || 'Agile Kanban Board',
|
||||
adminKeyHash,
|
||||
memberKeyHash,
|
||||
autoMoveStale: false,
|
||||
columns: {
|
||||
create: [
|
||||
{ title: '📋 Backlog', order: 0, maxWipLimit: 0, autoStaleHours: 0 },
|
||||
{ title: '📝 To Do', order: 1, maxWipLimit: 0, autoStaleHours: 0 },
|
||||
{ title: '⚡ In Progress', order: 2, maxWipLimit: 3, autoStaleHours: 24 }, // Standard 3-card WIP limit & 24h stale
|
||||
{ title: '🔍 Review', order: 3, maxWipLimit: 4, autoStaleHours: 24 },
|
||||
{ title: '✅ Done', order: 4, maxWipLimit: 0, autoStaleHours: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
id: board.id,
|
||||
title: board.title,
|
||||
adminKey,
|
||||
memberKey,
|
||||
adminUrlFragment: `/b/${board.id}#admin=${adminKey}`,
|
||||
memberUrlFragment: `/b/${board.id}#member=${memberKey}`,
|
||||
createdAt: board.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Authenticate a key against a board ID. Returns the role ('admin' | 'member') or null if invalid.
|
||||
*/
|
||||
static async authenticateKey(boardId, key) {
|
||||
if (!boardId || !key)
|
||||
return null;
|
||||
const board = await prisma.board.findUnique({
|
||||
where: { id: boardId },
|
||||
});
|
||||
if (!board)
|
||||
return null;
|
||||
if (verifyKeyHash(key, board.adminKeyHash)) {
|
||||
return { role: 'admin', board };
|
||||
}
|
||||
if (verifyKeyHash(key, board.memberKeyHash)) {
|
||||
return { role: 'member', board };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Fetch full board data with columns and tickets ordered properly.
|
||||
*/
|
||||
static async getBoardDetails(boardId, role) {
|
||||
const board = await prisma.board.findUnique({
|
||||
where: { id: boardId },
|
||||
include: {
|
||||
columns: {
|
||||
orderBy: { order: 'asc' },
|
||||
include: {
|
||||
tickets: {
|
||||
orderBy: { order: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!board)
|
||||
return null;
|
||||
const columns = board.columns.map((col) => ({
|
||||
id: col.id,
|
||||
boardId: col.boardId,
|
||||
title: col.title,
|
||||
order: col.order,
|
||||
maxWipLimit: col.maxWipLimit || 0,
|
||||
autoStaleHours: col.autoStaleHours || 0,
|
||||
createdAt: col.createdAt.toISOString(),
|
||||
updatedAt: col.updatedAt.toISOString(),
|
||||
tickets: col.tickets.map((t) => {
|
||||
let parsedTags = [];
|
||||
let parsedSubtasks = [];
|
||||
try {
|
||||
parsedTags = JSON.parse(t.tags || '[]');
|
||||
}
|
||||
catch {
|
||||
parsedTags = [];
|
||||
}
|
||||
try {
|
||||
parsedSubtasks = JSON.parse(t.subtasks || '[]');
|
||||
}
|
||||
catch {
|
||||
parsedSubtasks = [];
|
||||
}
|
||||
return {
|
||||
id: t.id,
|
||||
columnId: t.columnId,
|
||||
title: t.title,
|
||||
description: t.description,
|
||||
order: t.order,
|
||||
priority: t.priority,
|
||||
tags: parsedTags,
|
||||
subtasks: parsedSubtasks,
|
||||
dueDate: t.dueDate || null,
|
||||
isBlocked: t.isBlocked || false,
|
||||
blockedReason: t.blockedReason || null,
|
||||
assignee: t.assignee,
|
||||
movedAt: t.movedAt ? t.movedAt.toISOString() : t.createdAt.toISOString(),
|
||||
createdAt: t.createdAt.toISOString(),
|
||||
updatedAt: t.updatedAt.toISOString(),
|
||||
};
|
||||
}),
|
||||
}));
|
||||
return {
|
||||
id: board.id,
|
||||
title: board.title,
|
||||
role,
|
||||
autoMoveStale: board.autoMoveStale || false,
|
||||
columns,
|
||||
createdAt: board.createdAt.toISOString(),
|
||||
updatedAt: board.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Regenerate member key (Key Rotation). Only superadmin can perform this.
|
||||
*/
|
||||
static async rotateMemberKey(boardId) {
|
||||
const newMemberKey = generateSecureKey(32);
|
||||
const newMemberKeyHash = hashKey(newMemberKey);
|
||||
await prisma.board.update({
|
||||
where: { id: boardId },
|
||||
data: {
|
||||
memberKeyHash: newMemberKeyHash,
|
||||
},
|
||||
});
|
||||
return {
|
||||
newMemberKey,
|
||||
memberUrlFragment: `/b/${boardId}#member=${newMemberKey}`,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Delete a board. Only superadmin can perform this.
|
||||
*/
|
||||
static async deleteBoard(boardId) {
|
||||
await prisma.board.delete({
|
||||
where: { id: boardId },
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Update board title & settings.
|
||||
*/
|
||||
static async updateBoard(boardId, updates) {
|
||||
const data = {};
|
||||
if (updates.title !== undefined)
|
||||
data.title = updates.title.trim() || 'Agile Kanban Board';
|
||||
if (updates.autoMoveStale !== undefined)
|
||||
data.autoMoveStale = updates.autoMoveStale;
|
||||
await prisma.board.update({
|
||||
where: { id: boardId },
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { Priority, TicketPayload, ColumnPayload, Subtask, ActivityLogPayload } from '../types/index.js';
|
||||
export declare class KanbanService {
|
||||
/**
|
||||
* Create a new column in a board.
|
||||
*/
|
||||
static createColumn(boardId: string, title: string, maxWipLimit?: number, autoStaleHours?: number): Promise<ColumnPayload>;
|
||||
/**
|
||||
* Update a column (title, maxWipLimit, autoStaleHours).
|
||||
*/
|
||||
static updateColumn(columnId: string, updates: {
|
||||
title?: string;
|
||||
maxWipLimit?: number;
|
||||
autoStaleHours?: number;
|
||||
}): Promise<ColumnPayload>;
|
||||
/**
|
||||
* Delete a column and its tickets.
|
||||
*/
|
||||
static deleteColumn(columnId: string): Promise<string>;
|
||||
/**
|
||||
* Create a new ticket in a column and record activity log.
|
||||
*/
|
||||
static createTicket(columnId: string, title: string, description?: string, priority?: Priority, tags?: string[], assignee?: string, dueDate?: string, subtasks?: Subtask[], isBlocked?: boolean, blockedReason?: string, userName?: string): Promise<{
|
||||
ticket: TicketPayload;
|
||||
activity?: ActivityLogPayload;
|
||||
}>;
|
||||
/**
|
||||
* Update ticket fields.
|
||||
*/
|
||||
static updateTicket(ticketId: string, updates: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
priority?: Priority;
|
||||
tags?: string[];
|
||||
subtasks?: Subtask[];
|
||||
dueDate?: string | null;
|
||||
isBlocked?: boolean;
|
||||
blockedReason?: string | null;
|
||||
assignee?: string | null;
|
||||
}, userName?: string): Promise<{
|
||||
ticket: TicketPayload;
|
||||
activity?: ActivityLogPayload;
|
||||
}>;
|
||||
/**
|
||||
* Delete a ticket.
|
||||
*/
|
||||
static deleteTicket(ticketId: string, userName?: string): Promise<{
|
||||
boardId: string;
|
||||
activity?: ActivityLogPayload;
|
||||
}>;
|
||||
/**
|
||||
* 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 moveTicket(ticketId: string, targetColumnId: string, newOrder: number, ticketIdsInTargetColumn: string[], movedBy?: string): Promise<ActivityLogPayload | null>;
|
||||
/**
|
||||
* Get recent activity logs for a board.
|
||||
*/
|
||||
static getBoardActivityLogs(boardId: string, limit?: number): Promise<ActivityLogPayload[]>;
|
||||
/**
|
||||
* Get activity logs for a specific ticket.
|
||||
*/
|
||||
static getTicketActivityLogs(ticketId: string, limit?: number): Promise<ActivityLogPayload[]>;
|
||||
/**
|
||||
* Reorder columns in a board.
|
||||
*/
|
||||
static reorderColumns(boardId: string, columnIdsInOrder: string[]): Promise<void>;
|
||||
/**
|
||||
* Check for stale tickets in active columns and auto-move them to Backlog if enabled.
|
||||
*/
|
||||
static processStaleTickets(boardId: string): Promise<{
|
||||
movedTicketIds: string[];
|
||||
}>;
|
||||
}
|
||||
+520
@@ -0,0 +1,520 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user