backend project

This commit is contained in:
Sone
2025-11-02 10:05:02 +01:00
parent e4199f65d8
commit deb622b796
62 changed files with 12475 additions and 0 deletions
+33
View File
@@ -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;
}
}
+78
View File
@@ -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;
}
+151
View File
@@ -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;
}