Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Fixes CI pipeline failures caused by missing Prisma Client generation and TypeScript type safety issues. Added Prisma generation step to CI pipeline, installed missing type dependencies, and resolved 40+ exactOptionalPropertyTypes violations across service layer. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
599 lines
16 KiB
TypeScript
599 lines
16 KiB
TypeScript
import { Injectable } from "@nestjs/common";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { TaskStatus, TaskPriority, ProjectStatus } from "@prisma/client";
|
|
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
|
|
|
/**
|
|
* Widget data response types
|
|
*/
|
|
export interface WidgetStatData {
|
|
value: number;
|
|
change?: number;
|
|
changePercent?: number;
|
|
previousValue?: number;
|
|
}
|
|
|
|
export interface WidgetChartData {
|
|
labels: string[];
|
|
datasets: {
|
|
label: string;
|
|
data: number[];
|
|
backgroundColor?: string[];
|
|
}[];
|
|
}
|
|
|
|
export interface WidgetListItem {
|
|
id: string;
|
|
title: string;
|
|
subtitle?: string;
|
|
status?: string;
|
|
priority?: string;
|
|
dueDate?: string;
|
|
startTime?: string;
|
|
color?: string;
|
|
}
|
|
|
|
export interface WidgetCalendarItem {
|
|
id: string;
|
|
title: string;
|
|
startTime: string;
|
|
endTime?: string;
|
|
allDay?: boolean;
|
|
type: "task" | "event";
|
|
color?: string;
|
|
}
|
|
|
|
/**
|
|
* Service for fetching widget data from various sources
|
|
*/
|
|
@Injectable()
|
|
export class WidgetDataService {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
/**
|
|
* Get stat card data based on configuration
|
|
*/
|
|
async getStatCardData(workspaceId: string, query: StatCardQueryDto): Promise<WidgetStatData> {
|
|
const { dataSource, metric, filter } = query;
|
|
|
|
switch (dataSource) {
|
|
case "tasks":
|
|
return this.getTaskStatData(workspaceId, metric, filter);
|
|
case "events":
|
|
return this.getEventStatData(workspaceId, metric, filter);
|
|
case "projects":
|
|
return this.getProjectStatData(workspaceId, metric, filter);
|
|
default:
|
|
return { value: 0 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get chart data based on configuration
|
|
*/
|
|
async getChartData(workspaceId: string, query: ChartQueryDto): Promise<WidgetChartData> {
|
|
const { dataSource, groupBy, filter, colors } = query;
|
|
|
|
switch (dataSource) {
|
|
case "tasks":
|
|
return this.getTaskChartData(workspaceId, groupBy, filter, colors);
|
|
case "events":
|
|
return this.getEventChartData(workspaceId, groupBy, filter, colors);
|
|
case "projects":
|
|
return this.getProjectChartData(workspaceId, groupBy, filter, colors);
|
|
default:
|
|
return { labels: [], datasets: [] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get list data based on configuration
|
|
*/
|
|
async getListData(workspaceId: string, query: ListQueryDto): Promise<WidgetListItem[]> {
|
|
const { dataSource, sortBy, sortOrder, limit, filter } = query;
|
|
|
|
switch (dataSource) {
|
|
case "tasks":
|
|
return this.getTaskListData(workspaceId, sortBy, sortOrder, limit, filter);
|
|
case "events":
|
|
return this.getEventListData(workspaceId, sortBy, sortOrder, limit, filter);
|
|
case "projects":
|
|
return this.getProjectListData(workspaceId, sortBy, sortOrder, limit, filter);
|
|
default:
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get calendar preview data
|
|
*/
|
|
async getCalendarPreviewData(
|
|
workspaceId: string,
|
|
query: CalendarPreviewQueryDto
|
|
): Promise<WidgetCalendarItem[]> {
|
|
const { showTasks = true, showEvents = true, daysAhead = 7 } = query;
|
|
const items: WidgetCalendarItem[] = [];
|
|
|
|
const startDate = new Date();
|
|
startDate.setHours(0, 0, 0, 0);
|
|
const endDate = new Date(startDate);
|
|
endDate.setDate(endDate.getDate() + daysAhead);
|
|
|
|
if (showEvents) {
|
|
const events = await this.prisma.event.findMany({
|
|
where: {
|
|
workspaceId,
|
|
startTime: {
|
|
gte: startDate,
|
|
lte: endDate,
|
|
},
|
|
},
|
|
include: {
|
|
project: {
|
|
select: { color: true },
|
|
},
|
|
},
|
|
orderBy: { startTime: "asc" },
|
|
take: 20,
|
|
});
|
|
|
|
items.push(
|
|
...events.map((event) => {
|
|
const item: WidgetCalendarItem = {
|
|
id: event.id,
|
|
title: event.title,
|
|
startTime: event.startTime.toISOString(),
|
|
allDay: event.allDay,
|
|
type: "event" as const,
|
|
color: event.project?.color ?? "#3B82F6",
|
|
};
|
|
if (event.endTime !== null) {
|
|
item.endTime = event.endTime.toISOString();
|
|
}
|
|
return item;
|
|
})
|
|
);
|
|
}
|
|
|
|
if (showTasks) {
|
|
const tasks = await this.prisma.task.findMany({
|
|
where: {
|
|
workspaceId,
|
|
dueDate: {
|
|
gte: startDate,
|
|
lte: endDate,
|
|
},
|
|
status: {
|
|
not: TaskStatus.COMPLETED,
|
|
},
|
|
},
|
|
include: {
|
|
project: {
|
|
select: { color: true },
|
|
},
|
|
},
|
|
orderBy: { dueDate: "asc" },
|
|
take: 20,
|
|
});
|
|
|
|
items.push(
|
|
...tasks
|
|
.filter((task): task is typeof task & { dueDate: Date } => task.dueDate !== null)
|
|
.map((task) => ({
|
|
id: task.id,
|
|
title: task.title,
|
|
startTime: task.dueDate.toISOString(),
|
|
allDay: true,
|
|
type: "task" as const,
|
|
color: task.project?.color ?? "#10B981",
|
|
}))
|
|
);
|
|
}
|
|
|
|
// Sort by start time
|
|
items.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
|
|
|
return items;
|
|
}
|
|
|
|
// Private helper methods
|
|
|
|
private async getTaskStatData(
|
|
workspaceId: string,
|
|
metric: string,
|
|
filter?: Record<string, unknown>
|
|
): Promise<WidgetStatData> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
|
|
switch (metric) {
|
|
case "count": {
|
|
const count = await this.prisma.task.count({ where });
|
|
return { value: count };
|
|
}
|
|
case "completed": {
|
|
const completed = await this.prisma.task.count({
|
|
where: { ...where, status: TaskStatus.COMPLETED },
|
|
});
|
|
return { value: completed };
|
|
}
|
|
case "overdue": {
|
|
const overdue = await this.prisma.task.count({
|
|
where: {
|
|
...where,
|
|
status: { not: TaskStatus.COMPLETED },
|
|
dueDate: { lt: new Date() },
|
|
},
|
|
});
|
|
return { value: overdue };
|
|
}
|
|
case "upcoming": {
|
|
const nextWeek = new Date();
|
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
|
const upcoming = await this.prisma.task.count({
|
|
where: {
|
|
...where,
|
|
status: { not: TaskStatus.COMPLETED },
|
|
dueDate: { gte: new Date(), lte: nextWeek },
|
|
},
|
|
});
|
|
return { value: upcoming };
|
|
}
|
|
default:
|
|
return { value: 0 };
|
|
}
|
|
}
|
|
|
|
private async getEventStatData(
|
|
workspaceId: string,
|
|
metric: string,
|
|
filter?: Record<string, unknown>
|
|
): Promise<WidgetStatData> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
|
|
switch (metric) {
|
|
case "count": {
|
|
const count = await this.prisma.event.count({ where });
|
|
return { value: count };
|
|
}
|
|
case "upcoming": {
|
|
const nextWeek = new Date();
|
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
|
const upcoming = await this.prisma.event.count({
|
|
where: {
|
|
...where,
|
|
startTime: { gte: new Date(), lte: nextWeek },
|
|
},
|
|
});
|
|
return { value: upcoming };
|
|
}
|
|
default:
|
|
return { value: 0 };
|
|
}
|
|
}
|
|
|
|
private async getProjectStatData(
|
|
workspaceId: string,
|
|
metric: string,
|
|
filter?: Record<string, unknown>
|
|
): Promise<WidgetStatData> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
|
|
switch (metric) {
|
|
case "count": {
|
|
const count = await this.prisma.project.count({ where });
|
|
return { value: count };
|
|
}
|
|
case "completed": {
|
|
const completed = await this.prisma.project.count({
|
|
where: { ...where, status: ProjectStatus.COMPLETED },
|
|
});
|
|
return { value: completed };
|
|
}
|
|
default:
|
|
return { value: 0 };
|
|
}
|
|
}
|
|
|
|
private async getTaskChartData(
|
|
workspaceId: string,
|
|
groupBy: string,
|
|
filter?: Record<string, unknown>,
|
|
colors?: string[]
|
|
): Promise<WidgetChartData> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
const defaultColors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
|
|
|
switch (groupBy) {
|
|
case "status": {
|
|
const statusCounts = await this.prisma.task.groupBy({
|
|
by: ["status"],
|
|
where,
|
|
_count: { id: true },
|
|
});
|
|
|
|
const statusLabels = Object.values(TaskStatus);
|
|
const statusData = statusLabels.map((status) => {
|
|
const found = statusCounts.find((s) => s.status === status);
|
|
return found ? found._count.id : 0;
|
|
});
|
|
|
|
return {
|
|
labels: statusLabels.map((s) => s.replace("_", " ")),
|
|
datasets: [
|
|
{
|
|
label: "Tasks by Status",
|
|
data: statusData,
|
|
backgroundColor: colors ?? defaultColors,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
case "priority": {
|
|
const priorityCounts = await this.prisma.task.groupBy({
|
|
by: ["priority"],
|
|
where,
|
|
_count: { id: true },
|
|
});
|
|
|
|
const priorityLabels = Object.values(TaskPriority);
|
|
const priorityData = priorityLabels.map((priority) => {
|
|
const found = priorityCounts.find((p) => p.priority === priority);
|
|
return found ? found._count.id : 0;
|
|
});
|
|
|
|
return {
|
|
labels: priorityLabels,
|
|
datasets: [
|
|
{
|
|
label: "Tasks by Priority",
|
|
data: priorityData,
|
|
backgroundColor: colors ?? ["#EF4444", "#F59E0B", "#3B82F6", "#10B981"],
|
|
},
|
|
],
|
|
};
|
|
}
|
|
case "project": {
|
|
const projectCounts = await this.prisma.task.groupBy({
|
|
by: ["projectId"],
|
|
where: { ...where, projectId: { not: null } },
|
|
_count: { id: true },
|
|
});
|
|
|
|
const projectIds = projectCounts.map((p) => {
|
|
if (p.projectId === null) {
|
|
throw new Error("Unexpected null projectId");
|
|
}
|
|
return p.projectId;
|
|
});
|
|
const projects = await this.prisma.project.findMany({
|
|
where: { id: { in: projectIds } },
|
|
select: { id: true, name: true, color: true },
|
|
});
|
|
|
|
return {
|
|
labels: projects.map((p) => p.name),
|
|
datasets: [
|
|
{
|
|
label: "Tasks by Project",
|
|
data: projectCounts.map((p) => p._count.id),
|
|
backgroundColor: projects.map((p) => p.color ?? "#3B82F6"),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
default:
|
|
return { labels: [], datasets: [] };
|
|
}
|
|
}
|
|
|
|
private async getEventChartData(
|
|
workspaceId: string,
|
|
groupBy: string,
|
|
filter?: Record<string, unknown>,
|
|
colors?: string[]
|
|
): Promise<WidgetChartData> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
const defaultColors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
|
|
|
switch (groupBy) {
|
|
case "project": {
|
|
const projectCounts = await this.prisma.event.groupBy({
|
|
by: ["projectId"],
|
|
where: { ...where, projectId: { not: null } },
|
|
_count: { id: true },
|
|
});
|
|
|
|
const projectIds = projectCounts.map((p) => {
|
|
if (p.projectId === null) {
|
|
throw new Error("Unexpected null projectId");
|
|
}
|
|
return p.projectId;
|
|
});
|
|
const projects = await this.prisma.project.findMany({
|
|
where: { id: { in: projectIds } },
|
|
select: { id: true, name: true, color: true },
|
|
});
|
|
|
|
return {
|
|
labels: projects.map((p) => p.name),
|
|
datasets: [
|
|
{
|
|
label: "Events by Project",
|
|
data: projectCounts.map((p) => p._count.id),
|
|
backgroundColor: projects.map((p) => p.color ?? "#3B82F6"),
|
|
},
|
|
],
|
|
};
|
|
}
|
|
default:
|
|
return {
|
|
labels: [],
|
|
datasets: [{ label: "Events", data: [], backgroundColor: colors ?? defaultColors }],
|
|
};
|
|
}
|
|
}
|
|
|
|
private async getProjectChartData(
|
|
workspaceId: string,
|
|
groupBy: string,
|
|
filter?: Record<string, unknown>,
|
|
colors?: string[]
|
|
): Promise<WidgetChartData> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
const defaultColors = ["#3B82F6", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6"];
|
|
|
|
switch (groupBy) {
|
|
case "status": {
|
|
const statusCounts = await this.prisma.project.groupBy({
|
|
by: ["status"],
|
|
where,
|
|
_count: { id: true },
|
|
});
|
|
|
|
const statusLabels = Object.values(ProjectStatus);
|
|
const statusData = statusLabels.map((status) => {
|
|
const found = statusCounts.find((s) => s.status === status);
|
|
return found ? found._count.id : 0;
|
|
});
|
|
|
|
return {
|
|
labels: statusLabels.map((s) => s.replace("_", " ")),
|
|
datasets: [
|
|
{
|
|
label: "Projects by Status",
|
|
data: statusData,
|
|
backgroundColor: colors ?? defaultColors,
|
|
},
|
|
],
|
|
};
|
|
}
|
|
default:
|
|
return { labels: [], datasets: [] };
|
|
}
|
|
}
|
|
|
|
private async getTaskListData(
|
|
workspaceId: string,
|
|
sortBy?: string,
|
|
sortOrder?: "asc" | "desc",
|
|
limit?: number,
|
|
filter?: Record<string, unknown>
|
|
): Promise<WidgetListItem[]> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
const orderBy: Record<string, "asc" | "desc"> = {};
|
|
|
|
if (sortBy) {
|
|
orderBy[sortBy] = sortOrder ?? "desc";
|
|
} else {
|
|
orderBy.createdAt = "desc";
|
|
}
|
|
|
|
const tasks = await this.prisma.task.findMany({
|
|
where,
|
|
include: {
|
|
project: { select: { name: true, color: true } },
|
|
},
|
|
orderBy,
|
|
take: limit ?? 10,
|
|
});
|
|
|
|
return tasks.map((task) => {
|
|
const item: WidgetListItem = {
|
|
id: task.id,
|
|
title: task.title,
|
|
status: task.status,
|
|
priority: task.priority,
|
|
};
|
|
if (task.project?.name) {
|
|
item.subtitle = task.project.name;
|
|
}
|
|
if (task.dueDate) {
|
|
item.dueDate = task.dueDate.toISOString();
|
|
}
|
|
if (task.project?.color) {
|
|
item.color = task.project.color;
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
|
|
private async getEventListData(
|
|
workspaceId: string,
|
|
sortBy?: string,
|
|
sortOrder?: "asc" | "desc",
|
|
limit?: number,
|
|
filter?: Record<string, unknown>
|
|
): Promise<WidgetListItem[]> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
const orderBy: Record<string, "asc" | "desc"> = {};
|
|
|
|
if (sortBy) {
|
|
orderBy[sortBy] = sortOrder ?? "asc";
|
|
} else {
|
|
orderBy.startTime = "asc";
|
|
}
|
|
|
|
const events = await this.prisma.event.findMany({
|
|
where,
|
|
include: {
|
|
project: { select: { name: true, color: true } },
|
|
},
|
|
orderBy,
|
|
take: limit ?? 10,
|
|
});
|
|
|
|
return events.map((event) => {
|
|
const item: WidgetListItem = {
|
|
id: event.id,
|
|
title: event.title,
|
|
startTime: event.startTime.toISOString(),
|
|
};
|
|
if (event.project?.name) {
|
|
item.subtitle = event.project.name;
|
|
}
|
|
if (event.project?.color) {
|
|
item.color = event.project.color;
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
|
|
private async getProjectListData(
|
|
workspaceId: string,
|
|
sortBy?: string,
|
|
sortOrder?: "asc" | "desc",
|
|
limit?: number,
|
|
filter?: Record<string, unknown>
|
|
): Promise<WidgetListItem[]> {
|
|
const where: Record<string, unknown> = { workspaceId, ...filter };
|
|
const orderBy: Record<string, "asc" | "desc"> = {};
|
|
|
|
if (sortBy) {
|
|
orderBy[sortBy] = sortOrder ?? "desc";
|
|
} else {
|
|
orderBy.createdAt = "desc";
|
|
}
|
|
|
|
const projects = await this.prisma.project.findMany({
|
|
where,
|
|
orderBy,
|
|
take: limit ?? 10,
|
|
});
|
|
|
|
return projects.map((project) => {
|
|
const item: WidgetListItem = {
|
|
id: project.id,
|
|
title: project.name,
|
|
status: project.status,
|
|
};
|
|
if (project.description) {
|
|
item.subtitle = project.description;
|
|
}
|
|
if (project.color) {
|
|
item.color = project.color;
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
}
|