Files
stack/apps/api/src/tasks/tasks.service.ts
Jason Woltje c221b63d14
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
fix: Resolve CI typecheck failures and improve type safety
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>
2026-01-30 20:39:03 -06:00

331 lines
8.7 KiB
TypeScript

import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityService } from "../activity/activity.service";
import { TaskStatus, TaskPriority } from "@prisma/client";
import type { CreateTaskDto, UpdateTaskDto, QueryTasksDto } from "./dto";
/**
* Service for managing tasks
*/
@Injectable()
export class TasksService {
constructor(
private readonly prisma: PrismaService,
private readonly activityService: ActivityService
) {}
/**
* Create a new task
*/
async create(workspaceId: string, userId: string, createTaskDto: CreateTaskDto) {
const assigneeConnection = createTaskDto.assigneeId
? { connect: { id: createTaskDto.assigneeId } }
: undefined;
const projectConnection = createTaskDto.projectId
? { connect: { id: createTaskDto.projectId } }
: undefined;
const parentConnection = createTaskDto.parentId
? { connect: { id: createTaskDto.parentId } }
: undefined;
const data: Prisma.TaskCreateInput = {
title: createTaskDto.title,
description: createTaskDto.description ?? null,
dueDate: createTaskDto.dueDate ?? null,
workspace: { connect: { id: workspaceId } },
creator: { connect: { id: userId } },
status: createTaskDto.status ?? TaskStatus.NOT_STARTED,
priority: createTaskDto.priority ?? TaskPriority.MEDIUM,
sortOrder: createTaskDto.sortOrder ?? 0,
metadata: createTaskDto.metadata
? (createTaskDto.metadata as unknown as Prisma.InputJsonValue)
: {},
...(assigneeConnection && { assignee: assigneeConnection }),
...(projectConnection && { project: projectConnection }),
...(parentConnection && { parent: parentConnection }),
};
// Set completedAt if status is COMPLETED
if (data.status === TaskStatus.COMPLETED) {
data.completedAt = new Date();
}
const task = await this.prisma.task.create({
data,
include: {
assignee: {
select: { id: true, name: true, email: true },
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
});
// Log activity
await this.activityService.logTaskCreated(workspaceId, userId, task.id, {
title: task.title,
});
return task;
}
/**
* Get paginated tasks with filters
*/
async findAll(query: QueryTasksDto) {
const page = query.page ?? 1;
const limit = query.limit ?? 50;
const skip = (page - 1) * limit;
// Build where clause
const where: Prisma.TaskWhereInput = query.workspaceId
? {
workspaceId: query.workspaceId,
}
: {};
if (query.status) {
where.status = Array.isArray(query.status) ? { in: query.status } : query.status;
}
if (query.priority) {
where.priority = Array.isArray(query.priority) ? { in: query.priority } : query.priority;
}
if (query.assigneeId) {
where.assigneeId = query.assigneeId;
}
if (query.projectId) {
where.projectId = query.projectId;
}
if (query.parentId) {
where.parentId = query.parentId;
}
if (query.dueDateFrom || query.dueDateTo) {
where.dueDate = {};
if (query.dueDateFrom) {
where.dueDate.gte = query.dueDateFrom;
}
if (query.dueDateTo) {
where.dueDate.lte = query.dueDateTo;
}
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.task.findMany({
where,
include: {
assignee: {
select: { id: true, name: true, email: true },
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
this.prisma.task.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get a single task by ID
*/
async findOne(id: string, workspaceId: string) {
const task = await this.prisma.task.findUnique({
where: {
id,
workspaceId,
},
include: {
assignee: {
select: { id: true, name: true, email: true },
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
subtasks: {
include: {
assignee: {
select: { id: true, name: true, email: true },
},
},
},
},
});
if (!task) {
throw new NotFoundException(`Task with ID ${id} not found`);
}
return task;
}
/**
* Update a task
*/
async update(id: string, workspaceId: string, userId: string, updateTaskDto: UpdateTaskDto) {
// Verify task exists
const existingTask = await this.prisma.task.findUnique({
where: { id, workspaceId },
});
if (!existingTask) {
throw new NotFoundException(`Task with ID ${id} not found`);
}
// Build update data - only include defined fields
const data: Prisma.TaskUpdateInput = {};
if (updateTaskDto.title !== undefined) {
data.title = updateTaskDto.title;
}
if (updateTaskDto.description !== undefined) {
data.description = updateTaskDto.description;
}
if (updateTaskDto.status !== undefined) {
data.status = updateTaskDto.status;
}
if (updateTaskDto.priority !== undefined) {
data.priority = updateTaskDto.priority;
}
if (updateTaskDto.dueDate !== undefined) {
data.dueDate = updateTaskDto.dueDate;
}
if (updateTaskDto.sortOrder !== undefined) {
data.sortOrder = updateTaskDto.sortOrder;
}
if (updateTaskDto.metadata !== undefined) {
data.metadata = updateTaskDto.metadata as unknown as Prisma.InputJsonValue;
}
if (updateTaskDto.assigneeId !== undefined && updateTaskDto.assigneeId !== null) {
data.assignee = { connect: { id: updateTaskDto.assigneeId } };
}
if (updateTaskDto.projectId !== undefined && updateTaskDto.projectId !== null) {
data.project = { connect: { id: updateTaskDto.projectId } };
}
if (updateTaskDto.parentId !== undefined && updateTaskDto.parentId !== null) {
data.parent = { connect: { id: updateTaskDto.parentId } };
}
// Handle completedAt based on status changes
if (updateTaskDto.status) {
if (
updateTaskDto.status === TaskStatus.COMPLETED &&
existingTask.status !== TaskStatus.COMPLETED
) {
data.completedAt = new Date();
} else if (
updateTaskDto.status !== TaskStatus.COMPLETED &&
existingTask.status === TaskStatus.COMPLETED
) {
data.completedAt = null;
}
}
const task = await this.prisma.task.update({
where: {
id,
workspaceId,
},
data,
include: {
assignee: {
select: { id: true, name: true, email: true },
},
creator: {
select: { id: true, name: true, email: true },
},
project: {
select: { id: true, name: true, color: true },
},
},
});
// Log activities
await this.activityService.logTaskUpdated(workspaceId, userId, id, {
changes: updateTaskDto as Prisma.JsonValue,
});
// Log completion if status changed to COMPLETED
if (
updateTaskDto.status === TaskStatus.COMPLETED &&
existingTask.status !== TaskStatus.COMPLETED
) {
await this.activityService.logTaskCompleted(workspaceId, userId, id);
}
// Log assignment if assigneeId changed
if (
updateTaskDto.assigneeId !== undefined &&
updateTaskDto.assigneeId !== existingTask.assigneeId
) {
await this.activityService.logTaskAssigned(
workspaceId,
userId,
id,
updateTaskDto.assigneeId ?? ""
);
}
return task;
}
/**
* Delete a task
*/
async remove(id: string, workspaceId: string, userId: string) {
// Verify task exists
const task = await this.prisma.task.findUnique({
where: { id, workspaceId },
});
if (!task) {
throw new NotFoundException(`Task with ID ${id} not found`);
}
await this.prisma.task.delete({
where: {
id,
workspaceId,
},
});
// Log activity
await this.activityService.logTaskDeleted(workspaceId, userId, id, {
title: task.title,
});
}
}