459 lines
12 KiB
TypeScript
459 lines
12 KiB
TypeScript
import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
|
|
import { Prisma, TaskPriority, TaskStatus } from "../../generated/prisma/client";
|
|
import { PrismaService } from "../prisma/prisma.service";
|
|
import { DataEncryptionService } from "../security/data-encryption.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,
|
|
private readonly dataEncryptionService: DataEncryptionService
|
|
) {}
|
|
|
|
async listTasks(userId: string, query: ListTasksQueryDto): Promise<ListTasksResponse> {
|
|
const page = query.page ?? 1;
|
|
const pageSize = query.pageSize ?? 20;
|
|
const skip = (page - 1) * pageSize;
|
|
const keyword = query.keyword?.trim() ?? "";
|
|
|
|
const where = this.buildWhereInput(userId, query, keyword.length === 0);
|
|
const orderBy = this.buildOrderByInput(query);
|
|
|
|
if (keyword.length > 0) {
|
|
const items = await this.prismaService.task.findMany({
|
|
where,
|
|
orderBy,
|
|
include: {
|
|
taskTags: {
|
|
include: {
|
|
tag: {
|
|
select: {
|
|
name: true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
const serializedItems = items.map((item: TaskEntity) => this.serializeTask(item));
|
|
const filteredItems = serializedItems.filter((item) => this.matchesKeyword(item, keyword));
|
|
|
|
return {
|
|
items: filteredItems.slice(skip, skip + pageSize),
|
|
page,
|
|
pageSize,
|
|
total: filteredItems.length
|
|
};
|
|
}
|
|
|
|
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: TaskEntity) => 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
|
|
? ((this.dataEncryptionService.encryptJson(body.contentJson as Prisma.InputJsonValue) ??
|
|
Prisma.JsonNull) as Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput)
|
|
: undefined;
|
|
|
|
const task = await this.prismaService.$transaction(async (tx) => {
|
|
const createdTask = await tx.task.create({
|
|
data: {
|
|
userId,
|
|
title: this.encryptRequiredString(body.title),
|
|
contentJson,
|
|
contentText: this.encryptNullableString(body.contentText),
|
|
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 = this.encryptRequiredString(body.title);
|
|
}
|
|
if (body.contentJson !== undefined) {
|
|
data.contentJson = (this.dataEncryptionService.encryptJson(
|
|
body.contentJson as Prisma.InputJsonValue
|
|
) ?? Prisma.JsonNull) as Prisma.InputJsonValue | Prisma.NullableJsonNullValueInput;
|
|
}
|
|
if (body.contentText !== undefined) {
|
|
data.contentText = this.encryptNullableString(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,
|
|
includeKeyword: boolean
|
|
): 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 (includeKeyword && 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: { id: string }) => ({
|
|
taskId,
|
|
tagId: tag.id
|
|
})),
|
|
skipDuplicates: true
|
|
});
|
|
}
|
|
|
|
private serializeTask(task: TaskEntity): TaskResponse {
|
|
return {
|
|
id: task.id,
|
|
title: this.readDecryptedString(task.title) ?? "未命名任务",
|
|
contentJson: this.dataEncryptionService.decryptJson(task.contentJson),
|
|
contentText: this.readDecryptedString(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: { tag: { name: string } }) => taskTag.tag.name),
|
|
createdAt: task.createdAt.toISOString(),
|
|
updatedAt: task.updatedAt.toISOString()
|
|
};
|
|
}
|
|
|
|
private encryptRequiredString(value: string): string {
|
|
const encryptedValue = this.dataEncryptionService.encryptString(value);
|
|
if (!encryptedValue) {
|
|
throw new InternalServerErrorException("任务字段加密失败");
|
|
}
|
|
|
|
return encryptedValue;
|
|
}
|
|
|
|
private encryptNullableString(value: string | null | undefined): string | null | undefined {
|
|
return this.dataEncryptionService.encryptString(value);
|
|
}
|
|
|
|
private readDecryptedString(value: string | null): string | null {
|
|
const decryptedValue = this.dataEncryptionService.decryptString(value);
|
|
return typeof decryptedValue === "string" ? decryptedValue : null;
|
|
}
|
|
|
|
private matchesKeyword(task: TaskResponse, keyword: string): boolean {
|
|
const lowerKeyword = keyword.toLocaleLowerCase();
|
|
return (
|
|
task.title.toLocaleLowerCase().includes(lowerKeyword) ||
|
|
task.contentText?.toLocaleLowerCase().includes(lowerKeyword) === true
|
|
);
|
|
}
|
|
}
|