feat(api-task): implement task crud with tag filters

This commit is contained in:
2026-04-05 00:01:28 +08:00
parent 62b0514da7
commit 8f6ff38a32
10 changed files with 722 additions and 3 deletions
+390
View File
@@ -0,0 +1,390 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { Prisma, TaskPriority, TaskStatus } from "../../generated/prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CreateTaskDto } from "./dto/create-task.dto";
import { ListTasksQueryDto, TaskSortBy, TaskSortOrder } from "./dto/list-tasks-query.dto";
import { UpdateTaskDto } from "./dto/update-task.dto";
type TaskEntity = Prisma.TaskGetPayload<{
include: {
taskTags: {
include: {
tag: {
select: {
name: true;
};
};
};
};
};
}>;
export type TaskResponse = {
id: string;
title: string;
contentJson: unknown | null;
contentText: string | null;
priority: TaskPriority;
status: TaskStatus;
ddl: string | null;
completedAt: string | null;
version: number;
tags: string[];
createdAt: string;
updatedAt: string;
};
export type ListTasksResponse = {
items: TaskResponse[];
page: number;
pageSize: number;
total: number;
};
@Injectable()
export class TaskService {
constructor(private readonly prismaService: PrismaService) {}
async listTasks(userId: string, query: ListTasksQueryDto): Promise<ListTasksResponse> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = this.buildWhereInput(userId, query);
const orderBy = this.buildOrderByInput(query);
const [items, total] = await Promise.all([
this.prismaService.task.findMany({
where,
orderBy,
skip,
take: pageSize,
include: {
taskTags: {
include: {
tag: {
select: {
name: true
}
}
}
}
}
}),
this.prismaService.task.count({ where })
]);
return {
items: items.map((item) => this.serializeTask(item)),
page,
pageSize,
total
};
}
async getTaskById(userId: string, taskId: string): Promise<TaskResponse> {
const task = await this.prismaService.task.findFirst({
where: {
id: taskId,
userId
},
include: {
taskTags: {
include: {
tag: {
select: {
name: true
}
}
}
}
}
});
if (!task) {
throw new NotFoundException("任务不存在");
}
return this.serializeTask(task);
}
async createTask(userId: string, body: CreateTaskDto): Promise<TaskResponse> {
const tagNames = this.normalizeTagNames(body.tagNames);
const nextStatus = body.status ?? TaskStatus.TODO;
const contentJson =
body.contentJson !== undefined ? (body.contentJson as Prisma.InputJsonValue) : undefined;
const task = await this.prismaService.$transaction(async (tx) => {
const createdTask = await tx.task.create({
data: {
userId,
title: body.title,
contentJson,
contentText: body.contentText ?? null,
priority: body.priority ?? TaskPriority.MEDIUM,
status: nextStatus,
ddl: body.ddl ? new Date(body.ddl) : null,
completedAt: nextStatus === TaskStatus.DONE ? new Date() : null
}
});
await this.replaceTaskTags(tx, userId, createdTask.id, tagNames);
return tx.task.findUniqueOrThrow({
where: { id: createdTask.id },
include: {
taskTags: {
include: {
tag: {
select: {
name: true
}
}
}
}
}
});
});
return this.serializeTask(task);
}
async updateTask(userId: string, taskId: string, body: UpdateTaskDto): Promise<TaskResponse> {
const currentTask = await this.prismaService.task.findFirst({
where: {
id: taskId,
userId
},
select: {
id: true,
status: true
}
});
if (!currentTask) {
throw new NotFoundException("任务不存在");
}
const data: Prisma.TaskUpdateInput = {
version: {
increment: 1
}
};
if (body.title !== undefined) {
data.title = body.title;
}
if (body.contentJson !== undefined) {
data.contentJson = body.contentJson as Prisma.InputJsonValue;
}
if (body.contentText !== undefined) {
data.contentText = body.contentText;
}
if (body.priority !== undefined) {
data.priority = body.priority;
}
if (body.status !== undefined) {
data.status = body.status;
if (body.status === TaskStatus.DONE && currentTask.status !== TaskStatus.DONE) {
data.completedAt = new Date();
} else if (body.status !== TaskStatus.DONE) {
data.completedAt = null;
}
}
if (body.ddl !== undefined) {
data.ddl = body.ddl ? new Date(body.ddl) : null;
}
const shouldReplaceTags = body.tagNames !== undefined;
const nextTagNames = this.normalizeTagNames(body.tagNames);
const task = await this.prismaService.$transaction(async (tx) => {
await tx.task.update({
where: { id: taskId },
data
});
if (shouldReplaceTags) {
await this.replaceTaskTags(tx, userId, taskId, nextTagNames);
}
return tx.task.findUniqueOrThrow({
where: { id: taskId },
include: {
taskTags: {
include: {
tag: {
select: {
name: true
}
}
}
}
}
});
});
return this.serializeTask(task);
}
async deleteTask(userId: string, taskId: string): Promise<{ success: boolean }> {
const deleted = await this.prismaService.task.deleteMany({
where: {
id: taskId,
userId
}
});
if (deleted.count === 0) {
throw new NotFoundException("任务不存在");
}
return { success: true };
}
private buildWhereInput(userId: string, query: ListTasksQueryDto): Prisma.TaskWhereInput {
const where: Prisma.TaskWhereInput = {
userId
};
if (query.status !== undefined) {
where.status = query.status;
}
if (query.priority !== undefined) {
where.priority = query.priority;
}
if (query.tags !== undefined && query.tags.length > 0) {
where.taskTags = {
some: {
tag: {
name: {
in: query.tags
}
}
}
};
}
if (query.keyword !== undefined && query.keyword.length > 0) {
where.OR = [
{
title: {
contains: query.keyword,
mode: "insensitive"
}
},
{
contentText: {
contains: query.keyword,
mode: "insensitive"
}
}
];
}
return where;
}
private buildOrderByInput(query: ListTasksQueryDto): Prisma.TaskOrderByWithRelationInput {
const order: Prisma.SortOrder =
query.sortOrder === TaskSortOrder.ASC ? Prisma.SortOrder.asc : Prisma.SortOrder.desc;
if (query.sortBy === TaskSortBy.CREATED_AT) {
return { createdAt: order };
}
if (query.sortBy === TaskSortBy.DDL) {
return { ddl: order };
}
return { updatedAt: order };
}
private normalizeTagNames(tagNames: string[] | undefined): string[] {
if (!tagNames) {
return [];
}
const result: string[] = [];
const uniqueNames = new Set<string>();
for (const rawTagName of tagNames) {
const normalized = rawTagName.trim();
if (!normalized) {
continue;
}
const uniqueKey = normalized.toLocaleLowerCase();
if (uniqueNames.has(uniqueKey)) {
continue;
}
uniqueNames.add(uniqueKey);
result.push(normalized);
}
return result;
}
private async replaceTaskTags(
tx: Prisma.TransactionClient,
userId: string,
taskId: string,
tagNames: string[]
): Promise<void> {
await tx.taskTag.deleteMany({
where: {
taskId
}
});
if (tagNames.length === 0) {
return;
}
const tags = await Promise.all(
tagNames.map((name) =>
tx.tag.upsert({
where: {
userId_name: {
userId,
name
}
},
update: {},
create: {
userId,
name
}
})
)
);
await tx.taskTag.createMany({
data: tags.map((tag) => ({
taskId,
tagId: tag.id
})),
skipDuplicates: true
});
}
private serializeTask(task: TaskEntity): TaskResponse {
return {
id: task.id,
title: task.title,
contentJson: task.contentJson,
contentText: task.contentText,
priority: task.priority,
status: task.status,
ddl: task.ddl?.toISOString() ?? null,
completedAt: task.completedAt?.toISOString() ?? null,
version: task.version,
tags: task.taskTags.map((taskTag) => taskTag.tag.name),
createdAt: task.createdAt.toISOString(),
updatedAt: task.updatedAt.toISOString()
};
}
}