From e5ad06f25936674b311e02f86b0107d47b0829ce Mon Sep 17 00:00:00 2001 From: Anthony Saah Date: Sun, 3 Aug 2025 08:44:50 +0000 Subject: [PATCH 1/2] attempt #1 --- app/routes/invite.tsx | 36 ++- app/routes/list.tsx | 400 +++++++++++++------------- app/routes/notifications.$id.read.tsx | 26 +- app/routes/notifications.ts | 128 +++++---- uno.config.ts | 2 +- 5 files changed, 308 insertions(+), 284 deletions(-) diff --git a/app/routes/invite.tsx b/app/routes/invite.tsx index 518d430..82dad14 100644 --- a/app/routes/invite.tsx +++ b/app/routes/invite.tsx @@ -1,31 +1,37 @@ import { addHours } from "date-fns"; import { customAlphabet } from "nanoid"; import type { LoaderFunctionArgs } from "react-router"; +import { redirect } from "react-router"; +import { tryit } from "radashi"; import { checkAuth } from "~/lib/check-auth"; import { prisma } from "~/lib/prisma.server"; import { unauthorized } from "~/lib/responses"; const generateToken = customAlphabet( - "abcde0123456789fghijklmnABCDEFGHNOPopqrstWXYZuvwxyz", - 10, + "abcde0123456789fghijklmnABCDEFGHNOPopqrstWXYZuvwxyz", + 10 ); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await checkAuth(request); +export const loader = async ({ request }: LoaderFunctionArgs) => { + const [err, user] = await tryit(checkAuth)(request); - if (!user.superUser) { - throw unauthorized(); - } + if (err) { + throw redirect("/auth"); + } - const expiresAt = addHours(new Date(), 12); - const token = generateToken(); + if (!user.superUser) { + throw unauthorized(); + } - const url = new URL(request.url); - const project = url.searchParams.get("project"); + const expiresAt = addHours(new Date(), 12); + const token = generateToken(); - await prisma.inviteToken.create({ - data: { token, expiresAt, project: { connect: { slug: project! } } }, - }); + const url = new URL(request.url); + const project = url.searchParams.get("project"); - return { token }; + await prisma.inviteToken.create({ + data: { token, expiresAt, project: { connect: { slug: project! } } }, + }); + + return { token }; }; diff --git a/app/routes/list.tsx b/app/routes/list.tsx index 6330bf6..6613cdc 100644 --- a/app/routes/list.tsx +++ b/app/routes/list.tsx @@ -1,5 +1,7 @@ import type { Prisma, Status } from "@prisma/client"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { tryit } from "radashi"; +import { redirect } from "react-router"; import { checkAuth } from "~/lib/check-auth"; import { cleanUpdate } from "~/lib/clean-update"; import { TASK_ID_REGEX } from "~/lib/constants"; @@ -8,204 +10,208 @@ import { badRequest, notFound } from "~/lib/responses"; import { sendWebhook } from "~/lib/webhook"; export const loader = async ({ request }: LoaderFunctionArgs) => { - const url = new URL(request.url); - const searchParams = url.searchParams; - - const page = Number(searchParams.get("page")) || 0; - const assigneeId = searchParams.get("assigneeId") || undefined; - const status = searchParams.get("status") || undefined; - - const search = searchParams.get("search") || ""; - const project = searchParams.get("project") || undefined; - - const where: Prisma.TaskWhereInput = { OR: [] }; - - where.OR!.push({ - title: { - contains: search, - mode: "insensitive", - }, - }); - - const match = search.match(TASK_ID_REGEX); - if (match) { - where.OR!.push({ id: Number(match[1]) }); - } - - if (assigneeId) { - where.assigneeId = Number(assigneeId); - } - - if (status) { - where.status = status as Status; - } - - if (project) { - where.project = { slug: project }; - } - - const tasks = await prisma.task.findMany({ - where, - orderBy: { - createdAt: "desc", - }, - include: { - _count: { select: { Comment: true } }, - assignee: { select: { username: true, id: true } }, - author: { select: { username: true, id: true } }, - }, - take: 100, - skip: page * 100, - }); - - const withComments = tasks.map((task) => ({ - ...task, - comments: task._count.Comment, - _count: undefined, - })); - - return { tasks: withComments }; + const url = new URL(request.url); + const searchParams = url.searchParams; + + const page = Number(searchParams.get("page")) || 0; + const assigneeId = searchParams.get("assigneeId") || undefined; + const status = searchParams.get("status") || undefined; + + const search = searchParams.get("search") || ""; + const project = searchParams.get("project") || undefined; + + const where: Prisma.TaskWhereInput = { OR: [] }; + + where.OR!.push({ + title: { + contains: search, + mode: "insensitive", + }, + }); + + const match = search.match(TASK_ID_REGEX); + if (match) { + where.OR!.push({ id: Number(match[1]) }); + } + + if (assigneeId) { + where.assigneeId = Number(assigneeId); + } + + if (status) { + where.status = status as Status; + } + + if (project) { + where.project = { slug: project }; + } + + const tasks = await prisma.task.findMany({ + where, + orderBy: { + createdAt: "desc", + }, + include: { + _count: { select: { Comment: true } }, + assignee: { select: { username: true, id: true } }, + author: { select: { username: true, id: true } }, + }, + take: 100, + skip: page * 100, + }); + + const withComments = tasks.map((task) => ({ + ...task, + comments: task._count.Comment, + _count: undefined, + })); + + return { tasks: withComments }; }; export const action = async ({ request }: ActionFunctionArgs) => { - const user = await checkAuth(request); - - if (request.method === "DELETE") { - const { taskId: id } = await request.json(); - - if (!id) throw badRequest({ error: "taskId is required" }); - - const taskToDelete = await prisma.task.findUnique({ - where: { id }, - include: { - assignee: { - omit: { - password: true, - }, - }, - }, - }); - - const result = await prisma.task.delete({ - where: { id }, - }); - - if (taskToDelete) { - sendWebhook("task.deleted", { - task: taskToDelete, - user, - projectId: taskToDelete.projectId, - }); - } - - return result; - } - - if (request.method === "PATCH") { - const { id, updates } = cleanUpdate(await request.json()); - - const previous = await prisma.task.findUnique({ - where: { id }, - select: { - assigneeId: true, - status: true, - title: true, - }, - }); - - if (!previous) throw notFound(); - - const previousAssigneeId = previous.assigneeId; - const previousStatus = previous.status; - - const task = await prisma.task.update({ - where: { id }, - data: { - ...updates, - completedAt: updates.status === "done" ? new Date() : null, - }, - include: { - assignee: { - omit: { - password: true, - }, - }, - }, - }); - - if (updates.status && previousStatus !== updates.status) { - sendWebhook("task.status_changed", { - task, - user, - previousStatus, - projectId: task.projectId, - }); - } - - if (updates.assigneeId && previousAssigneeId !== updates.assigneeId) { - if (updates.assigneeId !== user.id) { - // don't notify self - await prisma.notification.create({ - data: { - message: `You have been assigned to task @[task/${id}] by @[user/${user.id}]`, - userId: task.assigneeId, - type: "assignment", - meta: { - taskId: id, - previousAssigneeId, - newAssigneeId: updates.assigneeId, - }, - projectId: task.projectId, - }, - }); - } - - sendWebhook("task.assigned", { - task, - user, - projectId: task.projectId, - }); - } - - if (updates.title && previous.title !== updates.title) { - sendWebhook("task.updated", { - task, - user, - updatedFields: ["title"], - projectId: task.projectId, - }); - } - - return { task }; - } - - if (request.method === "POST") { - const data = await request.json(); - - const task = await prisma.task.create({ - data, - include: { - assignee: { - omit: { - password: true, - }, - }, - }, - }); - - const taskAuthor = await prisma.user.findUnique({ - where: { id: data.authorId }, - omit: { - password: true, - }, - }); - - sendWebhook("task.created", { - task, - user: taskAuthor || undefined, - projectId: task.projectId, - }); - - return { task }; - } + const [err, user] = await tryit(checkAuth)(request); + + if (err) { + throw redirect("/auth"); + } + + if (request.method === "DELETE") { + const { taskId: id } = await request.json(); + + if (!id) throw badRequest({ error: "taskId is required" }); + + const taskToDelete = await prisma.task.findUnique({ + where: { id }, + include: { + assignee: { + omit: { + password: true, + }, + }, + }, + }); + + const result = await prisma.task.delete({ + where: { id }, + }); + + if (taskToDelete) { + sendWebhook("task.deleted", { + task: taskToDelete, + user, + projectId: taskToDelete.projectId, + }); + } + + return result; + } + + if (request.method === "PATCH") { + const { id, updates } = cleanUpdate(await request.json()); + + const previous = await prisma.task.findUnique({ + where: { id }, + select: { + assigneeId: true, + status: true, + title: true, + }, + }); + + if (!previous) throw notFound(); + + const previousAssigneeId = previous.assigneeId; + const previousStatus = previous.status; + + const task = await prisma.task.update({ + where: { id }, + data: { + ...updates, + completedAt: updates.status === "done" ? new Date() : null, + }, + include: { + assignee: { + omit: { + password: true, + }, + }, + }, + }); + + if (updates.status && previousStatus !== updates.status) { + sendWebhook("task.status_changed", { + task, + user, + previousStatus, + projectId: task.projectId, + }); + } + + if (updates.assigneeId && previousAssigneeId !== updates.assigneeId) { + if (updates.assigneeId !== user.id) { + // don't notify self + await prisma.notification.create({ + data: { + message: `You have been assigned to task @[task/${id}] by @[user/${user.id}]`, + userId: task.assigneeId, + type: "assignment", + meta: { + taskId: id, + previousAssigneeId, + newAssigneeId: updates.assigneeId, + }, + projectId: task.projectId, + }, + }); + } + + sendWebhook("task.assigned", { + task, + user, + projectId: task.projectId, + }); + } + + if (updates.title && previous.title !== updates.title) { + sendWebhook("task.updated", { + task, + user, + updatedFields: ["title"], + projectId: task.projectId, + }); + } + + return { task }; + } + + if (request.method === "POST") { + const data = await request.json(); + + const task = await prisma.task.create({ + data, + include: { + assignee: { + omit: { + password: true, + }, + }, + }, + }); + + const taskAuthor = await prisma.user.findUnique({ + where: { id: data.authorId }, + omit: { + password: true, + }, + }); + + sendWebhook("task.created", { + task, + user: taskAuthor || undefined, + projectId: task.projectId, + }); + + return { task }; + } }; diff --git a/app/routes/notifications.$id.read.tsx b/app/routes/notifications.$id.read.tsx index 0333bbb..6c5d046 100644 --- a/app/routes/notifications.$id.read.tsx +++ b/app/routes/notifications.$id.read.tsx @@ -1,18 +1,24 @@ import type { LoaderFunctionArgs } from "react-router"; +import { redirect } from "react-router"; +import { tryit } from "radashi"; import { checkAuth } from "~/lib/check-auth"; import { prisma } from "~/lib/prisma.server"; export const loader = async ({ params, request }: LoaderFunctionArgs) => { - await checkAuth(request); + const [err] = await tryit(checkAuth)(request); - await prisma.notification.update({ - where: { - id: Number(params.id), - }, - data: { - read: true, - }, - }); + if (err) { + throw redirect("/auth"); + } - return { read: true }; + await prisma.notification.update({ + where: { + id: Number(params.id), + }, + data: { + read: true, + }, + }); + + return { read: true }; }; diff --git a/app/routes/notifications.ts b/app/routes/notifications.ts index d3999d5..70c0273 100644 --- a/app/routes/notifications.ts +++ b/app/routes/notifications.ts @@ -1,84 +1,90 @@ import type { Notification, Prisma } from "@prisma/client"; import type { LoaderFunctionArgs } from "react-router"; +import { redirect } from "react-router"; +import { tryit } from "radashi"; import { checkAuth } from "~/lib/check-auth"; import { TASK_MENTION_REGEX, USER_MENTION_REGEX } from "~/lib/constants"; import { prisma } from "~/lib/prisma.server"; export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await checkAuth(request); + const [err, user] = await tryit(checkAuth)(request); - const url = new URL(request.url); - const project = url.searchParams.get("project"); + if (err) { + throw redirect("/auth"); + } - const where: Prisma.NotificationWhereInput = { userId: user.id }; + const url = new URL(request.url); + const project = url.searchParams.get("project"); - if (project) { - where.project = { slug: project }; - } + const where: Prisma.NotificationWhereInput = { userId: user.id }; - const notifications = await prisma.notification.findMany({ - where, - orderBy: { createdAt: "desc" }, - take: 100, - }); + if (project) { + where.project = { slug: project }; + } - const expanded = await expandNotifications(notifications); + const notifications = await prisma.notification.findMany({ + where, + orderBy: { createdAt: "desc" }, + take: 100, + }); - return { notifications: expanded }; + const expanded = await expandNotifications(notifications); + + return { notifications: expanded }; }; async function expandNotifications(notifications: Notification[]) { - const extraction = notifications.map((it) => { - const taskMatches = [...it.message.matchAll(TASK_MENTION_REGEX)].map( - (match) => Number(match[1]), - ); - const userMatches = [...it.message.matchAll(USER_MENTION_REGEX)].map( - (match) => Number(match[1]), - ); + const extraction = notifications.map((it) => { + const taskMatches = [...it.message.matchAll(TASK_MENTION_REGEX)].map( + (match) => Number(match[1]) + ); + const userMatches = [...it.message.matchAll(USER_MENTION_REGEX)].map( + (match) => Number(match[1]) + ); - return { - id: it.id, - tasks: taskMatches, - users: userMatches, - }; - }); + return { + id: it.id, + tasks: taskMatches, + users: userMatches, + }; + }); - const taskIds = new Set(extraction.flatMap((it) => it.tasks)); - const userIds = new Set(extraction.flatMap((it) => it.users)); + const taskIds = new Set(extraction.flatMap((it) => it.tasks)); + const userIds = new Set(extraction.flatMap((it) => it.users)); - const tasks = await prisma.task.findMany({ - where: { - id: { - in: Array.from(taskIds), - }, - }, - select: { - title: true, - id: true, - }, - }); + const tasks = await prisma.task.findMany({ + where: { + id: { + in: Array.from(taskIds), + }, + }, + select: { + title: true, + id: true, + }, + }); - const users = await prisma.user.findMany({ - where: { - id: { - in: Array.from(userIds), - }, - }, - select: { - id: true, - username: true, - }, - }); + const users = await prisma.user.findMany({ + where: { + id: { + in: Array.from(userIds), + }, + }, + select: { + id: true, + username: true, + }, + }); - return notifications.map((it) => { - const { tasks: taskIds, users: userIds } = extraction.find( - (ext) => it.id === ext.id, - )!; + return notifications.map((it) => { + const { tasks: taskIds, users: userIds } = extraction.find( + (ext) => it.id === ext.id + )!; - return { - ...it, - tasks: taskIds.map((id) => tasks.find((t) => t.id === id)), - users: userIds.map((id) => users.find((u) => u.id === id)), - }; - }); + return { + ...it, + tasks: taskIds.map((id) => tasks.find((t) => t.id === id)), + users: userIds.map((id) => users.find((u) => u.id === id)), + }; + }); } diff --git a/uno.config.ts b/uno.config.ts index e24449e..839a505 100644 --- a/uno.config.ts +++ b/uno.config.ts @@ -9,7 +9,7 @@ import { export default defineConfig({ content: { - filesystem: ["**/*.{html,js,ts,jsx,tsx,vue,svelte,astro}"], + filesystem: ["app/**/*.{ts,tsx,js,jsx}"], }, presets: [presetWind3({ dark: "media" }), presetIcons(), presetForms()], transformers: [transformerDirectives(), transformerVariantGroup()], From f5decbda3349b4f65f1e711445cdf459b408e9b3 Mon Sep 17 00:00:00 2001 From: Anthony Saah Date: Sun, 3 Aug 2025 08:51:26 +0000 Subject: [PATCH 2/2] Task complete --- app/routes/$project.tsx | 4 ++-- app/routes/projects.tsx | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/routes/$project.tsx b/app/routes/$project.tsx index 5ac92d3..c34764b 100644 --- a/app/routes/$project.tsx +++ b/app/routes/$project.tsx @@ -24,9 +24,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const [err, access] = await tryit(checkAccess)(request, params.project!); if (err) { - if ("data" in err) throw err; + if (err instanceof Response) throw err; - return redirect("/auth"); + throw redirect("/auth"); } const users = await prisma.user.findMany({ diff --git a/app/routes/projects.tsx b/app/routes/projects.tsx index 05493be..bee71b6 100644 --- a/app/routes/projects.tsx +++ b/app/routes/projects.tsx @@ -8,6 +8,8 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const [err, user] = await tryit(checkAuth)(request); if (err) { + if (err instanceof Response) throw err; + throw unauthorized(); }