backend project
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { Role } from "@prisma/client";
|
||||
|
||||
export type Requester = { id: string; role: Role };
|
||||
|
||||
export type TodoAccessShape = {
|
||||
assignees: Array<{ id: string; managerId: string | null }>;
|
||||
createdBy: { id: string };
|
||||
};
|
||||
|
||||
export function canAccessTodo(requester: Requester, todo: TodoAccessShape): boolean {
|
||||
// Policy: end users never access app endpoints
|
||||
if (requester.role === Role.USER) return false;
|
||||
|
||||
const isAssignee = !!todo.assignees.find((a) => a.id === requester.id);
|
||||
const isCreator = todo.createdBy.id === requester.id;
|
||||
const isManagerOfAssignee = !!todo.assignees.find((a) => a.managerId === requester.id);
|
||||
|
||||
if (requester.role === Role.ADMIN) {
|
||||
return isAssignee; // Admins can only access Todos assigned to themselves
|
||||
}
|
||||
if (requester.role === Role.MANAGER) {
|
||||
return isAssignee || isManagerOfAssignee || isCreator;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function validateCommentAuthor(requesterId: string, comment: { userId: string }) {
|
||||
if (comment.userId !== requesterId) {
|
||||
const err: any = new Error("Only the author can modify this comment");
|
||||
err.status = 403;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { PriorityStatus, Role, TodoStatus } from "@prisma/client";
|
||||
|
||||
export type ListParams = {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
statuses?: string | string[];
|
||||
priorities?: string | string[];
|
||||
sortBy?: string;
|
||||
sortDir?: "asc" | "desc" | string;
|
||||
assigneeUserId?: string;
|
||||
teamOfManagerId?: string;
|
||||
};
|
||||
|
||||
export type Requester = { id: string; role: Role };
|
||||
|
||||
export function parseCsv(value?: string | string[]): string[] {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
|
||||
return value.split(",").map((v) => v.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function buildTodoQuery(params: ListParams, requester: Requester) {
|
||||
const page = Math.max(parseInt(String(params.page ?? 1), 10), 1);
|
||||
const limit = Math.max(parseInt(String(params.limit ?? 25), 10), 1);
|
||||
const search = (params.search ?? "").trim();
|
||||
const statuses = parseCsv(params.statuses) as TodoStatus[];
|
||||
const priorities = parseCsv(params.priorities) as PriorityStatus[];
|
||||
const sortBy = (params.sortBy ?? "") as string;
|
||||
const sortDir = String(params.sortDir ?? "asc").toLowerCase() === "desc" ? "desc" : "asc";
|
||||
const assigneeUserId = params.assigneeUserId ?? "";
|
||||
const teamOfManagerId = params.teamOfManagerId ?? "";
|
||||
|
||||
const where: any = {};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: "insensitive" } },
|
||||
{ task: { contains: search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
if (statuses.length) where.status = { in: statuses };
|
||||
if (priorities.length) where.priority = { in: priorities };
|
||||
|
||||
const andClauses: any[] = [];
|
||||
if (assigneeUserId) andClauses.push({ assignees: { some: { id: assigneeUserId } } });
|
||||
if (teamOfManagerId) andClauses.push({ assignees: { some: { managerId: teamOfManagerId } } });
|
||||
|
||||
// Server-side scoping by role
|
||||
if (requester.role === Role.ADMIN) {
|
||||
andClauses.push({ assignees: { some: { id: requester.id } } });
|
||||
} else if (requester.role === Role.MANAGER) {
|
||||
andClauses.push({
|
||||
OR: [
|
||||
{ assignees: { some: { id: requester.id } } },
|
||||
{ assignees: { some: { managerId: requester.id } } },
|
||||
],
|
||||
});
|
||||
}
|
||||
if (andClauses.length) where.AND = [...(where.AND || []), ...andClauses];
|
||||
|
||||
let orderBy: any = undefined;
|
||||
if (sortBy) {
|
||||
if (sortBy === "title") orderBy = { title: sortDir };
|
||||
else if (sortBy === "dueDate") orderBy = { dueDate: sortDir };
|
||||
else if (sortBy === "status") orderBy = { status: sortDir };
|
||||
else if (sortBy === "priority") orderBy = { priority: sortDir };
|
||||
else if (sortBy === "createdAt") orderBy = { createdAt: sortDir };
|
||||
}
|
||||
|
||||
return {
|
||||
page,
|
||||
limit,
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { PriorityStatus, Role, TodoStatus } from "@prisma/client";
|
||||
import prisma from "../prismaClient";
|
||||
import { buildTodoQuery, ListParams, Requester as QueryRequester } from "./todoQuery";
|
||||
|
||||
export type TodoCreateInput = {
|
||||
title: string;
|
||||
task: string;
|
||||
status: TodoStatus;
|
||||
priority: PriorityStatus;
|
||||
dueDate: Date;
|
||||
createdById: string;
|
||||
assigneeIds: string[];
|
||||
};
|
||||
|
||||
export type TodoUpdateInput = Partial<{
|
||||
title: string;
|
||||
task: string;
|
||||
status: TodoStatus;
|
||||
priority: PriorityStatus;
|
||||
dueDate: Date | null;
|
||||
assigneeIds: string[];
|
||||
}>;
|
||||
|
||||
export async function createTodo(data: TodoCreateInput) {
|
||||
const todo = await prisma.todo.create({
|
||||
data: {
|
||||
title: data.title,
|
||||
task: data.task,
|
||||
status: data.status,
|
||||
priority: data.priority,
|
||||
dueDate: data.dueDate,
|
||||
createdBy: { connect: { id: data.createdById } },
|
||||
assignees: { connect: data.assigneeIds.map((id) => ({ id })) },
|
||||
},
|
||||
include: { assignees: true, createdBy: true },
|
||||
});
|
||||
return todo;
|
||||
}
|
||||
|
||||
export async function updateTodo(todoId: string, data: TodoUpdateInput) {
|
||||
const todo = await prisma.todo.update({
|
||||
where: { id: todoId },
|
||||
data: {
|
||||
...(data.title !== undefined ? { title: data.title } : {}),
|
||||
...(data.task !== undefined ? { task: data.task } : {}),
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.priority !== undefined ? { priority: data.priority } : {}),
|
||||
...(data.dueDate !== undefined ? { dueDate: data.dueDate ?? undefined } : {}),
|
||||
...(data.assigneeIds !== undefined
|
||||
? { assignees: { set: data.assigneeIds.map((id) => ({ id })) } }
|
||||
: {}),
|
||||
},
|
||||
include: { assignees: true, createdBy: true },
|
||||
});
|
||||
return todo;
|
||||
}
|
||||
|
||||
export async function deleteTodo(todoId: string) {
|
||||
await prisma.todo.delete({ where: { id: todoId } });
|
||||
}
|
||||
|
||||
export async function listTodos(params: ListParams, requester: QueryRequester) {
|
||||
const { page, limit, where, orderBy, skip, take } = buildTodoQuery(params, requester);
|
||||
const [total, items] = await Promise.all([
|
||||
prisma.todo.count({ where }),
|
||||
prisma.todo.findMany({ where, include: { assignees: true, createdBy: true }, orderBy, skip, take }),
|
||||
]);
|
||||
return { items, total, page, limit } as const;
|
||||
}
|
||||
|
||||
export async function getTodoAccessShape(todoId: string) {
|
||||
return prisma.todo.findUnique({
|
||||
where: { id: todoId },
|
||||
include: {
|
||||
assignees: { select: { id: true, managerId: true } },
|
||||
createdBy: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function listComments(todoId: string) {
|
||||
return prisma.comment.findMany({
|
||||
where: { todoId },
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true, imageUrl: true, role: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function addComment(todoId: string, userId: string, content: string) {
|
||||
return prisma.comment.create({
|
||||
data: {
|
||||
content,
|
||||
todo: { connect: { id: todoId } },
|
||||
user: { connect: { id: userId } },
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true, imageUrl: true, role: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCommentSlim(commentId: string) {
|
||||
return prisma.comment.findUnique({
|
||||
where: { id: commentId },
|
||||
select: { id: true, userId: true, todoId: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateComment(commentId: string, content: string) {
|
||||
return prisma.comment.update({
|
||||
where: { id: commentId },
|
||||
data: { content },
|
||||
include: {
|
||||
user: { select: { id: true, firstName: true, lastName: true, imageUrl: true, role: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteComment(commentId: string) {
|
||||
await prisma.comment.delete({ where: { id: commentId } });
|
||||
}
|
||||
|
||||
export async function getTodosForUser(targetUserId: string, requester: { id: string; role: Role }) {
|
||||
// Determine target user's role for Manager override
|
||||
const targetUser = await prisma.user.findUnique({ where: { id: targetUserId }, select: { role: true } });
|
||||
|
||||
let visibilityClause: any = {};
|
||||
if (requester.role === Role.MANAGER) {
|
||||
if (targetUser?.role === Role.ADMIN) {
|
||||
// Managers can view ANY Admin's todos (admin detail view use-case)
|
||||
visibilityClause = {};
|
||||
} else {
|
||||
visibilityClause = {
|
||||
OR: [
|
||||
{ assignees: { some: { id: requester.id } } },
|
||||
{ assignees: { some: { managerId: requester.id } } },
|
||||
],
|
||||
} as any;
|
||||
}
|
||||
} else if (requester.role === Role.ADMIN) {
|
||||
visibilityClause = { assignees: { some: { id: requester.id } } } as any;
|
||||
}
|
||||
|
||||
const todos = await prisma.todo.findMany({
|
||||
where: { AND: [{ assignees: { some: { id: targetUserId } } }, visibilityClause] },
|
||||
include: { assignees: true, createdBy: true },
|
||||
});
|
||||
return todos;
|
||||
}
|
||||
Reference in New Issue
Block a user