From 6b991ba9f12a19e2af3f8bb1032f0a811574f7d9 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Tue, 9 Sep 2025 23:15:04 +1000 Subject: [PATCH 1/4] Update account / org deletion logic --- .../jobs/account/mark-for-deletion/route.ts | 52 +++++++++---------- .../api/jobs/user/mark-for-deletion/route.ts | 44 ++++++++-------- app/(dashboard)/[tenant]/today/page.tsx | 34 +----------- bun.lock | 17 +++++- ...ion-notice.tsx => org-deletion-notice.tsx} | 32 ++++++------ components/today/task-item.tsx | 34 ++++++++++++ next.config.mjs | 2 + package.json | 1 + 8 files changed, 115 insertions(+), 101 deletions(-) rename components/emails/{thirty-day-deletion-notice.tsx => org-deletion-notice.tsx} (86%) create mode 100644 components/today/task-item.tsx diff --git a/app/(api)/api/jobs/account/mark-for-deletion/route.ts b/app/(api)/api/jobs/account/mark-for-deletion/route.ts index 20eb433..6a9cd1e 100644 --- a/app/(api)/api/jobs/account/mark-for-deletion/route.ts +++ b/app/(api)/api/jobs/account/mark-for-deletion/route.ts @@ -1,17 +1,17 @@ +import { clerkClient } from "@clerk/nextjs/server"; +import { serve } from "@upstash/workflow/nextjs"; +import { and, eq, isNull, lte } from "drizzle-orm"; +import { Resend } from "resend"; +import { + OrgDeletionNotice, + thirtyDayDeletionNoticePlainText, +} from "@/components/emails/org-deletion-notice"; import { SevenDayWarning, sevenDayWarningPlainText, } from "@/components/emails/seven-day-warning"; -import { - ThirtyDayDeletionNotice, - thirtyDayDeletionNoticePlainText, -} from "@/components/emails/thirty-day-deletion-notice"; import { opsOrganization } from "@/ops/drizzle/schema"; import { getOpsDatabase } from "@/ops/useOps"; -import { clerkClient } from "@clerk/nextjs/server"; -import { serve } from "@upstash/workflow/nextjs"; -import { and, eq, isNull, lte } from "drizzle-orm"; -import { Resend } from "resend"; type ClerkOrgData = { createdBy?: string; @@ -66,20 +66,20 @@ export const { POST } = serve(async (context) => { new Date().toISOString(), ); const db = await getOpsDatabase(); - const thirtyDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 30); + const sixtyDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 60); console.log( "[OrgDeletion] Looking for orgs inactive since:", - thirtyDaysAgo.toISOString(), + sixtyDaysAgo.toISOString(), ); - // Step 1: Mark organizations for deletion (30 days inactive) + // Step 1: Mark organizations for deletion (60 days inactive) const orgsToMark = await context.run("fetch-orgs-to-mark", async () => { const orgs = await db .select() .from(opsOrganization) .where( and( - lte(opsOrganization.lastActiveAt, thirtyDaysAgo), + lte(opsOrganization.lastActiveAt, sixtyDaysAgo), isNull(opsOrganization.markedForDeletionAt), ), ); @@ -104,7 +104,7 @@ export const { POST } = serve(async (context) => { return orgs; }); - // Step 2: Send 30-day deletion notice and mark organizations + // Step 2: Send 60-day deletion notice and mark organizations await context.run("mark-orgs-for-deletion", async () => { if (orgsToMark.length === 0) { console.log( @@ -114,7 +114,7 @@ export const { POST } = serve(async (context) => { } console.log( - `[OrgDeletion] Processing ${orgsToMark.length} organizations for 30-day deletion notices`, + `[OrgDeletion] Processing ${orgsToMark.length} organizations for 60-day deletion notices`, ); for (const org of orgsToMark) { try { @@ -129,15 +129,15 @@ export const { POST } = serve(async (context) => { JSON.stringify(org.rawData, null, 2), ); - // Send 30-day deletion notice + // Send 60-day deletion notice console.log( - `[OrgDeletion] Sending 30-day notice email to ${contactEmail} for org ${org.name}`, + `[OrgDeletion] Sending 60-day notice email to ${contactEmail} for org ${org.name}`, ); const emailResult = await resend.emails.send({ from: "Manage Team ", to: contactEmail, - subject: "Organization Deletion Notice - 30 Days", - react: ThirtyDayDeletionNotice({ + subject: "Organization Deletion Notice - 60 Days", + react: OrgDeletionNotice({ firstName: firstName, email: contactEmail, organizationName: org.name, @@ -171,19 +171,17 @@ export const { POST } = serve(async (context) => { } }); - // Step 3: Send 7-day warning to organizations marked 23 days ago + // Step 3: Send 7-day warning to organizations marked 53 days ago const orgsFor7DayWarning = await context.run( "fetch-orgs-for-7-day-warning", async () => { - const twentyThreeDaysAgo = new Date( - Date.now() - 1000 * 60 * 60 * 24 * 23, - ); + const fiftyThreeDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 53); const orgs = await db .select() .from(opsOrganization) .where( and( - lte(opsOrganization.markedForDeletionAt, twentyThreeDaysAgo), + lte(opsOrganization.markedForDeletionAt, fiftyThreeDaysAgo), isNull(opsOrganization.finalWarningAt), ), ); @@ -271,18 +269,18 @@ export const { POST } = serve(async (context) => { } }); - // Step 4: Trigger deletion for organizations marked 30 days ago + // Step 4: Trigger deletion for organizations marked 60 days ago const orgsToTriggerDeletion = await context.run( "fetch-orgs-to-trigger-deletion", async () => { - const thirtyDaysAgoForDeletion = new Date( - Date.now() - 1000 * 60 * 60 * 24 * 30, + const sixtyDaysAgoForDeletion = new Date( + Date.now() - 1000 * 60 * 60 * 24 * 60, ); const orgs = await db .select() .from(opsOrganization) .where( - lte(opsOrganization.markedForDeletionAt, thirtyDaysAgoForDeletion), + lte(opsOrganization.markedForDeletionAt, sixtyDaysAgoForDeletion), ); console.log( diff --git a/app/(api)/api/jobs/user/mark-for-deletion/route.ts b/app/(api)/api/jobs/user/mark-for-deletion/route.ts index 40baa51..bff34f4 100644 --- a/app/(api)/api/jobs/user/mark-for-deletion/route.ts +++ b/app/(api)/api/jobs/user/mark-for-deletion/route.ts @@ -2,14 +2,14 @@ import { clerkClient } from "@clerk/nextjs/server"; import { serve } from "@upstash/workflow/nextjs"; import { and, eq, isNull, lte } from "drizzle-orm"; import { Resend } from "resend"; +import { + OrgDeletionNotice, + thirtyDayDeletionNoticePlainText, +} from "@/components/emails/org-deletion-notice"; import { SevenDayWarning, sevenDayWarningPlainText, } from "@/components/emails/seven-day-warning"; -import { - ThirtyDayDeletionNotice, - thirtyDayDeletionNoticePlainText, -} from "@/components/emails/thirty-day-deletion-notice"; import { opsUser } from "@/ops/drizzle/schema"; import { getOpsDatabase } from "@/ops/useOps"; @@ -67,20 +67,20 @@ export const { POST } = serve(async (context) => { new Date().toISOString(), ); const db = await getOpsDatabase(); - const thirtyDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 30); + const sixtyDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 60); console.log( "[UserDeletion] Looking for users inactive since:", - thirtyDaysAgo.toISOString(), + sixtyDaysAgo.toISOString(), ); - // Step 1: Mark users for deletion (30 days inactive, not part of any org) + // Step 1: Mark users for deletion (60 days inactive, not part of any org) const usersToMark = await context.run("fetch-users-to-mark", async () => { const users = await db .select() .from(opsUser) .where( and( - lte(opsUser.lastActiveAt, thirtyDaysAgo), + lte(opsUser.lastActiveAt, sixtyDaysAgo), isNull(opsUser.markedForDeletionAt), ), ); @@ -122,7 +122,7 @@ export const { POST } = serve(async (context) => { return eligibleUsers; }); - // Step 2: Send 30-day deletion notice and mark users + // Step 2: Send 60-day deletion notice and mark users await context.run("mark-users-for-deletion", async () => { if (usersToMark.length === 0) { console.log( @@ -132,7 +132,7 @@ export const { POST } = serve(async (context) => { } console.log( - `[UserDeletion] Processing ${usersToMark.length} users for 30-day deletion notices`, + `[UserDeletion] Processing ${usersToMark.length} users for 60-day deletion notices`, ); for (const user of usersToMark) { try { @@ -143,15 +143,15 @@ export const { POST } = serve(async (context) => { `[UserDeletion] Processing user ${user.id}, contact email: ${contactEmail}`, ); - // Send 30-day deletion notice + // Send 60-day deletion notice console.log( - `[UserDeletion] Sending 30-day notice email to ${contactEmail} for user ${user.id}`, + `[UserDeletion] Sending 60-day notice email to ${contactEmail} for user ${user.id}`, ); const emailResult = await resend.emails.send({ from: "Manage Team ", to: contactEmail, - subject: "Account Deletion Notice - 30 Days", - react: ThirtyDayDeletionNotice({ + subject: "Account Deletion Notice - 60 Days", + react: OrgDeletionNotice({ firstName: firstName, email: contactEmail, // organizationName is undefined for user deletion @@ -185,19 +185,17 @@ export const { POST } = serve(async (context) => { } }); - // Step 3: Send 7-day warning to users marked 23 days ago + // Step 3: Send 7-day warning to users marked 53 days ago const usersFor7DayWarning = await context.run( "fetch-users-for-7-day-warning", async () => { - const twentyThreeDaysAgo = new Date( - Date.now() - 1000 * 60 * 60 * 24 * 23, - ); + const fiftyThreeDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 53); const users = await db .select() .from(opsUser) .where( and( - lte(opsUser.markedForDeletionAt, twentyThreeDaysAgo), + lte(opsUser.markedForDeletionAt, fiftyThreeDaysAgo), isNull(opsUser.finalWarningAt), ), ); @@ -283,17 +281,17 @@ export const { POST } = serve(async (context) => { } }); - // Step 4: Trigger deletion for users marked 30 days ago + // Step 4: Trigger deletion for users marked 60 days ago const usersToTriggerDeletion = await context.run( "fetch-users-to-trigger-deletion", async () => { - const thirtyDaysAgoForDeletion = new Date( - Date.now() - 1000 * 60 * 60 * 24 * 30, + const sixtyDaysAgoForDeletion = new Date( + Date.now() - 1000 * 60 * 60 * 24 * 60, ); const users = await db .select() .from(opsUser) - .where(lte(opsUser.markedForDeletionAt, thirtyDaysAgoForDeletion)); + .where(lte(opsUser.markedForDeletionAt, sixtyDaysAgoForDeletion)); console.log( `[UserDeletion] Found ${users.length} users ready for deletion`, diff --git a/app/(dashboard)/[tenant]/today/page.tsx b/app/(dashboard)/[tenant]/today/page.tsx index 9a97d15..b516b58 100644 --- a/app/(dashboard)/[tenant]/today/page.tsx +++ b/app/(dashboard)/[tenant]/today/page.tsx @@ -22,6 +22,7 @@ import { SaveButton } from "@/components/form/button"; import SharedForm from "@/components/form/shared"; import PageTitle from "@/components/layout/page-title"; import { ProjecItem } from "@/components/project/project-item"; +import { TaskItem } from "@/components/today/task-item"; import { Button, buttonVariants } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { toDateStringWithDay } from "@/lib/utils/date"; @@ -267,36 +268,3 @@ export default function Today() { ); } - -function TaskItem( - tenant: string, - task: { - name: string; - id: number; - taskList: { - id: number; - name: string; - status: string; - project: { id: number; name: string }; - }; - }, -) { - return ( - -
-
-
-

{task.name}

-
- {task.taskList.project.name}{" "} - • {task.taskList.name} -
-
-
-
- - ); -} diff --git a/bun.lock b/bun.lock index 9603d40..ed56ccf 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "@radix-ui/react-tooltip": "^1.2.3", "@react-email/components": "^0.1.1", "@sentry/nextjs": "^10", + "@sentry/node": "^10.10.0", "@tanstack/react-query": "^5.71.10", "@trpc/client": "^11.0.2", "@trpc/server": "^11.0.2", @@ -779,9 +780,9 @@ "@sentry/nextjs": ["@sentry/nextjs@10.7.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@rollup/plugin-commonjs": "28.0.1", "@sentry-internal/browser-utils": "10.7.0", "@sentry/core": "10.7.0", "@sentry/node": "10.7.0", "@sentry/opentelemetry": "10.7.0", "@sentry/react": "10.7.0", "@sentry/vercel-edge": "10.7.0", "@sentry/webpack-plugin": "^4.1.1", "chalk": "3.0.0", "resolve": "1.22.8", "rollup": "^4.35.0", "stacktrace-parser": "^0.1.10" }, "peerDependencies": { "next": "^13.2.0 || ^14.0 || ^15.0.0-rc.0" } }, "sha512-zGATwmUYd5rgy0G6Zi29GglxFYnn38FQQPjS+gnRPXaVIbck6Dtrjh+cJ3QDsttgUvfuZKpEE03JSrAA7D5QOA=="], - "@sentry/node": ["@sentry/node@10.7.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/instrumentation-amqplib": "0.50.0", "@opentelemetry/instrumentation-connect": "0.47.0", "@opentelemetry/instrumentation-dataloader": "0.21.1", "@opentelemetry/instrumentation-express": "0.52.0", "@opentelemetry/instrumentation-fs": "0.23.0", "@opentelemetry/instrumentation-generic-pool": "0.47.0", "@opentelemetry/instrumentation-graphql": "0.51.0", "@opentelemetry/instrumentation-hapi": "0.50.0", "@opentelemetry/instrumentation-http": "0.203.0", "@opentelemetry/instrumentation-ioredis": "0.51.0", "@opentelemetry/instrumentation-kafkajs": "0.13.0", "@opentelemetry/instrumentation-knex": "0.48.0", "@opentelemetry/instrumentation-koa": "0.51.0", "@opentelemetry/instrumentation-lru-memoizer": "0.48.0", "@opentelemetry/instrumentation-mongodb": "0.56.0", "@opentelemetry/instrumentation-mongoose": "0.50.0", "@opentelemetry/instrumentation-mysql": "0.49.0", "@opentelemetry/instrumentation-mysql2": "0.50.0", "@opentelemetry/instrumentation-pg": "0.55.0", "@opentelemetry/instrumentation-redis": "0.51.0", "@opentelemetry/instrumentation-tedious": "0.22.0", "@opentelemetry/instrumentation-undici": "0.14.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@prisma/instrumentation": "6.14.0", "@sentry/core": "10.7.0", "@sentry/node-core": "10.7.0", "@sentry/opentelemetry": "10.7.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-VtUFyf8avWUqN5RRTTmcU8aGdyNUGHzz/f+3n86BR5gBL3lziKOajyc0VClfc80VLsih+PWQ/5FrIHl+S1S1YQ=="], + "@sentry/node": ["@sentry/node@10.10.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/instrumentation-amqplib": "0.50.0", "@opentelemetry/instrumentation-connect": "0.47.0", "@opentelemetry/instrumentation-dataloader": "0.21.1", "@opentelemetry/instrumentation-express": "0.52.0", "@opentelemetry/instrumentation-fs": "0.23.0", "@opentelemetry/instrumentation-generic-pool": "0.47.0", "@opentelemetry/instrumentation-graphql": "0.51.0", "@opentelemetry/instrumentation-hapi": "0.50.0", "@opentelemetry/instrumentation-http": "0.203.0", "@opentelemetry/instrumentation-ioredis": "0.51.0", "@opentelemetry/instrumentation-kafkajs": "0.13.0", "@opentelemetry/instrumentation-knex": "0.48.0", "@opentelemetry/instrumentation-koa": "0.51.0", "@opentelemetry/instrumentation-lru-memoizer": "0.48.0", "@opentelemetry/instrumentation-mongodb": "0.56.0", "@opentelemetry/instrumentation-mongoose": "0.50.0", "@opentelemetry/instrumentation-mysql": "0.49.0", "@opentelemetry/instrumentation-mysql2": "0.50.0", "@opentelemetry/instrumentation-pg": "0.55.0", "@opentelemetry/instrumentation-redis": "0.51.0", "@opentelemetry/instrumentation-tedious": "0.22.0", "@opentelemetry/instrumentation-undici": "0.14.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@prisma/instrumentation": "6.14.0", "@sentry/core": "10.10.0", "@sentry/node-core": "10.10.0", "@sentry/opentelemetry": "10.10.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-GdI/ELIipKhdL8gdvnRLtz1ItPzAXRCZrvTwGMd5C+kDRALakQIR7pONC9nf5TKCG2UaslHEX+2XDImorhM7OA=="], - "@sentry/node-core": ["@sentry/node-core@10.7.0", "", { "dependencies": { "@sentry/core": "10.7.0", "@sentry/opentelemetry": "10.7.0", "import-in-the-middle": "^1.14.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-iafuG3Fp0pleuk1WaL4UW7wpT6C86pMEQBZ7ARZ7UHc9ujRi/dewKFi0Stu0SxJm6PZ706VZ8Igz9xpvQ0aEEg=="], + "@sentry/node-core": ["@sentry/node-core@10.10.0", "", { "dependencies": { "@sentry/core": "10.10.0", "@sentry/opentelemetry": "10.10.0", "import-in-the-middle": "^1.14.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-7jHM1Is0Si737SVA0sHPg7lj7OmKoNM+f7+E3ySvtHIUeSINZBLM6jg1q57R1kIg8eavpHXudYljRMpuv/8bYA=="], "@sentry/opentelemetry": ["@sentry/opentelemetry@10.7.0", "", { "dependencies": { "@sentry/core": "10.7.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-8SrRZyERDfCYYett6dklGe+qWMDZSytKPIZpS0nDb0IqZGC02ZVIhRISbBTy4Gctowu/gMK9XaOXfBNN0pI1sg=="], @@ -2671,6 +2672,16 @@ "@sentry/bundler-plugin-core/magic-string": ["magic-string@0.30.8", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ=="], + "@sentry/nextjs/@sentry/node": ["@sentry/node@10.7.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/core": "^2.0.0", "@opentelemetry/instrumentation": "^0.203.0", "@opentelemetry/instrumentation-amqplib": "0.50.0", "@opentelemetry/instrumentation-connect": "0.47.0", "@opentelemetry/instrumentation-dataloader": "0.21.1", "@opentelemetry/instrumentation-express": "0.52.0", "@opentelemetry/instrumentation-fs": "0.23.0", "@opentelemetry/instrumentation-generic-pool": "0.47.0", "@opentelemetry/instrumentation-graphql": "0.51.0", "@opentelemetry/instrumentation-hapi": "0.50.0", "@opentelemetry/instrumentation-http": "0.203.0", "@opentelemetry/instrumentation-ioredis": "0.51.0", "@opentelemetry/instrumentation-kafkajs": "0.13.0", "@opentelemetry/instrumentation-knex": "0.48.0", "@opentelemetry/instrumentation-koa": "0.51.0", "@opentelemetry/instrumentation-lru-memoizer": "0.48.0", "@opentelemetry/instrumentation-mongodb": "0.56.0", "@opentelemetry/instrumentation-mongoose": "0.50.0", "@opentelemetry/instrumentation-mysql": "0.49.0", "@opentelemetry/instrumentation-mysql2": "0.50.0", "@opentelemetry/instrumentation-pg": "0.55.0", "@opentelemetry/instrumentation-redis": "0.51.0", "@opentelemetry/instrumentation-tedious": "0.22.0", "@opentelemetry/instrumentation-undici": "0.14.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0", "@prisma/instrumentation": "6.14.0", "@sentry/core": "10.7.0", "@sentry/node-core": "10.7.0", "@sentry/opentelemetry": "10.7.0", "import-in-the-middle": "^1.14.2", "minimatch": "^9.0.0" } }, "sha512-VtUFyf8avWUqN5RRTTmcU8aGdyNUGHzz/f+3n86BR5gBL3lziKOajyc0VClfc80VLsih+PWQ/5FrIHl+S1S1YQ=="], + + "@sentry/node/@sentry/core": ["@sentry/core@10.10.0", "", {}, "sha512-4O1O6my/vYE98ZgfEuLEwOOuHzqqzfBT6IdRo1yiQM7/AXcmSl0H/k4HJtXCiCTiHm+veEuTDBHp0GQZmpIbtA=="], + + "@sentry/node/@sentry/opentelemetry": ["@sentry/opentelemetry@10.10.0", "", { "dependencies": { "@sentry/core": "10.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-EQ5/1Ps4n1JosmaDiFCyb5iByjjKja2pnmeMiLzTDZ5Zikjs/3GKzmh+SgTRFLOm6yKgQps0GdiCH2gxdrbONg=="], + + "@sentry/node-core/@sentry/core": ["@sentry/core@10.10.0", "", {}, "sha512-4O1O6my/vYE98ZgfEuLEwOOuHzqqzfBT6IdRo1yiQM7/AXcmSl0H/k4HJtXCiCTiHm+veEuTDBHp0GQZmpIbtA=="], + + "@sentry/node-core/@sentry/opentelemetry": ["@sentry/opentelemetry@10.10.0", "", { "dependencies": { "@sentry/core": "10.10.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-EQ5/1Ps4n1JosmaDiFCyb5iByjjKja2pnmeMiLzTDZ5Zikjs/3GKzmh+SgTRFLOm6yKgQps0GdiCH2gxdrbONg=="], + "@sentry/webpack-plugin/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], "@smithy/middleware-retry/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], @@ -2957,6 +2968,8 @@ "@sentry/bundler-plugin-core/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "@sentry/nextjs/@sentry/node/@sentry/node-core": ["@sentry/node-core@10.7.0", "", { "dependencies": { "@sentry/core": "10.7.0", "@sentry/opentelemetry": "10.7.0", "import-in-the-middle": "^1.14.2" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/context-async-hooks": "^1.30.1 || ^2.0.0", "@opentelemetry/core": "^1.30.1 || ^2.0.0", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/resources": "^1.30.1 || ^2.0.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.0.0", "@opentelemetry/semantic-conventions": "^1.34.0" } }, "sha512-iafuG3Fp0pleuk1WaL4UW7wpT6C86pMEQBZ7ARZ7UHc9ujRi/dewKFi0Stu0SxJm6PZ706VZ8Igz9xpvQ0aEEg=="], + "@types/pg-pool/@types/pg/pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng=="], "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], diff --git a/components/emails/thirty-day-deletion-notice.tsx b/components/emails/org-deletion-notice.tsx similarity index 86% rename from components/emails/thirty-day-deletion-notice.tsx rename to components/emails/org-deletion-notice.tsx index f9dd6bc..bd51f43 100644 --- a/components/emails/thirty-day-deletion-notice.tsx +++ b/components/emails/org-deletion-notice.tsx @@ -16,15 +16,15 @@ interface ThirtyDayDeletionNoticeProps { organizationName?: string; } -export const ThirtyDayDeletionNotice = ({ +export const OrgDeletionNotice = ({ firstName, email, organizationName, }: ThirtyDayDeletionNoticeProps) => { const isOrganization = !!organizationName; const previewText = isOrganization - ? `Your Manage organization "${organizationName}" will be deleted in 30 days due to inactivity` - : "Your Manage account will be deleted in 30 days due to inactivity"; + ? `Your Manage organization "${organizationName}" will be deleted in 60 days due to inactivity` + : "Your Manage account will be deleted in 60 days due to inactivity"; return ( @@ -45,26 +45,26 @@ export const ThirtyDayDeletionNotice = ({ {isOrganization - ? `We noticed that your Manage organization "${organizationName}" has been inactive for 30 days. + ? `We noticed that your Manage organization "${organizationName}" has been inactive for 60 days. To keep our platform secure and efficient, we automatically remove inactive organizations.` - : `We noticed that your Manage account (${email}) has been inactive for 30 days. + : `We noticed that your Manage account (${email}) has been inactive for 60 days. To keep our platform secure and efficient, we automatically remove inactive accounts.`} {isOrganization - ? `Your organization "${organizationName}" will be permanently deleted in 30 days` - : "Your account will be permanently deleted in 30 days"} + ? `Your organization "${organizationName}" will be permanently deleted in 60 days` + : "Your account will be permanently deleted in 60 days"} {" "} unless you log in and use the platform. {isOrganization - ? `If you'd like to keep your organization, simply log in to Manage within the next 30 days. + ? `If you'd like to keep your organization, simply log in to Manage within the next 60 days. All your projects, tasks, and data will remain intact.` - : `If you'd like to keep your account, simply log in to Manage within the next 30 days. + : `If you'd like to keep your account, simply log in to Manage within the next 60 days. All your projects, tasks, and data will remain intact.`} @@ -170,20 +170,20 @@ Hello ${firstName || "there"}, ${ isOrganization - ? `We noticed that your Manage organization "${organizationName}" has been inactive for 30 days. To keep our platform secure and efficient, we automatically remove inactive organizations.` - : `We noticed that your Manage account (${email}) has been inactive for 30 days. To keep our platform secure and efficient, we automatically remove inactive accounts.` + ? `We noticed that your Manage organization "${organizationName}" has been inactive for 60 days. To keep our platform secure and efficient, we automatically remove inactive organizations.` + : `We noticed that your Manage account (${email}) has been inactive for 60 days. To keep our platform secure and efficient, we automatically remove inactive accounts.` } ${ isOrganization - ? `Your organization "${organizationName}" will be permanently deleted in 30 days` - : "Your account will be permanently deleted in 30 days" + ? `Your organization "${organizationName}" will be permanently deleted in 60 days` + : "Your account will be permanently deleted in 60 days" } unless you log in and use the platform. ${ isOrganization - ? `If you'd like to keep your organization, simply log in to Manage within the next 30 days. All your projects, tasks, and data will remain intact.` - : `If you'd like to keep your account, simply log in to Manage within the next 30 days. All your projects, tasks, and data will remain intact.` + ? `If you'd like to keep your organization, simply log in to Manage within the next 60 days. All your projects, tasks, and data will remain intact.` + : `If you'd like to keep your account, simply log in to Manage within the next 60 days. All your projects, tasks, and data will remain intact.` } ${isOrganization ? "Keep My Organization Active" : "Keep My Account Active"}: https://managee.xyz/start @@ -198,4 +198,4 @@ Best regards, The Manage Team`; } -export default ThirtyDayDeletionNotice; +export default OrgDeletionNotice; diff --git a/components/today/task-item.tsx b/components/today/task-item.tsx new file mode 100644 index 0000000..c58955f --- /dev/null +++ b/components/today/task-item.tsx @@ -0,0 +1,34 @@ +import Link from "next/link"; + +export function TaskItem( + tenant: string, + task: { + name: string; + id: number; + taskList: { + id: number; + name: string; + status: string; + project: { id: number; name: string }; + }; + }, +) { + return ( + +
+
+
+

{task.name}

+
+ {task.taskList.project.name}{" "} + • {task.taskList.name} +
+
+
+
+ + ); +} diff --git a/next.config.mjs b/next.config.mjs index 5b80bcd..fac6811 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,8 @@ import { withSentryConfig } from "@sentry/nextjs"; const nextConfig = { + typedRoutes: true, + rewrites: async () => { return [ { diff --git a/package.json b/package.json index b9f7fd7..47597b0 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@radix-ui/react-tooltip": "^1.2.3", "@react-email/components": "^0.1.1", "@sentry/nextjs": "^10", + "@sentry/node": "^10.10.0", "@tanstack/react-query": "^5.71.10", "@trpc/client": "^11.0.2", "@trpc/server": "^11.0.2", From 1b90796179991e58d496ea94786dde9780a9c4ef Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Wed, 10 Sep 2025 07:56:02 +1000 Subject: [PATCH 2/4] Disable types routes --- next.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/next.config.mjs b/next.config.mjs index fac6811..fddf22b 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,7 +1,7 @@ import { withSentryConfig } from "@sentry/nextjs"; const nextConfig = { - typedRoutes: true, + typedRoutes: false, rewrites: async () => { return [ From 6cf5d932142fdb9a357a0ab5a6fd1f06c8c5568f Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Thu, 11 Sep 2025 18:26:36 +1000 Subject: [PATCH 3/4] Add support for comment threads --- README.md | 2 +- components/core/posthog-provider.tsx | 10 +- components/project/comment/comment.tsx | 85 ++++-- .../project/comment/comments-section.tsx | 31 ++- components/project/comment/comments.tsx | 254 +++++++++++++----- trpc/routers/_app.ts | 2 + trpc/routers/projects.ts | 19 +- 7 files changed, 312 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index a13206c..698634d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Manage is an open-source project management platform. With its intuitive interfa - [x] Activity logs - [x] Search - [x] Permissions -- [ ] Notifications +- [x] Notifications - [ ] Posts & files ## Development diff --git a/components/core/posthog-provider.tsx b/components/core/posthog-provider.tsx index bff9161..4557be3 100644 --- a/components/core/posthog-provider.tsx +++ b/components/core/posthog-provider.tsx @@ -1,12 +1,16 @@ "use client"; +import { usePathname, useSearchParams } from "next/navigation"; import posthog from "posthog-js"; import { PostHogProvider as PHProvider, usePostHog } from "posthog-js/react"; import { Suspense, useEffect } from "react"; -import { usePathname, useSearchParams } from "next/navigation"; export function PostHogProvider({ children }: { children: React.ReactNode }) { useEffect(() => { + if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) { + return; + } + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, { api_host: "/ingest", ui_host: "https://us.posthog.com", @@ -16,11 +20,13 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) { }); }, []); - return ( + return process.env.NEXT_PUBLIC_POSTHOG_KEY ? ( {children} + ) : ( + children ); } diff --git a/components/project/comment/comment.tsx b/components/project/comment/comment.tsx index babfe51..c244172 100644 --- a/components/project/comment/comment.tsx +++ b/components/project/comment/comment.tsx @@ -11,7 +11,21 @@ import { Button } from "@/components/ui/button"; import { displayMutationError } from "@/lib/utils/error"; import { useTRPC } from "@/trpc/client"; -export default function CommentForm({ roomId }: { roomId: string }) { +interface CommentFormProps { + roomId: string; + parentCommentId?: number; + onCancel?: () => void; + onSuccess?: () => void; + isReply?: boolean; +} + +export default function CommentForm({ + roomId, + parentCommentId, + onCancel, + onSuccess, + isReply = false, +}: CommentFormProps) { const { projectId } = useParams(); const { user: creator } = useUser(); const [content, setContent] = useState(""); @@ -25,29 +39,39 @@ export default function CommentForm({ roomId }: { roomId: string }) { }), ); + const submitRoomId = parentCommentId ? `comment-${parentCommentId}` : roomId; + return (
{ await addComment.mutateAsync({ - roomId, + roomId: submitRoomId, content: formData.get("content") as string, metadata: formData.get("metadata") as string, projectId: +projectId!, }); + queryClient.invalidateQueries({ - queryKey: trpc.projects.getComments.queryKey({ - roomId, - }), + queryKey: trpc.projects.getComments.queryKey({ roomId }), }); + + if (parentCommentId) { + queryClient.invalidateQueries({ + queryKey: trpc.projects.getComments.queryKey({ + roomId: `comment-${parentCommentId}`, + }), + }); + } + setContent(""); formRef.current?.reset(); + onSuccess?.(); + if (isReply) onCancel?.(); }} >
-
- {creator ? : null}
@@ -56,17 +80,40 @@ export default function CommentForm({ roomId }: { roomId: string }) { allowImageUpload onContentChange={setContent} /> - + {isReply ? ( +
+ + +
+ ) : ( + + )}
diff --git a/components/project/comment/comments-section.tsx b/components/project/comment/comments-section.tsx index fdcf869..6b51f3d 100644 --- a/components/project/comment/comments-section.tsx +++ b/components/project/comment/comments-section.tsx @@ -1,14 +1,18 @@ "use client"; -import CommentForm from "./comment"; -import { Comments } from "./comments"; -import { useTRPC } from "@/trpc/client"; import { useQuery } from "@tanstack/react-query"; +import { MessageSquareIcon } from "lucide-react"; import { useParams } from "next/navigation"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { useTRPC } from "@/trpc/client"; +import CommentForm from "./comment"; +import { Comments } from "./comments"; export function CommentsSection({ roomId }: { roomId: string }) { const { projectId } = useParams(); const trpc = useTRPC(); + const [showCommentForm, setShowCommentForm] = useState(false); const { data: project } = useQuery( trpc.projects.getProjectById.queryOptions({ @@ -17,9 +21,26 @@ export function CommentsSection({ roomId }: { roomId: string }) { ); return ( -
+
- {project?.canEdit && } + {project?.canEdit && + (!showCommentForm ? ( + + ) : ( + setShowCommentForm(false)} + onCancel={() => setShowCommentForm(false)} + /> + ))}
); } diff --git a/components/project/comment/comments.tsx b/components/project/comment/comments.tsx index 6ece8c9..b3c605b 100644 --- a/components/project/comment/comments.tsx +++ b/components/project/comment/comments.tsx @@ -1,9 +1,14 @@ "use client"; +import { useUser } from "@clerk/nextjs"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { CircleEllipsisIcon, MessageSquareIcon } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useState } from "react"; import { HtmlPreview } from "@/components/core/html-view"; -import { SpinnerWithSpacing } from "@/components/core/loaders"; import { UserAvatar } from "@/components/core/user-avatar"; import { DeleteButton } from "@/components/form/button"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -13,10 +18,172 @@ import { import { cn } from "@/lib/utils"; import { displayMutationError } from "@/lib/utils/error"; import { useTRPC } from "@/trpc/client"; -import { useUser } from "@clerk/nextjs"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { CircleEllipsisIcon } from "lucide-react"; -import { useParams } from "next/navigation"; +import type { RouterOutputs } from "@/trpc/routers/_app"; +import CommentForm from "./comment"; + +type CommentWithReplies = RouterOutputs["projects"]["getComments"][number]; + +function CommentThread({ + comment, + depth = 0, + onDelete, + roomId, + projectId, + onRefresh, +}: { + comment: CommentWithReplies; + depth?: number; + onDelete: (id: number) => void; + roomId: string; + projectId: string; + onRefresh: () => void; +}) { + const { user } = useUser(); + const [showReplyForm, setShowReplyForm] = useState(false); + const [showReplies, setShowReplies] = useState(false); + + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [shouldLoadReplies, setShouldLoadReplies] = useState(false); + + const { data: replies = [], isLoading: repliesLoading } = useQuery({ + ...trpc.projects.getComments.queryOptions({ + roomId: `comment-${comment.id}`, + }), + enabled: shouldLoadReplies, + }); + + const loadReplies = () => { + if (!shouldLoadReplies) { + setShouldLoadReplies(true); + setShowReplies(true); + } + }; + + const toggleReplies = () => { + if (!showReplies) { + loadReplies(); + } else { + setShowReplies(false); + // Reset loading state so replies can be loaded again + setShouldLoadReplies(false); + } + }; + + return ( +
0 && "ml-4 border-l-2 border-muted pl-2", + )} + > +
+ {comment.creator ? : null} +
+
+ + {comment.creator?.firstName ?? "User"} + + + {new Date(comment.createdAt).toLocaleString()} + +
+ + + {/* Reply button */} +
+ + {comment.replyCount > 0 && ( + + )} +
+ + {/* Reply form */} + {showReplyForm && ( + setShowReplyForm(false)} + onSuccess={() => { + setShowReplyForm(false); + onRefresh(); + if (!shouldLoadReplies) { + setShouldLoadReplies(true); + setShowReplies(true); + } + queryClient.invalidateQueries({ + queryKey: trpc.projects.getComments.queryKey({ + roomId: `comment-${comment.id}`, + }), + }); + }} + /> + )} + + {comment.creator?.id === user?.id ? ( + + + + + + +
{ + onDelete(comment.id); + }} + className="w-full" + > + + +
+
+
+ ) : null} + + {/* Render nested replies */} + {showReplies && replies.length > 0 && ( +
+ {replies.map((reply, _index) => ( +
+ +
+ ))} +
+ )} +
+
+
+ ); +} export function Comments({ roomId, @@ -26,11 +193,10 @@ export function Comments({ className?: string; }) { const { projectId } = useParams(); - const { user } = useUser(); const queryClient = useQueryClient(); const trpc = useTRPC(); - const { data: comments = [], isLoading } = useQuery( + const { data: comments = [], refetch } = useQuery( trpc.projects.getComments.queryOptions({ roomId, }), @@ -39,71 +205,35 @@ export function Comments({ const deleteComment = useMutation( trpc.projects.deleteComment.mutationOptions({ onSuccess: () => { + // Force refetch of current comments + refetch(); + // Also invalidate all comments queries queryClient.invalidateQueries({ - queryKey: trpc.projects.getComments.queryKey({ - roomId, - }), + queryKey: ["projects", "getComments"], }); }, onError: displayMutationError, }), ); - if (isLoading) { - return ; - } + const handleDelete = async (commentId: number) => { + await deleteComment.mutateAsync({ + id: commentId, + projectId: +projectId!, + }); + }; return ( -
+
{comments.map((comment) => ( -
-
-
- {new Date(comment.createdAt).toLocaleString()} -
- {comment.creator ? : null} -
-
- - {comment.creator?.firstName ?? "User"} - - - {new Date(comment.createdAt).toLocaleString()} - -
- - - {comment.creator?.id === user?.id ? ( - - - - - - -
{ - await deleteComment.mutateAsync({ - id: comment.id, - projectId: +projectId!, - }); - }} - className="w-full" - > - - -
-
-
- ) : null} -
-
-
+ refetch()} + /> ))}
); diff --git a/trpc/routers/_app.ts b/trpc/routers/_app.ts index 2c61998..6d50be7 100644 --- a/trpc/routers/_app.ts +++ b/trpc/routers/_app.ts @@ -1,3 +1,4 @@ +import type { inferRouterOutputs } from "@trpc/server"; import { createTRPCRouter } from "../init"; import { eventsRouter } from "./events"; import { permissionsRouter } from "./permissions"; @@ -18,3 +19,4 @@ export const appRouter = createTRPCRouter({ }); export type AppRouter = typeof appRouter; +export type RouterOutputs = inferRouterOutputs; diff --git a/trpc/routers/projects.ts b/trpc/routers/projects.ts index 6e796dc..a94ce8d 100644 --- a/trpc/routers/projects.ts +++ b/trpc/routers/projects.ts @@ -1,5 +1,5 @@ -import { and, desc, eq } from "drizzle-orm"; import { TRPCError } from "@trpc/server"; +import { and, desc, eq } from "drizzle-orm"; import { z } from "zod"; import { activity, @@ -259,7 +259,22 @@ export const projectsRouter = createTRPCRouter({ }, }); - return comments; + const commentsWithReplyCount = await Promise.all( + comments.map(async (commentItem) => { + const replyCount = await ctx.db.query.comment.findMany({ + where: eq(comment.roomId, `comment-${commentItem.id}`), + columns: { id: true }, + }); + + return { + ...commentItem, + replyCount: replyCount.length, + replies: [], // Always empty, loaded on demand + }; + }), + ); + + return commentsWithReplyCount; }), addComment: protectedProcedure .input( From bd9994c14bdc9b5176cc4bce34b169036bf54064 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Thu, 11 Sep 2025 18:51:21 +1000 Subject: [PATCH 4/4] Address code review --- .../jobs/account/mark-for-deletion/route.ts | 4 +-- .../api/jobs/user/mark-for-deletion/route.ts | 4 +-- app/(dashboard)/[tenant]/today/page.tsx | 8 +++-- components/emails/org-deletion-notice.tsx | 2 +- components/today/task-item.tsx | 12 ++++--- trpc/routers/projects.ts | 31 ++++++++++--------- 6 files changed, 35 insertions(+), 26 deletions(-) diff --git a/app/(api)/api/jobs/account/mark-for-deletion/route.ts b/app/(api)/api/jobs/account/mark-for-deletion/route.ts index 6a9cd1e..a024c88 100644 --- a/app/(api)/api/jobs/account/mark-for-deletion/route.ts +++ b/app/(api)/api/jobs/account/mark-for-deletion/route.ts @@ -3,8 +3,8 @@ import { serve } from "@upstash/workflow/nextjs"; import { and, eq, isNull, lte } from "drizzle-orm"; import { Resend } from "resend"; import { + DeletionNoticePlainText, OrgDeletionNotice, - thirtyDayDeletionNoticePlainText, } from "@/components/emails/org-deletion-notice"; import { SevenDayWarning, @@ -142,7 +142,7 @@ export const { POST } = serve(async (context) => { email: contactEmail, organizationName: org.name, }), - text: thirtyDayDeletionNoticePlainText({ + text: DeletionNoticePlainText({ firstName: firstName, email: contactEmail, organizationName: org.name, diff --git a/app/(api)/api/jobs/user/mark-for-deletion/route.ts b/app/(api)/api/jobs/user/mark-for-deletion/route.ts index bff34f4..72d288b 100644 --- a/app/(api)/api/jobs/user/mark-for-deletion/route.ts +++ b/app/(api)/api/jobs/user/mark-for-deletion/route.ts @@ -3,8 +3,8 @@ import { serve } from "@upstash/workflow/nextjs"; import { and, eq, isNull, lte } from "drizzle-orm"; import { Resend } from "resend"; import { + DeletionNoticePlainText, OrgDeletionNotice, - thirtyDayDeletionNoticePlainText, } from "@/components/emails/org-deletion-notice"; import { SevenDayWarning, @@ -156,7 +156,7 @@ export const { POST } = serve(async (context) => { email: contactEmail, // organizationName is undefined for user deletion }), - text: thirtyDayDeletionNoticePlainText({ + text: DeletionNoticePlainText({ firstName: firstName, email: contactEmail, // organizationName is undefined for user deletion diff --git a/app/(dashboard)/[tenant]/today/page.tsx b/app/(dashboard)/[tenant]/today/page.tsx index b516b58..e5abbb5 100644 --- a/app/(dashboard)/[tenant]/today/page.tsx +++ b/app/(dashboard)/[tenant]/today/page.tsx @@ -152,7 +152,9 @@ export default function Today() { titleClassName="text-red-600 dark:text-red-500" titleIcon={} > - {overDue.map((task) => TaskItem(tenant, task))} + {overDue.map((task) => ( + + ))} ) : null} @@ -162,7 +164,9 @@ export default function Today() { titleClassName="text-orange-600 dark:text-orange-500" titleIcon={} > - {dueToday.map((task) => TaskItem(tenant, task))} + {dueToday.map((task) => ( + + ))} ) : null} diff --git a/components/emails/org-deletion-notice.tsx b/components/emails/org-deletion-notice.tsx index bd51f43..e18d9c4 100644 --- a/components/emails/org-deletion-notice.tsx +++ b/components/emails/org-deletion-notice.tsx @@ -157,7 +157,7 @@ const footer = { margin: "32px 0 0", }; -export function thirtyDayDeletionNoticePlainText({ +export function DeletionNoticePlainText({ firstName, email, organizationName, diff --git a/components/today/task-item.tsx b/components/today/task-item.tsx index c58955f..503b9fa 100644 --- a/components/today/task-item.tsx +++ b/components/today/task-item.tsx @@ -1,7 +1,10 @@ import Link from "next/link"; -export function TaskItem( - tenant: string, +export function TaskItem({ + task, + tenant, +}: { + tenant: string; task: { name: string; id: number; @@ -11,12 +14,11 @@ export function TaskItem( status: string; project: { id: number; name: string }; }; - }, -) { + }; +}) { return (
diff --git a/trpc/routers/projects.ts b/trpc/routers/projects.ts index a94ce8d..1e8ad18 100644 --- a/trpc/routers/projects.ts +++ b/trpc/routers/projects.ts @@ -1,5 +1,5 @@ import { TRPCError } from "@trpc/server"; -import { and, desc, eq } from "drizzle-orm"; +import { and, count, desc, eq, inArray } from "drizzle-orm"; import { z } from "zod"; import { activity, @@ -259,22 +259,25 @@ export const projectsRouter = createTRPCRouter({ }, }); - const commentsWithReplyCount = await Promise.all( - comments.map(async (commentItem) => { - const replyCount = await ctx.db.query.comment.findMany({ - where: eq(comment.roomId, `comment-${commentItem.id}`), - columns: { id: true }, - }); + const replyRoomIds = comments.map((c) => `comment-${c.id}`); + + const counts = replyRoomIds.length + ? await ctx.db + .select({ roomId: comment.roomId, cnt: count() }) + .from(comment) + .where(inArray(comment.roomId, replyRoomIds)) + .groupBy(comment.roomId) + : []; - return { - ...commentItem, - replyCount: replyCount.length, - replies: [], // Always empty, loaded on demand - }; - }), + const countByRoomId = new Map( + counts.map((r) => [r.roomId, Number(r.cnt)]), ); - return commentsWithReplyCount; + return comments.map((commentItem) => ({ + ...commentItem, + replyCount: countByRoomId.get(`comment-${commentItem.id}`) ?? 0, + replies: [], // Always empty, loaded on demand + })); }), addComment: protectedProcedure .input(