From 669657ca30582f96d5d0455e83138de6e392dfb7 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Fri, 19 Dec 2025 11:30:48 +1100 Subject: [PATCH 1/4] Remove next version in landing page --- app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/page.tsx b/app/page.tsx index ccd651b..2d47620 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -314,7 +314,7 @@ export default async function Home() {
- Next.js 15 + Next.js
From 40fde4a13754762a376141ede7d957287de759df Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 24 Jan 2026 10:50:42 +1100 Subject: [PATCH 2/4] Update readme --- README.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/README.md b/README.md index c9ae328..82b4b0c 100644 --- a/README.md +++ b/README.md @@ -4,23 +4,6 @@ Manage is an open-source project management platform. With its intuitive interface, customizable features, and emphasis on collaboration, Manage empowers teams to enhance productivity and achieve project success. Enjoy the benefits of open-source flexibility, data security, and a thriving community while managing your projects efficiently with Manage. -> **Warning** -> This app is a work in progress. I'm building this in public. You can follow the progress on Twitter [@arjunz](https://twitter.com/arjunz). -> See the roadmap below. - -## V1 Roadmap - -- [x] Basic Project management -- [x] Task lists and tasks -- [x] Files - Uploading and sharing files -- [x] Comments -- [x] Events / Calendar -- [x] Activity logs -- [x] Search -- [x] Permissions -- [x] Notifications -- [x] Posts - ## Development ### Environment From 3bb8a11c145b0fec9b207c428fb1d7a887874613 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 24 Jan 2026 15:46:36 +1100 Subject: [PATCH 3/4] Simplify to focus on self hosting --- .env.example | 31 + CLAUDE.md | 3 +- README.md | 28 +- .../calendar/[ownerId]/[projectId]/route.ts | 8 +- .../jobs/account/mark-for-deletion/route.ts | 337 ----- .../jobs/blobs/delete-owner-blobs/route.ts | 145 -- app/(api)/api/jobs/daily-summary/route.ts | 187 --- .../api/jobs/user/mark-for-deletion/route.ts | 347 ----- app/(api)/api/webhook/auth/route.ts | 410 ------ app/(auth)/accept-invite/page.tsx | 95 ++ app/(auth)/sign-in/page.tsx | 111 ++ .../projects/[projectId]/events/page.tsx | 12 +- .../projects/[projectId]/settings/page.tsx | 18 +- app/(dashboard)/[tenant]/settings/page.tsx | 18 +- app/(dashboard)/[tenant]/today/page.tsx | 24 +- app/api/auth/[...all]/route.ts | 4 + app/layout.tsx | 17 +- app/page.tsx | 9 +- app/start/page.tsx | 13 +- build.Dockerfile | 6 - bun.lock | 123 +- components/core/billing.tsx | 24 +- components/core/cmd-menu.tsx | 4 +- components/core/org-switcher.tsx | 175 +++ components/core/permissions-management.tsx | 11 +- components/core/protected.tsx | 27 +- components/core/search-panel.tsx | 4 +- components/core/user-avatar.tsx | 34 +- components/core/user-menu.tsx | 58 + components/editor/mention-suggestion-menu.tsx | 2 +- components/layout/header.tsx | 6 +- components/layout/navbar.tsx | 20 +- components/project/comment/comment.tsx | 6 +- components/project/comment/comments.tsx | 6 +- components/project/events/events-list.tsx | 6 +- components/project/posts/posts-list.tsx | 6 +- components/project/shared/creator-details.tsx | 2 +- components/project/shared/user-badge.tsx | 2 +- components/settings/team-settings.tsx | 318 +++++ drizzle/0000_fantastic_nightshade.sql | 230 +++ drizzle/0000_long_bill_hollister.sql | 160 --- drizzle/0001_striped_blizzard.sql | 2 - drizzle/0002_warm_rocket_racer.sql | 6 - drizzle/0003_condemned_mysterio.sql | 4 - drizzle/0004_true_kingpin.sql | 6 - drizzle/0005_fuzzy_onslaught.sql | 1 - drizzle/0006_noisy_jack_murdock.sql | 1 - drizzle/0007_brainy_freak.sql | 13 - drizzle/0008_soft_serpent_society.sql | 16 - drizzle/meta/0000_snapshot.json | 981 +++++++++---- drizzle/meta/0001_snapshot.json | 1227 ---------------- drizzle/meta/0002_snapshot.json | 1240 ---------------- drizzle/meta/0003_snapshot.json | 1252 ----------------- drizzle/meta/0004_snapshot.json | 1012 ------------- drizzle/meta/0005_snapshot.json | 930 ------------ drizzle/meta/0006_snapshot.json | 936 ------------ drizzle/meta/0007_snapshot.json | 1044 -------------- drizzle/meta/0008_snapshot.json | 1164 --------------- drizzle/meta/_journal.json | 60 +- drizzle/schema.ts | 210 ++- drizzle/types.ts | 22 +- hooks/use-tasks.tsx | 8 +- lib/auth/client.ts | 15 + lib/auth/index.ts | 107 ++ lib/utils/useDatabase.ts | 68 +- lib/utils/useOwner.ts | 24 +- lib/utils/useUser.ts | 41 +- lib/utils/workflow.ts | 50 - ops/drizzle.config.ts | 13 - ops/drizzle/0000_tearful_komodo.sql | 13 - ops/drizzle/0001_overrated_siren.sql | 8 - ops/drizzle/0002_wide_wilson_fisk.sql | 2 - ops/drizzle/0003_oval_tyger_tiger.sql | 2 - ops/drizzle/meta/0000_snapshot.json | 102 -- ops/drizzle/meta/0001_snapshot.json | 153 -- ops/drizzle/meta/0002_snapshot.json | 165 --- ops/drizzle/meta/0003_snapshot.json | 177 --- ops/drizzle/meta/_journal.json | 34 - ops/drizzle/schema.ts | 27 - ops/drizzle/types.ts | 4 - ops/useOps.ts | 49 - package.json | 9 +- proxy.ts | 51 +- scripts/migrate-all-tenants.ts | 181 --- scripts/migrate-ops.ts | 54 - scripts/post-upgrade-maintenance.ts | 174 --- trpc/init.ts | 34 +- trpc/routers/events.ts | 2 +- trpc/routers/permissions.ts | 7 +- trpc/routers/posts.ts | 6 +- trpc/routers/projects.ts | 3 +- trpc/routers/settings.ts | 34 - trpc/routers/tasks.ts | 4 +- trpc/routers/user.ts | 51 +- 94 files changed, 2329 insertions(+), 12517 deletions(-) create mode 100644 .env.example delete mode 100644 app/(api)/api/jobs/account/mark-for-deletion/route.ts delete mode 100644 app/(api)/api/jobs/blobs/delete-owner-blobs/route.ts delete mode 100644 app/(api)/api/jobs/daily-summary/route.ts delete mode 100644 app/(api)/api/jobs/user/mark-for-deletion/route.ts delete mode 100644 app/(api)/api/webhook/auth/route.ts create mode 100644 app/(auth)/accept-invite/page.tsx create mode 100644 app/(auth)/sign-in/page.tsx create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 components/core/org-switcher.tsx create mode 100644 components/core/user-menu.tsx create mode 100644 components/settings/team-settings.tsx create mode 100644 drizzle/0000_fantastic_nightshade.sql delete mode 100644 drizzle/0000_long_bill_hollister.sql delete mode 100644 drizzle/0001_striped_blizzard.sql delete mode 100644 drizzle/0002_warm_rocket_racer.sql delete mode 100644 drizzle/0003_condemned_mysterio.sql delete mode 100644 drizzle/0004_true_kingpin.sql delete mode 100644 drizzle/0005_fuzzy_onslaught.sql delete mode 100644 drizzle/0006_noisy_jack_murdock.sql delete mode 100644 drizzle/0007_brainy_freak.sql delete mode 100644 drizzle/0008_soft_serpent_society.sql delete mode 100644 drizzle/meta/0001_snapshot.json delete mode 100644 drizzle/meta/0002_snapshot.json delete mode 100644 drizzle/meta/0003_snapshot.json delete mode 100644 drizzle/meta/0004_snapshot.json delete mode 100644 drizzle/meta/0005_snapshot.json delete mode 100644 drizzle/meta/0006_snapshot.json delete mode 100644 drizzle/meta/0007_snapshot.json delete mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 lib/auth/client.ts create mode 100644 lib/auth/index.ts delete mode 100644 lib/utils/workflow.ts delete mode 100644 ops/drizzle.config.ts delete mode 100644 ops/drizzle/0000_tearful_komodo.sql delete mode 100644 ops/drizzle/0001_overrated_siren.sql delete mode 100644 ops/drizzle/0002_wide_wilson_fisk.sql delete mode 100644 ops/drizzle/0003_oval_tyger_tiger.sql delete mode 100644 ops/drizzle/meta/0000_snapshot.json delete mode 100644 ops/drizzle/meta/0001_snapshot.json delete mode 100644 ops/drizzle/meta/0002_snapshot.json delete mode 100644 ops/drizzle/meta/0003_snapshot.json delete mode 100644 ops/drizzle/meta/_journal.json delete mode 100644 ops/drizzle/schema.ts delete mode 100644 ops/drizzle/types.ts delete mode 100644 ops/useOps.ts delete mode 100644 scripts/migrate-all-tenants.ts delete mode 100644 scripts/migrate-ops.ts delete mode 100644 scripts/post-upgrade-maintenance.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c9fc225 --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# Database +DATABASE_URL=postgres://managee:password@localhost:5432/managee + +# Application +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# Better Auth +BETTER_AUTH_SECRET=generate-a-random-secret-here +BETTER_AUTH_URL=http://localhost:3000 + +# Set to "true" to disable new user signups (existing users can still sign in) +# DISABLE_SIGNUPS=true + +# Email (Resend) +RESEND_API_KEY=re_xxxxx +EMAIL_FROM="Manage " + +# S3-compatible storage (MinIO for self-hosted) +S3_ENDPOINT=http://localhost:9000 +S3_ACCESS_KEY_ID=minioadmin +S3_SECRET_ACCESS_KEY=minioadmin +S3_BUCKET_NAME=managee +S3_REGION=us-east-1 + +# Optional: TurboWire for real-time notifications +# TURBOWIRE_URL=wss://your-turbowire-instance +# TURBOWIRE_SECRET=your-secret + +# Optional: Sentry for error tracking +# SENTRY_DSN=your-dsn +# SENTRY_AUTH_TOKEN=your-token diff --git a/CLAUDE.md b/CLAUDE.md index 34ae776..3d82b3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,12 +7,11 @@ - **Framework**: Next.js 16 with App Router - **Language**: TypeScript - **Database**: PostgreSQL with Drizzle ORM -- **Authentication**: Clerk (with organization support) +- **Authentication**: Better-auth - **Styling**: Tailwind CSS + Shadcn - **Real-time**: TurboWire for WebSockets - **Email**: React Email + Resend - **File Storage**: S3-compatible storage -- **Search**: Upstash Search for full-text search with filtering - **Monitoring**: Sentry - **Linting**: Biome - Bun as the runtime and package manager diff --git a/README.md b/README.md index 82b4b0c..7e51d0e 100644 --- a/README.md +++ b/README.md @@ -9,33 +9,7 @@ Manage is an open-source project management platform. With its intuitive interfa ### Environment ``` -# Auth -NEXT_PUBLIC_APP_URL= -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= -CLERK_SECRET_KEY= - -# Database -DATABASE_URL= - -# Any S3 compatible storage -S3_ENDPOINT=" -S3_ACCESS_KEY_ID="" -S3_SECRET_ACCESS_KEY="" -S3_BUCKET_NAME="" - -# TurboWire for Websockets -TURBOWIRE_DOMAIN= -TURBOWIRE_SIGNING_KEY= -TURBOWIRE_BROADCAST_KEY= - -# Email -RESEND_API_KEY= - -# Workflows -QSTASH_URL= -QSTASH_TOKEN= -QSTASH_CURRENT_SIGNING_KEY= -QSTASH_NEXT_SIGNING_KEY= +# See .env.example for all available environment variables ``` ### Run using Docker diff --git a/app/(api)/api/calendar/[ownerId]/[projectId]/route.ts b/app/(api)/api/calendar/[ownerId]/[projectId]/route.ts index 2d64353..1def26c 100644 --- a/app/(api)/api/calendar/[ownerId]/[projectId]/route.ts +++ b/app/(api)/api/calendar/[ownerId]/[projectId]/route.ts @@ -1,5 +1,5 @@ import { calendarEvent, project, task, taskList, user } from "@/drizzle/schema"; -import { getDatabaseForOwner } from "@/lib/utils/useDatabase"; +import { database } from "@/lib/utils/useDatabase"; import { and, desc, eq, lte } from "drizzle-orm"; import ical, { ICalCalendarMethod } from "ical-generator"; import type { NextRequest } from "next/server"; @@ -15,9 +15,9 @@ export async function GET( ) { const params = await props.params; const searchParams = request.nextUrl.searchParams; - const { projectId, ownerId } = params; + const { projectId } = params; - const db = await getDatabaseForOwner(ownerId); + const db = database(); let timezone: string | undefined | null; const userId = searchParams.get("userId"); @@ -99,7 +99,7 @@ export async function GET( end: timezone ? task.dueDate.toLocaleString("en-US", { timeZone: timezone }) : task.dueDate, - summary: `ā [${tasklist.name}] ${task.name}`, + summary: `[${tasklist.name}] ${task.name}`, description: `${task.description}`, allDay: true, created: task.createdAt, diff --git a/app/(api)/api/jobs/account/mark-for-deletion/route.ts b/app/(api)/api/jobs/account/mark-for-deletion/route.ts deleted file mode 100644 index 3742cf9..0000000 --- a/app/(api)/api/jobs/account/mark-for-deletion/route.ts +++ /dev/null @@ -1,337 +0,0 @@ -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 { - DeletionNoticePlainText, - OrgDeletionNotice, -} from "@/components/emails/org-deletion-notice"; -import { - SevenDayWarning, - sevenDayWarningPlainText, -} from "@/components/emails/seven-day-warning"; -import { opsOrganization } from "@/ops/drizzle/schema"; -import { getOpsDatabase } from "@/ops/useOps"; - -type ClerkOrgData = { createdBy?: string }; - -async function getOrgCreatorDetails(org: { - id: string; - name: string; - rawData: unknown; -}) { - const rawData = org.rawData as ClerkOrgData; - const createdByUserId = rawData?.createdBy; - - if (!createdByUserId) { - throw new Error( - `No creator user ID found for organization ${org.name} (${org.id})`, - ); - } - - try { - const clerk = await clerkClient(); - const creator = await clerk.users.getUser(createdByUserId); - const contactEmail = creator.emailAddresses[0]?.emailAddress; - const firstName = creator.firstName || undefined; - - if (!contactEmail) { - throw new Error( - `No email address found for creator of organization ${org.name} (${org.id})`, - ); - } - - return { contactEmail, firstName }; - } catch (error) { - console.error( - `[OrgDeletion] Failed to fetch creator details for user ${createdByUserId}:`, - error, - ); - throw new Error( - `Failed to fetch creator details for organization ${org.name} (${org.id}): ${error}`, - ); - } -} - -export const { POST } = serve(async (context) => { - const resend = new Resend(process.env.RESEND_API_KEY); - console.log( - `[OrgDeletion] Resend API Key configured: ${process.env.RESEND_API_KEY ? "Yes" : "No"}`, - ); - - console.log( - "[OrgDeletion] Starting organization deletion job at", - new Date().toISOString(), - ); - const db = await getOpsDatabase(); - const sixtyDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 60); - console.log( - "[OrgDeletion] Looking for orgs inactive since:", - sixtyDaysAgo.toISOString(), - ); - - // 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, sixtyDaysAgo), - isNull(opsOrganization.markedForDeletionAt), - ), - ); - - console.log( - `[OrgDeletion] Found ${orgs.length} organizations to mark for deletion`, - ); - if (orgs.length > 0) { - console.log( - "[OrgDeletion] Organizations to mark:", - orgs.map((o) => ({ - id: o.id, - name: o.name, - lastActiveAt: o.lastActiveAt?.toISOString(), - })), - ); - } else { - console.log( - "[OrgDeletion] No organizations found that need to be marked for deletion", - ); - } - return orgs; - }); - - // Step 2: Send 60-day deletion notice and mark organizations - await context.run("mark-orgs-for-deletion", async () => { - if (orgsToMark.length === 0) { - console.log( - "[OrgDeletion] Skipping step 2: No organizations to mark for deletion", - ); - return; - } - - console.log( - `[OrgDeletion] Processing ${orgsToMark.length} organizations for 60-day deletion notices`, - ); - for (const org of orgsToMark) { - try { - // Get creator details - const { contactEmail, firstName } = await getOrgCreatorDetails(org); - - console.log( - `[OrgDeletion] Processing org ${org.name} (${org.id}), contact email: ${contactEmail}`, - ); - console.log( - `[OrgDeletion] Raw org data for ${org.name}:`, - JSON.stringify(org.rawData, null, 2), - ); - - // Send 60-day deletion notice - console.log( - `[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 - 60 Days", - react: OrgDeletionNotice({ - firstName: firstName, - email: contactEmail, - organizationName: org.name, - }), - text: DeletionNoticePlainText({ - firstName: firstName, - email: contactEmail, - organizationName: org.name, - }), - }); - console.log( - `[OrgDeletion] Email send result for ${org.name}:`, - JSON.stringify(emailResult, null, 2), - ); - - // Mark organization for deletion - await db - .update(opsOrganization) - .set({ markedForDeletionAt: new Date() }) - .where(eq(opsOrganization.id, org.id)); - - console.log( - `[OrgDeletion] Successfully marked organization ${org.name} (${org.id}) for deletion`, - ); - } catch (error) { - console.error( - `[OrgDeletion] Failed to process organization ${org.name} (${org.id}):`, - error, - ); - } - } - }); - - // 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 fiftyThreeDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 53); - const orgs = await db - .select() - .from(opsOrganization) - .where( - and( - lte(opsOrganization.markedForDeletionAt, fiftyThreeDaysAgo), - isNull(opsOrganization.finalWarningAt), - ), - ); - - console.log( - `[OrgDeletion] Found ${orgs.length} organizations for 7-day warning`, - ); - if (orgs.length > 0) { - console.log( - "[OrgDeletion] Organizations for 7-day warning:", - orgs.map((o) => ({ - id: o.id, - name: o.name, - markedForDeletionAt: o.markedForDeletionAt?.toISOString(), - })), - ); - } else { - console.log( - "[OrgDeletion] No organizations found that need 7-day warnings", - ); - } - return orgs; - }, - ); - - await context.run("send-7-day-warning", async () => { - if (orgsFor7DayWarning.length === 0) { - console.log( - "[OrgDeletion] Skipping step 3: No organizations need 7-day warnings", - ); - return; - } - - console.log( - `[OrgDeletion] Processing ${orgsFor7DayWarning.length} organizations for 7-day warnings`, - ); - for (const org of orgsFor7DayWarning) { - try { - // Get creator details - const { contactEmail, firstName } = await getOrgCreatorDetails(org); - - console.log( - `[OrgDeletion] Sending 7-day warning to org ${org.name} (${org.id}), contact email: ${contactEmail}`, - ); - - // Send 7-day warning - console.log( - `[OrgDeletion] Sending 7-day warning email to ${contactEmail} for org ${org.name}`, - ); - const emailResult = await resend.emails.send({ - from: "Manage Team ", - to: contactEmail, - subject: "Final Warning - Organization Deletion in 7 Days", - react: SevenDayWarning({ - firstName: firstName, - email: contactEmail, - organizationName: org.name, - }), - text: sevenDayWarningPlainText({ - firstName: firstName, - email: contactEmail, - organizationName: org.name, - }), - }); - console.log( - `[OrgDeletion] 7-day warning email result for ${org.name}:`, - JSON.stringify(emailResult, null, 2), - ); - - // Mark final warning sent - await db - .update(opsOrganization) - .set({ finalWarningAt: new Date() }) - .where(eq(opsOrganization.id, org.id)); - - console.log( - `[OrgDeletion] Successfully sent 7-day warning for organization ${org.name} (${org.id})`, - ); - } catch (error) { - console.error( - `[OrgDeletion] Failed to send 7-day warning for organization ${org.name} (${org.id}):`, - error, - ); - } - } - }); - - // Step 4: Trigger deletion for organizations marked 60 days ago - const orgsToTriggerDeletion = await context.run( - "fetch-orgs-to-trigger-deletion", - async () => { - const sixtyDaysAgoForDeletion = new Date( - Date.now() - 1000 * 60 * 60 * 24 * 60, - ); - const orgs = await db - .select() - .from(opsOrganization) - .where( - lte(opsOrganization.markedForDeletionAt, sixtyDaysAgoForDeletion), - ); - - console.log( - `[OrgDeletion] Found ${orgs.length} organizations ready for deletion`, - ); - if (orgs.length > 0) { - console.log( - "[OrgDeletion] Organizations ready for deletion:", - orgs.map((o) => ({ - id: o.id, - name: o.name, - markedForDeletionAt: o.markedForDeletionAt?.toISOString(), - finalWarningAt: o.finalWarningAt?.toISOString(), - })), - ); - } else { - console.log( - "[OrgDeletion] No organizations found that are ready for deletion", - ); - } - return orgs; - }, - ); - - await context.run("trigger-organization-deletions", async () => { - if (orgsToTriggerDeletion.length === 0) { - console.log( - "[OrgDeletion] Skipping step 4: No organizations ready for deletion", - ); - return; - } - - console.log( - `[OrgDeletion] Triggering deletion for ${orgsToTriggerDeletion.length} organizations`, - ); - for (const org of orgsToTriggerDeletion) { - console.log( - `[OrgDeletion] Triggering deletion for org ${org.name} (${org.id}) via Clerk API`, - ); - - // Delete organization from Clerk, which will trigger the webhook - // The webhook will handle database deletion and ops cleanup - const clerk = await clerkClient(); - await clerk.organizations.deleteOrganization(org.id); - - console.log( - `[OrgDeletion] Successfully triggered deletion for organization ${org.name} (${org.id}). Webhook will handle cleanup.`, - ); - } - }); - - console.log( - "[OrgDeletion] Organization deletion job completed at", - new Date().toISOString(), - ); -}); diff --git a/app/(api)/api/jobs/blobs/delete-owner-blobs/route.ts b/app/(api)/api/jobs/blobs/delete-owner-blobs/route.ts deleted file mode 100644 index 1f14932..0000000 --- a/app/(api)/api/jobs/blobs/delete-owner-blobs/route.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { serve } from "@upstash/workflow/nextjs"; -import { inArray } from "drizzle-orm"; -import { blob } from "@/drizzle/schema"; -import { deleteFile, listFiles } from "@/lib/blobStore"; -import { getDatabaseForOwner } from "@/lib/utils/useDatabase"; -import { triggerWorkflow } from "@/lib/utils/workflow"; - -type WorkflowPayload = { - ownerId: string; -}; - -const BATCH_SIZE = 25; - -export const { POST } = serve(async (context) => { - const { ownerId } = context.requestPayload; - - console.log( - `[BlobDeletion] Starting blob deletion job for owner: ${ownerId}`, - ); - - // List blobs from S3 storage using the ownerId prefix - const s3Objects = await context.run("list-blobs-from-storage", async () => { - try { - const objects = await listFiles(`${ownerId}/`, BATCH_SIZE); - console.log( - `[BlobDeletion] Found ${objects.length} blobs in S3 for owner: ${ownerId}`, - ); - return objects; - } catch (error) { - console.error( - `[BlobDeletion] Error listing blobs from S3 for owner: ${ownerId}:`, - error, - ); - return []; - } - }); - - if (s3Objects.length === 0) { - console.log(`[BlobDeletion] No blobs to delete for owner: ${ownerId}`); - return { deleted: 0, hasMore: false }; - } - - // Delete blobs from S3 storage - const deletedKeys = await context.run( - "delete-blobs-from-storage", - async () => { - console.log( - `[BlobDeletion] Deleting ${s3Objects.length} blobs from S3 storage for owner: ${ownerId}`, - ); - - const deletionResults = await Promise.allSettled( - s3Objects.map(async (obj) => { - const key = obj.Key!; - try { - await deleteFile(key); - console.log( - `[BlobDeletion] Successfully deleted blob ${key} from storage`, - ); - return { success: true, key }; - } catch (error) { - console.error( - `[BlobDeletion] Failed to delete blob ${key} from storage:`, - error, - ); - return { success: false, key, error }; - } - }), - ); - - const successfulKeys = deletionResults - .filter( - (result) => result.status === "fulfilled" && result.value.success, - ) - .map((result) => - result.status === "fulfilled" ? result.value.key : "", - ) - .filter(Boolean); - - const failed = deletionResults.length - successfulKeys.length; - - console.log( - `[BlobDeletion] Storage deletion results for owner: ${ownerId} - Success: ${successfulKeys.length}, Failed: ${failed}`, - ); - - if (failed > 0) { - const failedKeys = deletionResults - .filter( - (result) => result.status === "fulfilled" && !result.value.success, - ) - .map((result) => - result.status === "fulfilled" ? result.value.key : "unknown", - ); - console.error( - "[BlobDeletion] Failed to delete blobs from storage:", - failedKeys, - ); - } - - return successfulKeys; - }, - ); - - // Clean up database entries for successfully deleted blobs - await context.run("cleanup-database", async () => { - if (deletedKeys.length === 0) { - console.log( - `[BlobDeletion] No successful deletions to clean up from database for owner: ${ownerId}`, - ); - return; - } - - try { - const db = await getDatabaseForOwner(ownerId); - const result = await db - .delete(blob) - .where(inArray(blob.key, deletedKeys)) - .execute(); - - console.log( - `[BlobDeletion] Cleaned up ${result.count || 0} blob records from database for owner: ${ownerId}`, - ); - } catch (error) { - console.error( - `[BlobDeletion] Error cleaning up database for owner: ${ownerId}:`, - error, - ); - } - }); - - const hasMore = s3Objects.length === BATCH_SIZE; - console.log( - `[BlobDeletion] Blob deletion batch completed for owner: ${ownerId} - Deleted: ${deletedKeys.length}, HasMore: ${hasMore}`, - ); - - // Trigger next batch if more blobs exist - if (hasMore) { - await context.run("trigger-next-batch", async () => { - const url = `${process.env.NEXT_PUBLIC_APP_URL}/api/jobs/blobs/delete-owner-blobs`; - await triggerWorkflow(url, { ownerId }, "[BlobDeletion]"); - console.log(`[BlobDeletion] Next batch triggered for owner: ${ownerId}`); - }); - } - - return { deleted: deletedKeys.length, hasMore }; -}); diff --git a/app/(api)/api/jobs/daily-summary/route.ts b/app/(api)/api/jobs/daily-summary/route.ts deleted file mode 100644 index b047e11..0000000 --- a/app/(api)/api/jobs/daily-summary/route.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { serve } from "@upstash/workflow/nextjs"; -import { gte } from "drizzle-orm"; -import { Resend } from "resend"; -import { - DailySummary, - dailySummaryPlainText, -} from "@/components/emails/daily-summary"; -import { getTodayDataForUser } from "@/lib/utils/todayData"; -import { getDatabaseForOwner } from "@/lib/utils/useDatabase"; -import { opsUser } from "@/ops/drizzle/schema"; -import { getOpsDatabase } from "@/ops/useOps"; - -function isCurrentlySevenAM(timezone: string): boolean { - try { - const now = new Date(); - const userTime = new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - hour: "numeric", - hour12: false, - }).format(now); - - const hour = Number.parseInt(userTime.split(" ")[0] || userTime, 10); - return hour === 7; - } catch (error) { - console.error( - `[DailySummary] Error checking time for timezone ${timezone}:`, - error, - ); - return false; - } -} - -export const { POST } = serve(async (context) => { - const resend = new Resend(process.env.RESEND_API_KEY); - console.log( - `[DailySummary] Starting daily summary job at ${new Date().toISOString()}`, - ); - - // Step 1: Get active users from ops database (active in last 7 days) and filter by 7AM timezone - const users = await context.run("fetch-users", async () => { - const opsDb = await getOpsDatabase(); - const sevenDaysAgo = new Date(); - sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); - - const allUsers = await opsDb - .select() - .from(opsUser) - .where(gte(opsUser.lastActiveAt, sevenDaysAgo)); - console.log( - `[DailySummary] Found ${allUsers.length} active users (last 7 days)`, - ); - - // Filter users where it's currently 7AM in their timezone - const usersAt7AM = allUsers.filter((user) => { - const userTimezone = user.timeZone || "UTC"; - return isCurrentlySevenAM(userTimezone); - }); - - console.log( - `[DailySummary] Found ${usersAt7AM.length} users where it's currently 7AM`, - ); - return usersAt7AM; - }); - - // Step 2: Process users - await context.run("process-users", async () => { - console.log(`[DailySummary] Processing ${users.length} users`); - - for (const userData of users) { - try { - const userTimezone = userData.timeZone || "UTC"; - await processUserSummary( - userData.id, - userData.email, - userData.firstName, - userTimezone, - resend, - ); - } catch (error) { - console.error( - `[DailySummary] Error processing user ${userData.email}:`, - error, - ); - } - } - }); - - console.log( - `[DailySummary] Daily summary job completed at ${new Date().toISOString()}`, - ); -}); - -async function processUserSummary( - userId: string, - email: string, - firstName: string | undefined | null, - timezone: string, - resend: Resend, -) { - try { - console.log( - `[DailySummary] Processing summary for user ${email} (${userId}) at timezone ${timezone}`, - ); - - // Get user's database - const db = await getDatabaseForOwner(userId); - - // Get today's data using the same logic as getTodayData - const today = new Date(); - const { dueToday, overDue, events } = await getTodayDataForUser( - db, - timezone, - today, - ); - - // Only send email if user has relevant content - const hasContent = - dueToday.length > 0 || overDue.length > 0 || events.length > 0; - - if (!hasContent) { - console.log( - `[DailySummary] No content for user ${email}, skipping email`, - ); - return; - } - - console.log( - `[DailySummary] Sending summary to ${email}: ${overDue.length} overdue, ${dueToday.length} due today, ${events.length} events`, - ); - - // Send daily summary email - await resend.emails.send({ - from: "Manage Daily Summary", - to: email, - subject: `šŸŒ… Your Daily Summary - ${getFormattedDate(today, timezone)} ✨`, - react: DailySummary({ - firstName: firstName || undefined, - email, - timezone, - date: today, - overdueTasks: overDue, - dueToday: dueToday, - events: events, - }), - text: dailySummaryPlainText({ - firstName: firstName || undefined, - email, - timezone, - date: today, - overdueTasks: overDue, - dueToday: dueToday, - events: events, - }), - }); - - console.log(`[DailySummary] Successfully sent summary email to ${email}`); - } catch (error) { - console.error( - `[DailySummary] Error processing summary for ${email}:`, - error, - ); - } -} - -function getFormattedDate(date: Date, timezone: string): string { - try { - return new Intl.DateTimeFormat("en-US", { - timeZone: timezone, - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - }).format(date); - } catch (error) { - console.error( - `[DailySummary] Error formatting date for timezone ${timezone}:`, - error, - ); - // Fallback to UTC if timezone is invalid - return new Intl.DateTimeFormat("en-US", { - weekday: "long", - year: "numeric", - month: "long", - day: "numeric", - }).format(date); - } -} diff --git a/app/(api)/api/jobs/user/mark-for-deletion/route.ts b/app/(api)/api/jobs/user/mark-for-deletion/route.ts deleted file mode 100644 index ec9d80a..0000000 --- a/app/(api)/api/jobs/user/mark-for-deletion/route.ts +++ /dev/null @@ -1,347 +0,0 @@ -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 { - DeletionNoticePlainText, - OrgDeletionNotice, -} from "@/components/emails/org-deletion-notice"; -import { - SevenDayWarning, - sevenDayWarningPlainText, -} from "@/components/emails/seven-day-warning"; -import { opsUser } from "@/ops/drizzle/schema"; -import { getOpsDatabase } from "@/ops/useOps"; - -async function getUserDetails(user: { - id: string; - email: string; - firstName?: string | null; -}) { - const contactEmail = user.email; - const firstName = user.firstName || undefined; - - if (!contactEmail) { - throw new Error(`No email address found for user ${user.id}`); - } - - return { contactEmail, firstName }; -} - -async function checkUserOrganizationMembership( - userId: string, -): Promise { - try { - const clerk = await clerkClient(); - // Get the user's organization memberships - const organizationMemberships = - await clerk.users.getOrganizationMembershipList({ userId: userId }); - - console.log( - `[UserDeletion] User ${userId} has ${organizationMemberships.data.length} organization memberships`, - ); - - // Return true if user belongs to any organization - return organizationMemberships.data.length > 0; - } catch (error) { - console.error( - `[UserDeletion] Failed to fetch organization memberships for user ${userId}:`, - error, - ); - // If we can't determine organization membership, err on the side of caution - // and assume the user belongs to an organization (don't delete) - return true; - } -} - -export const { POST } = serve(async (context) => { - const resend = new Resend(process.env.RESEND_API_KEY); - console.log( - `[UserDeletion] Resend API Key configured: ${process.env.RESEND_API_KEY ? "Yes" : "No"}`, - ); - - console.log( - "[UserDeletion] Starting user deletion job at", - new Date().toISOString(), - ); - const db = await getOpsDatabase(); - const sixtyDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 60); - console.log( - "[UserDeletion] Looking for users inactive since:", - sixtyDaysAgo.toISOString(), - ); - - // 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, sixtyDaysAgo), - isNull(opsUser.markedForDeletionAt), - ), - ); - - console.log( - `[UserDeletion] Found ${users.length} inactive users to check for organization membership`, - ); - - // Filter out users who belong to any organization - const eligibleUsers = []; - for (const user of users) { - const belongsToOrg = await checkUserOrganizationMembership(user.id); - if (!belongsToOrg) { - eligibleUsers.push(user); - } else { - console.log( - `[UserDeletion] Skipping user ${user.id} (${user.email}) - belongs to organization(s)`, - ); - } - } - - console.log( - `[UserDeletion] Found ${eligibleUsers.length} users eligible for deletion (not part of any organization)`, - ); - if (eligibleUsers.length > 0) { - console.log( - "[UserDeletion] Users to mark:", - eligibleUsers.map((u) => ({ - id: u.id, - email: u.email, - lastActiveAt: u.lastActiveAt?.toISOString(), - })), - ); - } else { - console.log( - "[UserDeletion] No eligible users found that need to be marked for deletion", - ); - } - return eligibleUsers; - }); - - // Step 2: Send 60-day deletion notice and mark users - await context.run("mark-users-for-deletion", async () => { - if (usersToMark.length === 0) { - console.log( - "[UserDeletion] Skipping step 2: No users to mark for deletion", - ); - return; - } - - console.log( - `[UserDeletion] Processing ${usersToMark.length} users for 60-day deletion notices`, - ); - for (const user of usersToMark) { - try { - // Get user details - const { contactEmail, firstName } = await getUserDetails(user); - - console.log( - `[UserDeletion] Processing user ${user.id}, contact email: ${contactEmail}`, - ); - - // Send 60-day deletion notice - console.log( - `[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 - 60 Days", - react: OrgDeletionNotice({ - firstName: firstName, - email: contactEmail, - // organizationName is undefined for user deletion - }), - text: DeletionNoticePlainText({ - firstName: firstName, - email: contactEmail, - // organizationName is undefined for user deletion - }), - }); - console.log( - `[UserDeletion] Email send result for user ${user.id}:`, - JSON.stringify(emailResult, null, 2), - ); - - // Mark user for deletion - await db - .update(opsUser) - .set({ markedForDeletionAt: new Date() }) - .where(eq(opsUser.id, user.id)); - - console.log( - `[UserDeletion] Successfully marked user ${user.id} (${contactEmail}) for deletion`, - ); - } catch (error) { - console.error( - `[UserDeletion] Failed to process user ${user.id}:`, - error, - ); - } - } - }); - - // 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 fiftyThreeDaysAgo = new Date(Date.now() - 1000 * 60 * 60 * 24 * 53); - const users = await db - .select() - .from(opsUser) - .where( - and( - lte(opsUser.markedForDeletionAt, fiftyThreeDaysAgo), - isNull(opsUser.finalWarningAt), - ), - ); - - console.log( - `[UserDeletion] Found ${users.length} users for 7-day warning`, - ); - if (users.length > 0) { - console.log( - "[UserDeletion] Users for 7-day warning:", - users.map((u) => ({ - id: u.id, - email: u.email, - markedForDeletionAt: u.markedForDeletionAt?.toISOString(), - })), - ); - } else { - console.log("[UserDeletion] No users found that need 7-day warnings"); - } - return users; - }, - ); - - await context.run("send-7-day-warning", async () => { - if (usersFor7DayWarning.length === 0) { - console.log( - "[UserDeletion] Skipping step 3: No users need 7-day warnings", - ); - return; - } - - console.log( - `[UserDeletion] Processing ${usersFor7DayWarning.length} users for 7-day warnings`, - ); - for (const user of usersFor7DayWarning) { - try { - // Get user details - const { contactEmail, firstName } = await getUserDetails(user); - - console.log( - `[UserDeletion] Sending 7-day warning to user ${user.id}, contact email: ${contactEmail}`, - ); - - // Send 7-day warning - console.log( - `[UserDeletion] Sending 7-day warning email to ${contactEmail} for user ${user.id}`, - ); - const emailResult = await resend.emails.send({ - from: "Manage Team", - to: contactEmail, - subject: "Final Warning - Account Deletion in 7 Days", - react: SevenDayWarning({ - firstName: firstName, - email: contactEmail, - // organizationName is undefined for user deletion - }), - text: sevenDayWarningPlainText({ - firstName: firstName, - email: contactEmail, - // organizationName is undefined for user deletion - }), - }); - console.log( - `[UserDeletion] 7-day warning email result for user ${user.id}:`, - JSON.stringify(emailResult, null, 2), - ); - - // Mark final warning sent - await db - .update(opsUser) - .set({ finalWarningAt: new Date() }) - .where(eq(opsUser.id, user.id)); - - console.log( - `[UserDeletion] Successfully sent 7-day warning for user ${user.id} (${contactEmail})`, - ); - } catch (error) { - console.error( - `[UserDeletion] Failed to send 7-day warning for user ${user.id}:`, - error, - ); - } - } - }); - - // Step 4: Trigger deletion for users marked 60 days ago - const usersToTriggerDeletion = await context.run( - "fetch-users-to-trigger-deletion", - async () => { - const sixtyDaysAgoForDeletion = new Date( - Date.now() - 1000 * 60 * 60 * 24 * 60, - ); - const users = await db - .select() - .from(opsUser) - .where(lte(opsUser.markedForDeletionAt, sixtyDaysAgoForDeletion)); - - console.log( - `[UserDeletion] Found ${users.length} users ready for deletion`, - ); - if (users.length > 0) { - console.log( - "[UserDeletion] Users ready for deletion:", - users.map((u) => ({ - id: u.id, - email: u.email, - markedForDeletionAt: u.markedForDeletionAt?.toISOString(), - finalWarningAt: u.finalWarningAt?.toISOString(), - })), - ); - } else { - console.log( - "[UserDeletion] No users found that are ready for deletion", - ); - } - return users; - }, - ); - - await context.run("trigger-user-deletions", async () => { - if (usersToTriggerDeletion.length === 0) { - console.log( - "[UserDeletion] Skipping step 4: No users ready for deletion", - ); - return; - } - - console.log( - `[UserDeletion] Triggering deletion for ${usersToTriggerDeletion.length} users`, - ); - for (const user of usersToTriggerDeletion) { - console.log( - `[UserDeletion] Triggering deletion for user ${user.id} (${user.email}) via Clerk API`, - ); - - // Delete user from Clerk, which will trigger the webhook - // The webhook will handle database deletion and ops cleanup - const clerk = await clerkClient(); - await clerk.users.deleteUser(user.id); - - console.log( - `[UserDeletion] Successfully triggered deletion for user ${user.id} (${user.email}). Webhook will handle cleanup.`, - ); - } - }); - - console.log( - "[UserDeletion] User deletion job completed at", - new Date().toISOString(), - ); -}); diff --git a/app/(api)/api/webhook/auth/route.ts b/app/(api)/api/webhook/auth/route.ts deleted file mode 100644 index c0961c5..0000000 --- a/app/(api)/api/webhook/auth/route.ts +++ /dev/null @@ -1,410 +0,0 @@ -import path from "node:path"; -import { verifyWebhook } from "@clerk/nextjs/webhooks"; -import { eq, sql } from "drizzle-orm"; -import { drizzle } from "drizzle-orm/postgres-js"; -import { migrate } from "drizzle-orm/postgres-js/migrator"; -import type { NextRequest } from "next/server"; -import { Resend } from "resend"; -import { - AccountDeleted, - accountDeletedPlainText, -} from "@/components/emails/account-deleted"; -import * as schema from "@/drizzle/schema"; -import { - deleteDatabase, - getDatabaseForOwner, - getDatabaseName, -} from "@/lib/utils/useDatabase"; -import { addUserToTenantDb } from "@/lib/utils/useUser"; -import { triggerBlobDeletionWorkflow } from "@/lib/utils/workflow"; -import { opsOrganization, opsUser } from "@/ops/drizzle/schema"; -import { addUserToOpsDb, getOpsDatabase } from "@/ops/useOps"; - -type ClerkOrgData = { - createdBy?: { email?: string; firstName?: string; lastName?: string }; - adminEmails?: string[]; -}; - -enum WebhookEventType { - organizationCreated = "organization.created", - organizationDeleted = "organization.deleted", - organizationUpdated = "organization.updated", - organizationInvitationAccepted = "organizationInvitation.accepted", - userCreated = "user.created", - userDeleted = "user.deleted", - userUpdated = "user.updated", -} - -async function createTenantDatabase(ownerId: string): Promise { - const databaseName = getDatabaseName(ownerId).match( - (value) => value, - () => { - throw new Error("Database name not found"); - }, - ); - - const ownerDb = drizzle({ - connection: { - url: `${process.env.DATABASE_URL}/manage`, - ssl: process.env.DATABASE_SSL === "true", - }, - schema, - }); - - const checkDb = await ownerDb.execute( - sql`SELECT 1 FROM pg_database WHERE datname = ${databaseName}`, - ); - - if (checkDb.count === 0) { - await ownerDb.execute(sql`CREATE DATABASE ${sql.identifier(databaseName)}`); - console.log(`Created database for tenant: ${databaseName}`); - } - - const tenantDb = drizzle({ - connection: { - url: `${process.env.DATABASE_URL}/${databaseName}`, - ssl: process.env.DATABASE_SSL === "true", - }, - schema, - }); - - const migrationsFolder = path.resolve(process.cwd(), "drizzle"); - await migrate(tenantDb, { migrationsFolder }); - console.log(`Migrated database for tenant: ${databaseName}`); -} - -export async function POST(req: NextRequest) { - try { - const evt = await verifyWebhook(req); - - const { id } = evt.data; - const eventType = evt.type; - console.log("Webhook payload:", id, evt.data); - - if (!id) { - console.error("Webhook received with no ID"); - return new Response("Webhook received with no ID", { status: 400 }); - } - - switch (eventType) { - case WebhookEventType.userCreated: - try { - const userData = evt.data; - await createTenantDatabase(id); - await Promise.all([ - addUserToTenantDb(userData), - addUserToOpsDb(userData), - ]); - console.log("User created - database and data synced successfully"); - } catch (err) { - console.error("Error creating user and database:", err); - } - break; - case WebhookEventType.userUpdated: - try { - const userData = evt.data; - await Promise.all([ - addUserToTenantDb(userData), - addUserToOpsDb(userData), - ]); - console.log("User updated - data synced successfully"); - } catch (err) { - console.error("Error syncing user data:", err); - } - break; - case WebhookEventType.organizationCreated: - try { - const orgData = evt.data; - await createTenantDatabase(id); - const db = await getOpsDatabase(); - await db - .insert(opsOrganization) - .values({ - id: orgData.id, - name: orgData.name, - rawData: orgData, - lastActiveAt: new Date(), - }) - .execute(); - - if (orgData.created_by) { - try { - const creatorData = await db - .select() - .from(opsUser) - .where(eq(opsUser.id, orgData.created_by)) - .limit(1); - - if (creatorData.length > 0) { - const creator = creatorData[0]; - const orgDb = await getDatabaseForOwner(id); - await orgDb - .insert(schema.user) - .values({ - id: creator.id, - email: creator.email, - firstName: creator.firstName, - lastName: creator.lastName, - imageUrl: creator.imageUrl, - rawData: creator.rawData, - lastActiveAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.user.id, - set: { - email: creator.email, - firstName: creator.firstName, - lastName: creator.lastName, - imageUrl: creator.imageUrl, - rawData: creator.rawData, - lastActiveAt: new Date(), - }, - }) - .execute(); - console.log( - `Added creator ${creator.id} to organization database`, - ); - } - } catch (creatorErr) { - console.error( - "Error adding creator to org database:", - creatorErr, - ); - } - } - - console.log( - "Organization created - database and data synced successfully", - ); - } catch (err) { - console.error("Error creating organization and database:", err); - } - break; - case WebhookEventType.organizationUpdated: - try { - const orgData = evt.data; - const db = await getOpsDatabase(); - await db - .insert(opsOrganization) - .values({ - id: orgData.id, - name: orgData.name, - rawData: orgData, - lastActiveAt: new Date(), - }) - .onConflictDoUpdate({ - target: opsOrganization.id, - set: { - name: orgData.name, - rawData: orgData, - lastActiveAt: new Date(), - markedForDeletionAt: null, - finalWarningAt: null, - }, - }) - .execute(); - console.log("Organization updated - data synced successfully"); - } catch (err) { - console.error("Error syncing org data:", err); - } - break; - case WebhookEventType.organizationInvitationAccepted: - try { - const invitationData = evt.data; - const orgId = invitationData.organization_id; - const emailAddress = invitationData.email_address; - - if (!orgId || !emailAddress) { - console.error("Missing organization or email in invitation data"); - break; - } - - const db = await getOpsDatabase(); - const userData = await db - .select() - .from(opsUser) - .where(eq(opsUser.email, emailAddress)) - .limit(1); - - if (userData.length > 0) { - const user = userData[0]; - const orgDb = await getDatabaseForOwner(orgId); - await orgDb - .insert(schema.user) - .values({ - id: user.id, - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - imageUrl: user.imageUrl, - rawData: user.rawData, - lastActiveAt: new Date(), - }) - .onConflictDoUpdate({ - target: schema.user.id, - set: { - email: user.email, - firstName: user.firstName, - lastName: user.lastName, - imageUrl: user.imageUrl, - rawData: user.rawData, - lastActiveAt: new Date(), - }, - }) - .execute(); - console.log( - `Added user ${user.id} to organization ${orgId} database after invitation acceptance`, - ); - } - } catch (err) { - console.error("Error adding user to org after invitation:", err); - } - break; - case WebhookEventType.userDeleted: - // For individual users, delete database immediately - // This happens when a user without an organization deletes their account - try { - await deleteDatabase(id); - console.log("User database deleted successfully"); - } catch (err) { - console.error("Error deleting user database:", err); - } - - // Also delete user from ops database - try { - const db = await getOpsDatabase(); - await db.delete(opsUser).where(eq(opsUser.id, id)); - console.log("User deleted from ops database successfully"); - } catch (err) { - console.error("Error deleting user from ops database:", err); - } - - // Trigger blob deletion workflow - try { - await triggerBlobDeletionWorkflow(id); - console.log("User blob deletion workflow triggered successfully"); - } catch (err) { - console.error("Error triggering user blob deletion workflow:", err); - } - break; - case WebhookEventType.organizationDeleted: { - console.log(`[Webhook] Processing organization deletion for ID: ${id}`); - - // First, get the organization info from ops database for email - let orgData = null; - try { - const db = await getOpsDatabase(); - const orgs = await db - .select() - .from(opsOrganization) - .where(eq(opsOrganization.id, id)); - orgData = orgs[0]; - if (orgData) { - console.log( - `[Webhook] Found organization data for ${orgData.name} (${id})`, - ); - } - } catch (err) { - console.error( - `[Webhook] Error fetching organization data for ID: ${id}:`, - err, - ); - } - - // Delete organization database immediately when Clerk deletes the organization - try { - await deleteDatabase(id); - console.log( - `[Webhook] Organization database deleted successfully for ID: ${id}`, - ); - } catch (err) { - console.error( - `[Webhook] Error deleting organization database for ID: ${id}:`, - err, - ); - } - - // Send deletion confirmation email if we have org data - if (orgData) { - try { - const rawData = orgData.rawData as ClerkOrgData; - const createdBy = rawData?.createdBy; - const contactEmail = createdBy?.email || rawData?.adminEmails?.[0]; - - if (contactEmail) { - console.log( - `[Webhook] Sending deletion confirmation email to ${contactEmail} for org ${orgData.name}`, - ); - - const resend = new Resend(process.env.RESEND_API_KEY); - await resend.emails.send({ - from: "Manage Team", - to: contactEmail, - subject: "Organization Deleted", - react: AccountDeleted({ - firstName: createdBy?.firstName || undefined, - email: contactEmail, - organizationName: orgData.name, - }), - text: accountDeletedPlainText({ - firstName: createdBy?.firstName || undefined, - email: contactEmail, - organizationName: orgData.name, - }), - }); - - console.log( - `[Webhook] Deletion confirmation email sent successfully for org ${orgData.name}`, - ); - } else { - console.log( - `[Webhook] No contact email found for org ${orgData.name}, skipping deletion confirmation email`, - ); - } - } catch (emailErr) { - console.error( - `[Webhook] Error sending deletion confirmation email for ID: ${id}:`, - emailErr, - ); - } - } - - // Delete organization from ops database - try { - const db = await getOpsDatabase(); - await db.delete(opsOrganization).where(eq(opsOrganization.id, id)); - console.log( - `[Webhook] Organization deleted from ops database successfully for ID: ${id}`, - ); - } catch (err) { - console.error( - `[Webhook] Error deleting organization from ops database for ID: ${id}:`, - err, - ); - } - - // Trigger blob deletion workflow - try { - await triggerBlobDeletionWorkflow(id); - console.log( - `[Webhook] Organization blob deletion workflow triggered successfully for ID: ${id}`, - ); - } catch (err) { - console.error( - `[Webhook] Error triggering organization blob deletion workflow for ID: ${id}:`, - err, - ); - } - break; - } - default: - console.log("Unhandled webhook event type:", eventType); - break; - } - - return new Response("ok", { status: 200 }); - } catch (err) { - console.error("Error verifying webhook:", err); - return new Response("Error verifying webhook", { status: 400 }); - } -} diff --git a/app/(auth)/accept-invite/page.tsx b/app/(auth)/accept-invite/page.tsx new file mode 100644 index 0000000..2a0c407 --- /dev/null +++ b/app/(auth)/accept-invite/page.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { authClient } from "@/lib/auth/client"; +import { toast } from "sonner"; + +export default function AcceptInvitePage() { + const searchParams = useSearchParams(); + const email = searchParams.get("email") || ""; + const [isLoading, setIsLoading] = useState(false); + const [sent, setSent] = useState(false); + + async function handleSignIn() { + if (!email) { + toast.error("No email provided"); + return; + } + + setIsLoading(true); + try { + const result = await authClient.signIn.magicLink({ + email, + callbackURL: "/start", + }); + + if (result.error) { + toast.error(result.error.message || "Failed to send sign in link"); + } else { + setSent(true); + toast.success("Check your email for the sign in link"); + } + } catch (error) { + console.error("Sign in error:", error); + toast.error("Failed to send sign in link"); + } finally { + setIsLoading(false); + } + } + + if (sent) { + return ( +
+
+
+

Check your email

+

+ We sent a sign in link to {email} +

+

+ Click the link in the email to sign in and join the workspace. +

+
+
+
+ ); + } + + return ( +
+
+
+

You're invited!

+

+ You've been invited to join a workspace on Manage. +

+
+ +
+
+ + +
+ + +
+
+
+ ); +} diff --git a/app/(auth)/sign-in/page.tsx b/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..7173cb1 --- /dev/null +++ b/app/(auth)/sign-in/page.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient } from "@/lib/auth/client"; +import { toast } from "sonner"; +import { Loader2, Mail } from "lucide-react"; + +export default function SignInPage() { + const searchParams = useSearchParams(); + const redirect = searchParams.get("redirect") || "/start"; + + const [email, setEmail] = useState(""); + const [loading, setLoading] = useState(false); + const [sent, setSent] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + + try { + await authClient.signIn.magicLink({ + email, + callbackURL: redirect, + }); + setSent(true); + toast.success("Check your email for the sign-in link"); + } catch (error) { + toast.error("Failed to send sign-in link. Please try again."); + console.error(error); + } finally { + setLoading(false); + } + } + + if (sent) { + return ( +
+ + +
+ +
+ Check your email + + We sent a sign-in link to {email} + +
+ +

+ Click the link in the email to sign in. The link will expire in 15 + minutes. +

+ +
+
+
+ ); + } + + return ( +
+ + + Sign in to Manage + + Enter your email to receive a sign-in link + + + +
+
+ + setEmail(e.target.value)} + required + disabled={loading} + /> +
+ +
+
+
+
+ ); +} diff --git a/app/(dashboard)/[tenant]/projects/[projectId]/events/page.tsx b/app/(dashboard)/[tenant]/projects/[projectId]/events/page.tsx index a04c55c..864cf9e 100644 --- a/app/(dashboard)/[tenant]/projects/[projectId]/events/page.tsx +++ b/app/(dashboard)/[tenant]/projects/[projectId]/events/page.tsx @@ -1,6 +1,5 @@ "use client"; -import { useSession } from "@clerk/nextjs"; import { Title } from "@radix-ui/react-dialog"; import { useQuery } from "@tanstack/react-query"; import { RssIcon, Upload } from "lucide-react"; @@ -18,13 +17,13 @@ import EventsList from "@/components/project/events/events-list"; import { FullCalendar } from "@/components/project/events/full-calendar"; import { Button, buttonVariants } from "@/components/ui/button"; import type { EventWithCreator } from "@/drizzle/types"; +import { useSession } from "@/lib/auth/client"; import { toDateString, toUTC } from "@/lib/utils/date"; import { useTRPC } from "@/trpc/client"; export default function Events() { - const { session } = useSession(); - const { projectId } = useParams(); - const { user, lastActiveOrganizationId } = session ?? {}; + const { data: session } = useSession(); + const { tenant, projectId } = useParams(); const [create, setCreate] = useQueryState( "create", @@ -34,9 +33,8 @@ export default function Events() { const [selectedDate, setSelectedDate] = useState(null); const calendarSubscriptionUrl = useMemo( - () => - `/api/calendar/${lastActiveOrganizationId ?? user?.id}/${projectId}/calendar.ics?userId=${user?.id}`, - [lastActiveOrganizationId, projectId, user?.id], + () => `/api/calendar/${tenant}/${projectId}/calendar.ics?userId=${session?.user?.id}`, + [tenant, projectId, session?.user?.id], ); const trpc = useTRPC(); diff --git a/app/(dashboard)/[tenant]/projects/[projectId]/settings/page.tsx b/app/(dashboard)/[tenant]/projects/[projectId]/settings/page.tsx index 41b566c..e17adbc 100644 --- a/app/(dashboard)/[tenant]/projects/[projectId]/settings/page.tsx +++ b/app/(dashboard)/[tenant]/projects/[projectId]/settings/page.tsx @@ -5,18 +5,15 @@ import PermissionsManagement from "@/components/core/permissions-management"; import PageSection from "@/components/core/section"; import PageTitle from "@/components/layout/page-title"; import { useTRPC } from "@/trpc/client"; -import { useUser } from "@clerk/nextjs"; import { useQuery } from "@tanstack/react-query"; import { Shield, Settings2 } from "lucide-react"; import { useParams } from "next/navigation"; -import { useMemo } from "react"; export default function ProjectSettings() { const params = useParams(); const projectId = +params.projectId!; const tenant = params.tenant as string; const trpc = useTRPC(); - const { user } = useUser(); const { data: project, isLoading } = useQuery( trpc.projects.getProjectById.queryOptions({ @@ -24,20 +21,7 @@ export default function ProjectSettings() { }), ); - // Check if user is org admin - same logic as in today page - const isOrgAdmin = useMemo(() => { - // Check if current tenant matches any organization the user belongs to - const orgMembership = user?.organizationMemberships?.find( - (membership) => membership.organization.slug === tenant, - ); - - if (orgMembership) { - // This is an organization tenant - check if user has org:admin role - return orgMembership.role === "org:admin"; - } - // This is a personal account tenant (no matching organization) - user is admin - return true; - }, [user, tenant]); + const isOrgAdmin = tenant === "me"; if (isLoading || !project) return ; diff --git a/app/(dashboard)/[tenant]/settings/page.tsx b/app/(dashboard)/[tenant]/settings/page.tsx index 1d36841..b0acfec 100644 --- a/app/(dashboard)/[tenant]/settings/page.tsx +++ b/app/(dashboard)/[tenant]/settings/page.tsx @@ -1,29 +1,31 @@ import PageSection from "@/components/core/section"; import PageTitle from "@/components/layout/page-title"; +import { TeamSettings } from "@/components/settings/team-settings"; import { bytesToMegabytes } from "@/lib/blobStore"; import { caller } from "@/trpc/server"; -import { ChartBar, User2 } from "lucide-react"; +import { ChartBar, User2, Users } from "lucide-react"; export default async function Settings() { - const [user, storage, timezone, projects] = await Promise.all([ + const [user, storage, timezone, projectsData] = await Promise.all([ caller.user.getCurrentUser(), caller.settings.getStorageUsage(), caller.settings.getTimezone(), caller.user.getProjects({ statuses: ["active", "archived"] }), ]); + const projects = projectsData.projects; + return ( <> - {/* } + } bottomMargin - transparent > - - */} + + { - // Check if current tenant matches any organization the user belongs to - const orgMembership = user?.organizationMemberships?.find( - (membership) => membership.organization.slug === tenant, - ); - - if (orgMembership) { - // This is an organization tenant - check if user has org:admin role - return orgMembership.role === "org:admin"; - } - // This is a personal account tenant (no matching organization) - user is admin - return true; - }, [user, tenant]); - - const [{ data: todayData }, { data: projects }, { data: timezone }] = + const [{ data: todayData }, { data: projectsData }, { data: timezone }] = useQueries({ queries: [ trpc.user.getTodayData.queryOptions(), @@ -67,6 +50,9 @@ export default function Today() { ], }); + const projects = projectsData?.projects; + const isOrgAdmin = projectsData?.isOrgAdmin ?? (tenant === "me"); + const { dueToday = [], overDue = [], events = [] } = todayData ?? {}; const createProject = useMutation( diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..5b67b06 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); diff --git a/app/layout.tsx b/app/layout.tsx index 332eea6..f5fe66a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,3 @@ -import { ClerkProvider } from "@clerk/nextjs"; import { Analytics } from "@vercel/analytics/next"; import { ThemeProvider } from "@/components/core/theme-provider"; import { Toaster } from "@/components/ui/sonner"; @@ -23,7 +22,7 @@ export const metadata = { "calendar", "file sharing", "activity tracking", - "multi-tenant", + "self-hosted", ], authors: [{ name: "Techulus", url: "https://github.com/techulus" }], creator: "Techulus", @@ -83,10 +82,6 @@ export default function RootLayout({ suppressHydrationWarning > - {/*