Compare commits

..

2 Commits

Author SHA1 Message Date
Henry
22c39fdff2 feat: extend session duration 2025-11-19 23:59:13 +00:00
Henry
a6aae0cea1 fix(cloud): reenable magic login 2025-11-19 23:52:04 +00:00
15 changed files with 105 additions and 214 deletions

View File

@@ -122,10 +122,10 @@ export default function SideNavigation({
)} )}
> >
<div> <div>
<div className="hidden h-[45px] items-center justify-between pb-3 md:flex"> <div className="hidden h-14 items-center justify-between pb-[18px] pt-1.5 md:flex">
{!isCollapsed && ( {!isCollapsed && (
<Link href="/" className="block"> <Link href="/" className="block">
<h1 className="pl-2 text-[16px] font-bold tracking-tight text-neutral-900 dark:text-dark-1000"> <h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
kan.bn kan.bn
</h1> </h1>
</Link> </Link>

View File

@@ -28,7 +28,7 @@ export default function BoardDropdown() {
}, },
]} ]}
> >
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" /> <HiEllipsisHorizontal className="h-6 w-6 text-dark-900" />
</Dropdown> </Dropdown>
); );
} }

View File

@@ -107,7 +107,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
return ( return (
<div className="h-full w-[360px] border-l-[1px] border-light-300 bg-light-50 p-8 text-light-900 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900"> <div className="h-full w-[360px] border-l-[1px] border-light-300 bg-light-50 p-8 text-light-900 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
<div className="mb-4 flex w-full flex-row pt-[18px]"> <div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p> <p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
<ListSelector <ListSelector
cardPublicId={cardId ?? ""} cardPublicId={cardId ?? ""}
@@ -210,17 +210,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
} }
}, [card, getModalState, clearModalState]); }, [card, getModalState, clearModalState]);
// Auto-resize title textarea
useEffect(() => {
const titleTextarea = document.getElementById(
"title",
) as HTMLTextAreaElement;
if (titleTextarea) {
titleTextarea.style.height = "auto";
titleTextarea.style.height = `${titleTextarea.scrollHeight}px`;
}
}, [card]);
if (!cardId) return <></>; if (!cardId) return <></>;
return ( return (
@@ -228,71 +217,47 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<PageHead <PageHead
title={t`${card?.title ?? t`Card`} | ${board?.name ?? t`Board`}`} title={t`${card?.title ?? t`Card`} | ${board?.name ?? t`Board`}`}
/> />
<div className="flex h-full flex-1 flex-col overflow-hidden"> <div className="flex h-full flex-1 flex-row overflow-hidden">
{/* Full-width top strip with board link and dropdown */}
<div className="flex w-full items-center justify-between border-b-[1px] border-light-300 bg-light-50 px-8 py-2 dark:border-dark-300 dark:bg-dark-50">
{!card && isLoading && (
<div className="flex space-x-2">
<div className="h-[1.5rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div>
)}
{card && (
<>
<div className="flex items-center gap-1">
<Link
className="whitespace-nowrapleading-[1.5rem] text-sm font-bold text-light-900 dark:text-dark-950"
href={`${isTemplate ? "/templates" : "/boards"}`}
>
{workspace.name}
</Link>
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
<Link
className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-900 dark:text-dark-950"
href={`${isTemplate ? "/templates" : "/boards"}/${board?.publicId}`}
>
{board?.name}
</Link>
</div>
<div className="flex items-center gap-2">
<Dropdown />
</div>
</>
)}
{!card && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[1.5rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1rem]">
{t`Card not found`}
</p>
)}
</div>
<div className="scrollbar-thumb-rounded-[4px] scrollbar-track-rounded-[4px] w-full flex-1 overflow-y-auto scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 hover:scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 dark:hover:scrollbar-thumb-dark-300"> <div className="scrollbar-thumb-rounded-[4px] scrollbar-track-rounded-[4px] w-full flex-1 overflow-y-auto scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 hover:scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 dark:hover:scrollbar-thumb-dark-300">
<div className="p-auto mx-auto flex h-full w-full max-w-[800px] flex-col"> <div className="p-auto mx-auto flex h-full w-full max-w-[800px] flex-col">
<div className="p-6 md:p-8"> <div className="p-6 md:p-8">
<div className="mb-8 md:mt-4"> <div className="mb-8 flex w-full items-center justify-between md:mt-6">
{!card && isLoading && ( {!card && isLoading && (
<div className="flex space-x-2"> <div className="flex space-x-2">
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" /> <div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
</div> </div>
)} )}
{card && ( {card && (
<form <>
onSubmit={handleSubmit(onSubmit)} <Link
className="w-full space-y-6" className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
> href={`${isTemplate ? "/templates" : "/boards"}/${board?.publicId}`}
<div> >
<textarea {board?.name}
id="title" </Link>
{...register("title")} <IoChevronForwardSharp
onBlur={handleSubmit(onSubmit)} size={18}
rows={1} className="mx-2 text-light-900 dark:text-dark-900"
className="block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]" />
onInput={(e) => { <form
const target = e.target as HTMLTextAreaElement; onSubmit={handleSubmit(onSubmit)}
target.style.height = "auto"; className="w-full space-y-6"
target.style.height = `${target.scrollHeight}px`; >
}} <div>
/> <input
type="text"
id="title"
{...register("title")}
onBlur={handleSubmit(onSubmit)}
className="block w-full border-0 bg-transparent p-0 py-0 font-bold tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
/>
</div>
</form>
<div className="flex">
<Dropdown />
</div> </div>
</form> </>
)} )}
{!card && !isLoading && ( {!card && !isLoading && (
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]"> <p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">

View File

@@ -35,10 +35,8 @@ services:
- SMTP_PASSWORD=${SMTP_PASSWORD} - SMTP_PASSWORD=${SMTP_PASSWORD}
- SMTP_SECURE=${SMTP_SECURE} - SMTP_SECURE=${SMTP_SECURE}
- EMAIL_FROM=${EMAIL_FROM} - EMAIL_FROM=${EMAIL_FROM}
- PLUNK_API_KEY=${PLUNK_API_KEY}
# Notifications - PLUNK_API_URL=${PLUNK_API_URL}
- NOVU_API_KEY=${NOVU_API_KEY}
- DISCORD_WEBHOOK_URL=${DISCORD_WEBHOOK_URL}
# S3 storage # S3 storage
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID} - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}

View File

@@ -476,7 +476,7 @@ export const memberRouter = createTRPCRouter({
openapi: { openapi: {
summary: "Get invite information by code", summary: "Get invite information by code",
method: "GET", method: "GET",
path: "/invites/{inviteCode}", path: "/workspaces/{workspacePublicId}/invites/{inviteCode}",
description: "Get invite information by invite code", description: "Get invite information by invite code",
tags: ["Invites"], tags: ["Invites"],
protect: false, protect: false,
@@ -521,7 +521,7 @@ export const memberRouter = createTRPCRouter({
openapi: { openapi: {
summary: "Accept an invite link", summary: "Accept an invite link",
method: "POST", method: "POST",
path: "/invites/accept", path: "/workspaces/{workspacePublicId}/invites/accept",
description: "Accepts an invitation via invite link", description: "Accepts an invitation via invite link",
tags: ["Invites"], tags: ["Invites"],
protect: false, protect: false,

View File

@@ -1,8 +1,5 @@
import type { Subscription } from "@better-auth/stripe";
import type Stripe from "stripe";
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { stripe } from "@better-auth/stripe"; import { stripe } from "@better-auth/stripe";
import { ChatOrPushProviderEnum } from "@novu/api/models/components";
import { betterAuth } from "better-auth"; import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api"; import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
@@ -17,7 +14,7 @@ import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
import * as userRepo from "@kan/db/repository/user.repo"; import * as userRepo from "@kan/db/repository/user.repo";
import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceRepo from "@kan/db/repository/workspace.repo";
import * as schema from "@kan/db/schema"; import * as schema from "@kan/db/schema";
import { notificationClient, sendEmail } from "@kan/email"; import { cloudMailerClient, sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
export const configuredProviders = socialProviderList.reduce< export const configuredProviders = socialProviderList.reduce<
@@ -181,17 +178,19 @@ export const initAuth = (db: dbClient) => {
freeTrial: { freeTrial: {
days: 14, days: 14,
onTrialStart: async (subscription) => { onTrialStart: async (subscription) => {
await triggerWorkflow(db, "trial-start", subscription); // Called when a trial starts
// @todo: send trial start email
// await sendTrialStartEmail(subscription.referenceId);
}, },
onTrialEnd: async ({ subscription }) => { onTrialEnd: async ({ subscription }, request) => {
await triggerWorkflow(db, "trial-end", subscription); // Called when a trial ends
// @todo: send trial end email
// await sendTrialEndEmail(user.email);
}, },
onTrialExpired: async (subscription) => { onTrialExpired: async (subscription) => {
await triggerWorkflow( // Called when a trial expires without conversion
db, // @todo: send trial expired email
"trial-expired", // await sendTrialExpiredEmail(subscription.referenceId);
subscription,
);
}, },
}, },
}, },
@@ -203,17 +202,19 @@ export const initAuth = (db: dbClient) => {
freeTrial: { freeTrial: {
days: 14, days: 14,
onTrialStart: async (subscription) => { onTrialStart: async (subscription) => {
await triggerWorkflow(db, "trial-start", subscription); // Called when a trial starts
// @todo: send trial start email
// await sendTrialStartEmail(subscription.referenceId);
}, },
onTrialEnd: async ({ subscription }) => { onTrialEnd: async ({ subscription }, request) => {
await triggerWorkflow(db, "trial-end", subscription); // Called when a trial ends
// @todo: send trial end email
// await sendTrialEndEmail(user.email);
}, },
onTrialExpired: async (subscription) => { onTrialExpired: async (subscription) => {
await triggerWorkflow( // Called when a trial expires without conversion
db, // @todo: send trial expired email
"trial-expired", // await sendTrialExpiredEmail(subscription.referenceId);
subscription,
);
}, },
}, },
}, },
@@ -347,7 +348,17 @@ export const initAuth = (db: dbClient) => {
return Promise.resolve(true); return Promise.resolve(true);
}, },
async after(user) { async after(user) {
let avatarKey = user.image; if (cloudMailerClient) {
await cloudMailerClient.events.track({
event: "user-signup",
email: user.email,
data: {
name: user.name,
userId: user.id,
},
});
}
if ( if (
user.image && user.image &&
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!) !user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
@@ -380,9 +391,6 @@ export const initAuth = (db: dbClient) => {
ACL: "public-read", ACL: "public-read",
}), }),
); );
avatarKey = key;
await userRepo.update(db, user.id, { await userRepo.update(db, user.id, {
image: key, image: key,
}); });
@@ -390,51 +398,6 @@ export const initAuth = (db: dbClient) => {
console.error(error); console.error(error);
} }
} }
if (notificationClient) {
try {
const [firstName, ...rest] = user.name
.split(" ")
.filter(Boolean);
const lastName = rest.length ? rest.join(" ") : undefined;
const avatarUrl = avatarKey
? `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${avatarKey}`
: undefined;
await notificationClient.trigger({
to: {
subscriberId: user.id,
firstName: firstName,
lastName: lastName,
email: user.email,
avatar: avatarUrl,
data: {
emailVerified: user.emailVerified,
stripeCustomerId: user.stripeCustomerId,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
},
},
workflowId: "user-signup",
});
await notificationClient.subscribers.credentials.update(
{
providerId: ChatOrPushProviderEnum.Discord,
credentials: {
webhookUrl: process.env.DISCORD_WEBHOOK_URL!,
},
integrationIdentifier: "discord",
},
user.id,
);
} catch (error) {
console.error(
"Error adding user to notification client",
error,
);
}
}
}, },
}, },
}, },
@@ -472,34 +435,3 @@ export const initAuth = (db: dbClient) => {
}, },
}); });
}; };
async function triggerWorkflow(
db: dbClient,
workflowId: string,
subscription: Subscription,
cancellationDetails?: Stripe.Subscription.CancellationDetails | null,
) {
try {
if (!subscription.stripeCustomerId || !notificationClient) return;
const user = await userRepo.getByStripeCustomerId(
db,
subscription.stripeCustomerId,
);
if (!user || !notificationClient) return;
await notificationClient.trigger({
to: {
subscriberId: user.id,
},
payload: {
...subscription,
cancellationDetails,
},
workflowId,
});
} catch (error) {
console.error("Error triggering workflow", error);
}
}

View File

@@ -28,15 +28,6 @@ export const getById = async (db: dbClient, userId: string) => {
}); });
}; };
export const getByStripeCustomerId = async (
db: dbClient,
stripeCustomerId: string,
) => {
return await db.query.users.findFirst({
where: eq(users.stripeCustomerId, stripeCustomerId),
});
};
export const getByEmail = (db: dbClient, email: string) => { export const getByEmail = (db: dbClient, email: string) => {
return db.query.users.findFirst({ return db.query.users.findFirst({
columns: { columns: {

View File

@@ -23,7 +23,7 @@
"typecheck": "tsc --noEmit --emitDeclarationOnly false" "typecheck": "tsc --noEmit --emitDeclarationOnly false"
}, },
"dependencies": { "dependencies": {
"@novu/api": "^3.11.0", "@plunk/node": "^3.0.3",
"@react-email/components": "^0.0.25", "@react-email/components": "^0.0.25",
"nodemailer": "^7.0.3", "nodemailer": "^7.0.3",
"react-email": "^3.0.1" "react-email": "^3.0.1"

View File

@@ -0,0 +1,10 @@
import Plunk from "@plunk/node";
export const cloudMailerClient =
process.env.NEXT_PUBLIC_KAN_ENV === "cloud" &&
process.env.PLUNK_API_KEY &&
process.env.PLUNK_API_URL
? new Plunk(process.env.PLUNK_API_KEY ?? "", {
baseUrl: process.env.PLUNK_API_URL,
})
: null;

View File

@@ -1,4 +1,4 @@
export const name = "email"; export const name = "email";
export { sendEmail } from "./sendEmail"; export { sendEmail } from "./sendEmail";
export { notificationClient } from "./notificationClient"; export { cloudMailerClient } from "./cloudMailerClient";

View File

@@ -1,6 +0,0 @@
import { Novu } from "@novu/api";
export const notificationClient =
process.env.NEXT_PUBLIC_KAN_ENV === "cloud" && process.env.NOVU_API_KEY
? new Novu({ secretKey: process.env.NOVU_API_KEY })
: null;

View File

@@ -1,6 +1,7 @@
import { render } from "@react-email/render"; import { render } from "@react-email/render";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { cloudMailerClient } from "./cloudMailerClient";
import JoinWorkspaceTemplate from "./templates/join-workspace"; import JoinWorkspaceTemplate from "./templates/join-workspace";
import MagicLinkTemplate from "./templates/magic-link"; import MagicLinkTemplate from "./templates/magic-link";
import ResetPasswordTemplate from "./templates/reset-password"; import ResetPasswordTemplate from "./templates/reset-password";
@@ -44,6 +45,15 @@ export const sendEmail = async (
html, html,
}; };
// if (cloudMailerClient) {
// const response = await cloudMailerClient.emails.send({
// ...options,
// body: html,
// });
// return response;
// }
const response = await transporter.sendMail(options); const response = await transporter.sendMail(options);
if (!response.accepted.length) { if (!response.accepted.length) {

View File

@@ -71,13 +71,4 @@ export const updateSubscriptionSeats = async (
return updatedSubscription; return updatedSubscription;
}; };
export const getCancellationDetails = async (
stripeSubscriptionId: string,
): Promise<Stripe.Subscription.CancellationDetails | null> => {
const stripe = createStripeClient();
const stripeSubscription =
await stripe.subscriptions.retrieve(stripeSubscriptionId);
return stripeSubscription.cancellation_details ?? null;
};
export { createStripeClient }; export { createStripeClient };

20
pnpm-lock.yaml generated
View File

@@ -440,9 +440,9 @@ importers:
packages/email: packages/email:
dependencies: dependencies:
'@novu/api': '@plunk/node':
specifier: ^3.11.0 specifier: ^3.0.3
version: 3.11.0 version: 3.0.3
'@react-email/components': '@react-email/components':
specifier: ^0.0.25 specifier: ^0.0.25
version: 0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 0.0.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -2683,9 +2683,6 @@ packages:
resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
'@novu/api@3.11.0':
resolution: {integrity: sha512-8u0mB5VThL7MhdxoN0UoA4CS9eu2k3Xa6iulauMhCHENuJNxUNuirJWq5t8jDoH4bFTQTfMN0VRkC0qodKR2qA==}
'@octokit/auth-token@3.0.4': '@octokit/auth-token@3.0.4':
resolution: {integrity: sha512-TWFX7cZF2LXoCvdmJWY7XVPi74aSY0+FfBZNSXEXFkMpjcqsQwDSYVv5FhRFaI0V1ECnwbz4j59T/G+rXNWaIQ==} resolution: {integrity: sha512-TWFX7cZF2LXoCvdmJWY7XVPi74aSY0+FfBZNSXEXFkMpjcqsQwDSYVv5FhRFaI0V1ECnwbz4j59T/G+rXNWaIQ==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -2765,6 +2762,9 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
'@plunk/node@3.0.3':
resolution: {integrity: sha512-f3NT418/EdLjebQ4arAwR64cnz7d5H7i1CeKh8+B0cVBrwzHvF+Q+QkhutF+FAp49yuI/BYc71qQH+RFc2fxxQ==}
'@popperjs/core@2.11.8': '@popperjs/core@2.11.8':
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
@@ -10226,10 +10226,6 @@ snapshots:
'@nodelib/fs.scandir': 2.1.5 '@nodelib/fs.scandir': 2.1.5
fastq: 1.19.1 fastq: 1.19.1
'@novu/api@3.11.0':
dependencies:
zod: 3.25.76
'@octokit/auth-token@3.0.4': {} '@octokit/auth-token@3.0.4': {}
'@octokit/core@4.2.4': '@octokit/core@4.2.4':
@@ -10349,6 +10345,10 @@ snapshots:
'@pkgjs/parseargs@0.11.0': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
'@plunk/node@3.0.3':
dependencies:
tslib: 2.8.1
'@popperjs/core@2.11.8': {} '@popperjs/core@2.11.8': {}
'@posthog/core@1.0.2': {} '@posthog/core@1.0.2': {}

View File

@@ -99,7 +99,6 @@
"NEXT_PUBLIC_KAN_ENV", "NEXT_PUBLIC_KAN_ENV",
"NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY", "NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY",
"STRIPE_SECRET_KEY", "STRIPE_SECRET_KEY",
"DISCORD_WEBHOOK_URL",
"STRIPE_WEBHOOK_SECRET", "STRIPE_WEBHOOK_SECRET",
"STRIPE_WEBHOOK_SECRET_LEGACY", "STRIPE_WEBHOOK_SECRET_LEGACY",
"STRIPE_PRO_PLAN_MONTHLY_PRICE_ID", "STRIPE_PRO_PLAN_MONTHLY_PRICE_ID",
@@ -121,7 +120,8 @@
"PORT", "PORT",
"BETTER_AUTH_SECRET", "BETTER_AUTH_SECRET",
"BETTER_AUTH_TRUSTED_ORIGINS", "BETTER_AUTH_TRUSTED_ORIGINS",
"NOVU_API_KEY" "PLUNK_API_KEY",
"PLUNK_API_URL"
], ],
"globalPassThroughEnv": [ "globalPassThroughEnv": [
"NODE_ENV", "NODE_ENV",