first init

This commit is contained in:
Power BI Dev
2026-08-17 10:52:09 +07:00
commit 88c7c653c2
9281 changed files with 2156575 additions and 0 deletions
+36
View File
@@ -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>;
}
+169
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
}