diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8cd6181..bbf571a 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7a94e73 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from "@nestjs/common"; +import { PrismaService } from "./prisma.service"; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService] +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..72e40f6 --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -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 { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/apps/api/src/task/dto/create-task.dto.ts b/apps/api/src/task/dto/create-task.dto.ts new file mode 100644 index 0000000..69c2000 --- /dev/null +++ b/apps/api/src/task/dto/create-task.dto.ts @@ -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; + + @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[]; +} diff --git a/apps/api/src/task/dto/list-tasks-query.dto.ts b/apps/api/src/task/dto/list-tasks-query.dto.ts new file mode 100644 index 0000000..baa5afb --- /dev/null +++ b/apps/api/src/task/dto/list-tasks-query.dto.ts @@ -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; +} diff --git a/apps/api/src/task/dto/update-task.dto.ts b/apps/api/src/task/dto/update-task.dto.ts new file mode 100644 index 0000000..23b8894 --- /dev/null +++ b/apps/api/src/task/dto/update-task.dto.ts @@ -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; + + @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[]; +} diff --git a/apps/api/src/task/task.controller.ts b/apps/api/src/task/task.controller.ts new file mode 100644 index 0000000..33b9710 --- /dev/null +++ b/apps/api/src/task/task.controller.ts @@ -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 { + 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 { + return this.taskService.getTaskById(this.resolveUserId(userIdHeader), taskId); + } + + @Post() + async createTask( + @Headers("x-user-id") userIdHeader: string | string[] | undefined, + @Body() body: CreateTaskDto + ): Promise { + 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 { + 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; + } +} diff --git a/apps/api/src/task/task.module.ts b/apps/api/src/task/task.module.ts new file mode 100644 index 0000000..8226093 --- /dev/null +++ b/apps/api/src/task/task.module.ts @@ -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 {} diff --git a/apps/api/src/task/task.service.ts b/apps/api/src/task/task.service.ts new file mode 100644 index 0000000..3f62dea --- /dev/null +++ b/apps/api/src/task/task.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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(); + + 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 { + 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() + }; + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index a6aded2..17d29bc 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -2,9 +2,9 @@ "$schema": "https://json.schemastore.org/tsconfig", "extends": "../../packages/tsconfig/nest-app.json", "compilerOptions": { - "rootDir": "src", + "rootDir": ".", "outDir": "dist" }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "generated/prisma/**/*.ts"], "exclude": ["dist", "node_modules"] }