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
+5 -1
View File
@@ -1,6 +1,8 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AuthModule } from "./auth/auth.module";
import { PrismaModule } from "./prisma/prisma.module";
import { TaskModule } from "./task/task.module";
@Module({
imports: [
@@ -8,7 +10,9 @@ import { AuthModule } from "./auth/auth.module";
isGlobal: true,
envFilePath: ".env"
}),
AuthModule
PrismaModule,
AuthModule,
TaskModule
]
})
export class AppModule {}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from "@nestjs/common";
import { PrismaService } from "./prisma.service";
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}
+13
View File
@@ -0,0 +1,13 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "../../generated/prisma/client";
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
async onModuleInit(): Promise<void> {
await this.$connect();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
}
}
+64
View File
@@ -0,0 +1,64 @@
import { Transform } from "class-transformer";
import {
IsArray,
IsDateString,
IsEnum,
IsObject,
IsOptional,
IsString,
MaxLength,
MinLength
} from "class-validator";
import { TaskPriority, TaskStatus } from "../../../generated/prisma/client";
function normalizeString(value: unknown): unknown {
if (typeof value !== "string") {
return value;
}
return value.trim();
}
export class CreateTaskDto {
@Transform(({ value }) => normalizeString(value))
@IsString()
@MinLength(1)
@MaxLength(120)
title!: string;
@IsOptional()
@IsObject()
contentJson?: Record<string, unknown>;
@Transform(({ value }) => normalizeString(value))
@IsOptional()
@IsString()
@MaxLength(20000)
contentText?: string;
@IsOptional()
@IsEnum(TaskPriority)
priority?: TaskPriority;
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@IsOptional()
@IsDateString()
ddl?: string;
@Transform(({ value }) => {
if (!Array.isArray(value)) {
return value;
}
return value.map((item) => normalizeString(item));
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@MinLength(1, { each: true })
@MaxLength(30, { each: true })
tagNames?: string[];
}
@@ -0,0 +1,92 @@
import { Transform, Type } from "class-transformer";
import { IsArray, IsEnum, IsInt, IsOptional, IsString, Max, MaxLength, Min } from "class-validator";
import { TaskPriority, TaskStatus } from "../../../generated/prisma/client";
export enum TaskSortBy {
CREATED_AT = "createdAt",
UPDATED_AT = "updatedAt",
DDL = "ddl"
}
export enum TaskSortOrder {
ASC = "asc",
DESC = "desc"
}
function normalizeString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim();
if (!normalized) {
return undefined;
}
return normalized;
}
export class ListTasksQueryDto {
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@IsOptional()
@IsEnum(TaskPriority)
priority?: TaskPriority;
@Transform(({ value }) => {
if (value === undefined || value === null || value === "") {
return undefined;
}
if (Array.isArray(value)) {
const normalized = value
.map((item) => normalizeString(item))
.filter((item): item is string => item !== undefined);
return normalized.length > 0 ? normalized : undefined;
}
if (typeof value === "string") {
const normalized = value
.split(",")
.map((item) => normalizeString(item))
.filter((item): item is string => item !== undefined);
return normalized.length > 0 ? normalized : undefined;
}
return undefined;
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@MaxLength(30, { each: true })
tags?: string[];
@Transform(({ value }) => normalizeString(value))
@IsOptional()
@IsString()
@MaxLength(120)
keyword?: string;
@Type(() => Number)
@IsOptional()
@IsInt()
@Min(1)
page?: number;
@Type(() => Number)
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
pageSize?: number;
@IsOptional()
@IsEnum(TaskSortBy)
sortBy?: TaskSortBy;
@IsOptional()
@IsEnum(TaskSortOrder)
sortOrder?: TaskSortOrder;
}
+65
View File
@@ -0,0 +1,65 @@
import { Transform } from "class-transformer";
import {
IsArray,
IsDateString,
IsEnum,
IsObject,
IsOptional,
IsString,
MaxLength,
MinLength
} from "class-validator";
import { TaskPriority, TaskStatus } from "../../../generated/prisma/client";
function normalizeString(value: unknown): unknown {
if (typeof value !== "string") {
return value;
}
return value.trim();
}
export class UpdateTaskDto {
@Transform(({ value }) => normalizeString(value))
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(120)
title?: string;
@IsOptional()
@IsObject()
contentJson?: Record<string, unknown>;
@Transform(({ value }) => normalizeString(value))
@IsOptional()
@IsString()
@MaxLength(20000)
contentText?: string;
@IsOptional()
@IsEnum(TaskPriority)
priority?: TaskPriority;
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
@IsOptional()
@IsDateString()
ddl?: string;
@Transform(({ value }) => {
if (!Array.isArray(value)) {
return value;
}
return value.map((item) => normalizeString(item));
})
@IsOptional()
@IsArray()
@IsString({ each: true })
@MinLength(1, { each: true })
@MaxLength(30, { each: true })
tagNames?: string[];
}
+71
View File
@@ -0,0 +1,71 @@
import {
Body,
Controller,
Delete,
Get,
Headers,
Param,
Patch,
Post,
Query,
UnauthorizedException
} from "@nestjs/common";
import { CreateTaskDto } from "./dto/create-task.dto";
import { ListTasksQueryDto } from "./dto/list-tasks-query.dto";
import { UpdateTaskDto } from "./dto/update-task.dto";
import { ListTasksResponse, TaskResponse, TaskService } from "./task.service";
@Controller("tasks")
export class TaskController {
constructor(private readonly taskService: TaskService) {}
@Get()
async listTasks(
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
@Query() query: ListTasksQueryDto
): Promise<ListTasksResponse> {
return this.taskService.listTasks(this.resolveUserId(userIdHeader), query);
}
@Get(":taskId")
async getTaskById(
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
@Param("taskId") taskId: string
): Promise<TaskResponse> {
return this.taskService.getTaskById(this.resolveUserId(userIdHeader), taskId);
}
@Post()
async createTask(
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
@Body() body: CreateTaskDto
): Promise<TaskResponse> {
return this.taskService.createTask(this.resolveUserId(userIdHeader), body);
}
@Patch(":taskId")
async updateTask(
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
@Param("taskId") taskId: string,
@Body() body: UpdateTaskDto
): Promise<TaskResponse> {
return this.taskService.updateTask(this.resolveUserId(userIdHeader), taskId, body);
}
@Delete(":taskId")
async deleteTask(
@Headers("x-user-id") userIdHeader: string | string[] | undefined,
@Param("taskId") taskId: string
): Promise<{ success: boolean }> {
return this.taskService.deleteTask(this.resolveUserId(userIdHeader), taskId);
}
private resolveUserId(userIdHeader: string | string[] | undefined): string {
const userId = Array.isArray(userIdHeader) ? userIdHeader[0] : userIdHeader;
if (!userId) {
throw new UnauthorizedException("缺少用户上下文");
}
return userId;
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module";
import { TaskController } from "./task.controller";
import { TaskService } from "./task.service";
@Module({
imports: [PrismaModule],
controllers: [TaskController],
providers: [TaskService]
})
export class TaskModule {}
+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()
};
}
}