From e5ad06f25936674b311e02f86b0107d47b0829ce Mon Sep 17 00:00:00 2001 From: Anthony Saah Date: Sun, 3 Aug 2025 08:44:50 +0000 Subject: [PATCH 1/5] 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/5] 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(); } From ce9a9a638ddbe1b393d9fd4d982b96f69bc5ab7c Mon Sep 17 00:00:00 2001 From: Anthony Saah Date: Sun, 3 Aug 2025 09:09:25 +0000 Subject: [PATCH 3/5] Loader added --- app/components/project-button.tsx | 314 ++++++++++++++++-------------- 1 file changed, 163 insertions(+), 151 deletions(-) diff --git a/app/components/project-button.tsx b/app/components/project-button.tsx index a050bdc..c576a10 100644 --- a/app/components/project-button.tsx +++ b/app/components/project-button.tsx @@ -5,167 +5,179 @@ import type { ProjectWithTaskCount } from "~/lib/types"; import { useProjects } from "~/lib/use-projects"; import type { loader } from "~/routes/$project"; import { - Popover, - PopoverContent, - PopoverTrigger, - usePopoverContext, + Popover, + PopoverContent, + PopoverTrigger, + usePopoverContext, } from "./popover"; import { ProjectDeleteForm } from "./project-delete-form"; import { ProjectForm } from "./project-form"; export function ProjectButton() { - const { project } = useLoaderData(); - - return ( - - -
-
-
-

{project.name}

-
-
-
- - - - - - - ); + const { project } = useLoaderData(); + + return ( + + +
+
+
+

{project.name}

+
+
+
+ + + + + + + ); } type View = - | "list" - | "new-project-form" - | "edit-project-form" - | "delete-project-form"; + | "list" + | "new-project-form" + | "edit-project-form" + | "delete-project-form"; function Content() { - const [view, setView] = React.useState("list"); - - const { user, project: activeProject } = useLoaderData(); - const { query } = useProjects(); - const { data: projects } = query; - - const popover = usePopoverContext(); - - const edit = React.useRef(); - - const isOnly = projects?.length === 1; - - if (view === "new-project-form") { - return ( - - setView("list")} /> - - ); - } - - if (view === "edit-project-form") { - return ( - - setView("list")} project={edit.current} /> - - ); - } - - if (view === "delete-project-form") { - return ( - - setView("list")} - /> - - ); - } - - return ( - -
-
    -
  • - {projects?.map((project) => ( -
    - popover.setOpen(false)} - > -
    - {project.name} - - - {user.superUser && ( -
    -
    - )} - -
    - {project._count.Task} -
    -
    - ))} -
  • -
- - {user.superUser && ( -
- -
- )} -
-
- ); + const [view, setView] = React.useState("list"); + + const { user, project: activeProject } = useLoaderData(); + const { query } = useProjects(); + let { data: projects, isLoading } = query; + + const popover = usePopoverContext(); + + const edit = React.useRef(); + + const isOnly = projects?.length === 1; + + if (view === "new-project-form") { + return ( + + setView("list")} /> + + ); + } + + if (view === "edit-project-form") { + return ( + + setView("list")} project={edit.current} /> + + ); + } + + if (view === "delete-project-form") { + return ( + + setView("list")} + /> + + ); + } + + isLoading = true; + + return ( + +
+
    +
  • + {isLoading ? ( +
    +
    +

    Loading projects...

    +
    + ) : ( + projects?.map((project) => ( +
    + popover.setOpen(false)} + > +
    + {project.name} + + + {user.superUser && ( +
    +
    + )} + +
    + {project._count.Task} +
    +
    + )) + )} +
  • +
+ + {user.superUser && ( +
+ +
+ )} +
+
+ ); } function Container({ - children, - className, -}: { children: React.ReactNode; className?: string }) { - return ( -
- {children} -
- ); + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) { + return ( +
+ {children} +
+ ); } From ad2fffb8e01ca219f0571efe6224a3fba1eb5fbe Mon Sep 17 00:00:00 2001 From: Anthony Saah Date: Sun, 3 Aug 2025 09:16:23 +0000 Subject: [PATCH 4/5] removed testing state --- app/components/project-button.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/components/project-button.tsx b/app/components/project-button.tsx index c576a10..2edf9e1 100644 --- a/app/components/project-button.tsx +++ b/app/components/project-button.tsx @@ -45,7 +45,7 @@ function Content() { const { user, project: activeProject } = useLoaderData(); const { query } = useProjects(); - let { data: projects, isLoading } = query; + const { data: projects, isLoading } = query; const popover = usePopoverContext(); @@ -80,8 +80,6 @@ function Content() { ); } - isLoading = true; - return (
From 1bea089487010df9865a0912f1f443f91eb0a76c Mon Sep 17 00:00:00 2001 From: Anthony Saah Date: Mon, 4 Aug 2025 07:40:46 +0000 Subject: [PATCH 5/5] Updated Loader using 3 dots instead; reduced py; removed loading text --- app/components/project-button.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/components/project-button.tsx b/app/components/project-button.tsx index 2edf9e1..29af3c9 100644 --- a/app/components/project-button.tsx +++ b/app/components/project-button.tsx @@ -86,9 +86,8 @@ function Content() {
  • {isLoading ? ( -
    -
    -

    Loading projects...

    +
    +
    ) : ( projects?.map((project) => (