mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-08 23:45:22 +02:00
Merge branch 'canary' into feat/add-chat
This commit is contained in:
@@ -424,6 +424,26 @@ test("Custom entrypoint with internalPath adds addprefix middleware", async () =
|
||||
expect(router.entryPoints).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
{
|
||||
...baseDomain,
|
||||
path: "/public",
|
||||
stripPath: true,
|
||||
internalPath: "/app/v2",
|
||||
},
|
||||
"web",
|
||||
);
|
||||
|
||||
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
|
||||
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
|
||||
|
||||
expect(stripIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(addIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(stripIndex).toBeLessThan(addIndex);
|
||||
});
|
||||
|
||||
test("Custom entrypoint with https and custom cert resolver", async () => {
|
||||
const router = await createRouterConfig(
|
||||
baseApp,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import { Bot, Loader2, RotateCcw, X } from "lucide-react";
|
||||
import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import { toast } from "sonner";
|
||||
@@ -32,14 +33,13 @@ export function AnalyzeLogs({ logs, context }: Props) {
|
||||
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
|
||||
enabled: open,
|
||||
});
|
||||
const { mutate, isPending, data, reset } =
|
||||
api.ai.analyzeLogs.useMutation({
|
||||
onError: (error) => {
|
||||
toast.error("Analysis failed", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
|
||||
onError: (error) => {
|
||||
toast.error("Analysis failed", {
|
||||
description: error.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleAnalyze = () => {
|
||||
if (!aiId || logs.length === 0) return;
|
||||
@@ -92,38 +92,57 @@ export function AnalyzeLogs({ logs, context }: Props) {
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
{!data?.analysis ? (
|
||||
<>
|
||||
<Select value={aiId} onValueChange={setAiId}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select AI provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.map((p) => (
|
||||
<SelectItem key={p.aiId} value={p.aiId}>
|
||||
{p.name} ({p.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={!aiId || isPending || logs.length === 0}
|
||||
onClick={handleAnalyze}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="mr-2 h-3.5 w-3.5" />
|
||||
Analyze {logs.length > MAX_LOG_LINES ? `last ${MAX_LOG_LINES}` : logs.length} lines
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
providers && providers.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No AI providers configured. Set up a provider to start
|
||||
analyzing logs.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href="/dashboard/settings/ai">
|
||||
<Settings className="mr-2 h-3.5 w-3.5" />
|
||||
Configure AI Provider
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select value={aiId} onValueChange={setAiId}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="Select AI provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers?.map((p) => (
|
||||
<SelectItem key={p.aiId} value={p.aiId}>
|
||||
{p.name} ({p.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={!aiId || isPending || logs.length === 0}
|
||||
onClick={handleAnalyze}
|
||||
>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="mr-2 h-3.5 w-3.5" />
|
||||
Analyze{" "}
|
||||
{logs.length > MAX_LOG_LINES
|
||||
? `last ${MAX_LOG_LINES}`
|
||||
: logs.length}{" "}
|
||||
lines
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-[400px] overflow-y-auto">
|
||||
|
||||
@@ -378,7 +378,7 @@ export const DockerLogsId: React.FC<Props> = ({
|
||||
<DownloadIcon className="mr-2 h-4 w-4" />
|
||||
Download logs
|
||||
</Button>
|
||||
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
||||
<AnalyzeLogs logs={filteredLogs} context="runtime" />
|
||||
</div>
|
||||
</div>
|
||||
{isPaused && (
|
||||
|
||||
@@ -220,11 +220,11 @@ export const ContainerFreeMonitoring = ({
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Used: {currentData.cpu.value}
|
||||
Used: {String(currentData.cpu.value ?? "0%")}
|
||||
</span>
|
||||
<Progress
|
||||
value={Number.parseInt(
|
||||
currentData.cpu.value.replace("%", ""),
|
||||
String(currentData.cpu.value ?? "0%").replace("%", ""),
|
||||
10,
|
||||
)}
|
||||
className="w-[100%]"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { loadStripe } from "@stripe/stripe-js";
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckIcon,
|
||||
CreditCard,
|
||||
FileText,
|
||||
@@ -25,7 +26,17 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { NumberInput } from "@/components/ui/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
@@ -90,6 +101,8 @@ export const ShowBilling = () => {
|
||||
api.stripe.createCustomerPortalSession.useMutation();
|
||||
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
|
||||
api.stripe.upgradeSubscription.useMutation();
|
||||
const { mutateAsync: updateInvoiceNotifications } =
|
||||
api.stripe.updateInvoiceNotifications.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||
@@ -151,14 +164,66 @@ export const ShowBilling = () => {
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
<CardHeader className="flex flex-row items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<CreditCard className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and invoices
|
||||
</CardDescription>
|
||||
</div>
|
||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Bell className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Notification Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your billing email notifications.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="invoice-notifications">
|
||||
Invoice Notifications
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive email notifications for payments and failed
|
||||
charges.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="invoice-notifications"
|
||||
checked={admin?.user.sendInvoiceNotifications ?? false}
|
||||
onCheckedChange={async (checked) => {
|
||||
await updateInvoiceNotifications({
|
||||
enabled: checked,
|
||||
})
|
||||
.then(() => {
|
||||
utils.user.get.invalidate();
|
||||
toast.success(
|
||||
checked
|
||||
? "Invoice notifications enabled"
|
||||
: "Invoice notifications disabled",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(
|
||||
"Failed to update invoice notifications",
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
|
||||
@@ -68,7 +68,7 @@ const AI_PROVIDERS = [
|
||||
{ name: "DeepInfra", apiUrl: "https://api.deepinfra.com/v1/openai" },
|
||||
{ name: "Ollama", apiUrl: "http://localhost:11434" },
|
||||
{ name: "OpenRouter", apiUrl: "https://openrouter.ai/api/v1" },
|
||||
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4/" },
|
||||
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4" },
|
||||
{ name: "MiniMax", apiUrl: "https://api.minimax.io/v1" },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -55,7 +55,8 @@ export const WelcomeSubscription = () => {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const stepper = useStepper();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const { push } = useRouter();
|
||||
const router = useRouter();
|
||||
const { push } = router;
|
||||
|
||||
useEffect(() => {
|
||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||
@@ -66,7 +67,22 @@ export const WelcomeSubscription = () => {
|
||||
}, [showConfetti]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
const { success, ...rest } = router.query;
|
||||
router.replace(
|
||||
{ pathname: router.pathname, query: rest },
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
||||
{showConfetti ?? "Flaso"}
|
||||
<div className="flex justify-center items-center w-full">
|
||||
|
||||
1
apps/dokploy/drizzle/0165_abnormal_greymalkin.sql
Normal file
1
apps/dokploy/drizzle/0165_abnormal_greymalkin.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "sendInvoiceNotifications" boolean DEFAULT false NOT NULL;
|
||||
8312
apps/dokploy/drizzle/meta/0165_snapshot.json
Normal file
8312
apps/dokploy/drizzle/meta/0165_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1156,6 +1156,13 @@
|
||||
"when": 1775369858244,
|
||||
"tag": "0164_slippery_sasquatch",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 165,
|
||||
"version": "7",
|
||||
"when": 1775845419261,
|
||||
"tag": "0165_abnormal_greymalkin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { and, asc, eq } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import { organization, server, user } from "@/server/db/schema";
|
||||
import {
|
||||
sendInvoiceEmail,
|
||||
sendPaymentFailedEmail,
|
||||
} from "@/server/utils/stripe-notifications";
|
||||
|
||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||
|
||||
@@ -241,6 +245,11 @@ export default async function handler(
|
||||
}
|
||||
const newServersQuantity = admin.serversQuantity;
|
||||
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
||||
|
||||
if (admin.sendInvoiceNotifications) {
|
||||
await sendInvoiceEmail(newInvoice, admin);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "invoice.payment_failed": {
|
||||
@@ -249,7 +258,6 @@ export default async function handler(
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
newInvoice.subscription as string,
|
||||
);
|
||||
|
||||
if (subscription.status !== "active") {
|
||||
const admin = await findUserByStripeCustomerId(
|
||||
newInvoice.customer as string,
|
||||
@@ -263,6 +271,10 @@ export default async function handler(
|
||||
break;
|
||||
}
|
||||
|
||||
if (admin.sendInvoiceNotifications) {
|
||||
await sendPaymentFailedEmail(newInvoice, admin);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(user)
|
||||
.set({
|
||||
|
||||
@@ -217,7 +217,7 @@ export const aiRouter = createTRPCRouter({
|
||||
context: z.enum(["build", "runtime"]),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const aiSettings = await getAiSettingById(input.aiId);
|
||||
if (!aiSettings?.isEnabled) {
|
||||
@@ -227,6 +227,13 @@ export const aiRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Access denied",
|
||||
});
|
||||
}
|
||||
|
||||
const provider = selectAIProvider(aiSettings);
|
||||
const model = provider(aiSettings.model);
|
||||
|
||||
@@ -253,7 +260,9 @@ ${input.logs}`,
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error ? error.message : `Analysis failed: ${error}`,
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Analysis failed: ${error}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
@@ -285,7 +294,9 @@ ${input.logs}`,
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error ? error.message : `Connection failed: ${error}`,
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Connection failed: ${error}`,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findGitProviderById,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getApplicationStats,
|
||||
getContainerLogs,
|
||||
IS_CLOUD,
|
||||
@@ -27,7 +28,6 @@ import {
|
||||
updateDeploymentStatus,
|
||||
writeConfig,
|
||||
writeConfigRemote,
|
||||
getAccessibleServerIds,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
|
||||
@@ -1168,6 +1168,7 @@ export const composeRouter = createTRPCRouter({
|
||||
input.since,
|
||||
input.search,
|
||||
compose.serverId,
|
||||
true,
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
findEnvironmentById,
|
||||
findMariadbById,
|
||||
findProjectById,
|
||||
getAccessibleServerIds,
|
||||
getContainerLogs,
|
||||
getServiceContainerCommand,
|
||||
IS_CLOUD,
|
||||
@@ -20,7 +21,6 @@ import {
|
||||
stopService,
|
||||
stopServiceRemote,
|
||||
updateMariadbById,
|
||||
getAccessibleServerIds,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
|
||||
@@ -205,11 +205,16 @@ export const stripeRouter = createTRPCRouter({
|
||||
mode: "subscription",
|
||||
line_items: items,
|
||||
...(stripeCustomerId
|
||||
? { customer: stripeCustomerId }
|
||||
? {
|
||||
customer: stripeCustomerId,
|
||||
customer_update: { name: "auto", address: "auto" },
|
||||
}
|
||||
: { customer_email: owner.email }),
|
||||
metadata: {
|
||||
adminId: owner.id,
|
||||
},
|
||||
billing_address_collection: "required",
|
||||
tax_id_collection: { enabled: true },
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
|
||||
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
|
||||
@@ -332,6 +337,22 @@ export const stripeRouter = createTRPCRouter({
|
||||
},
|
||||
),
|
||||
|
||||
updateInvoiceNotifications: adminProcedure
|
||||
.input(z.object({ enabled: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This feature is only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
const owner = await findUserById(ctx.user.ownerId);
|
||||
await updateUser(owner.id, {
|
||||
sendInvoiceNotifications: input.enabled,
|
||||
});
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
getInvoices: adminProcedure.query(async ({ ctx }) => {
|
||||
const user = await findUserById(ctx.user.ownerId);
|
||||
const stripeCustomerId = user.stripeCustomerId;
|
||||
|
||||
113
apps/dokploy/server/utils/stripe-notifications.ts
Normal file
113
apps/dokploy/server/utils/stripe-notifications.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import InvoiceNotificationEmail from "@dokploy/server/emails/emails/invoice-notification";
|
||||
import PaymentFailedEmail from "@dokploy/server/emails/emails/payment-failed";
|
||||
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
|
||||
import { renderAsync } from "@react-email/components";
|
||||
import { format } from "date-fns";
|
||||
import type Stripe from "stripe";
|
||||
|
||||
function formatAmount(amountInCents: number, currency: string): string {
|
||||
const amount = amountInCents / 100;
|
||||
const formatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currency.toUpperCase(),
|
||||
});
|
||||
return formatter.format(amount);
|
||||
}
|
||||
|
||||
const downloadPdf = async (url: string): Promise<Buffer | null> => {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return null;
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
return Buffer.from(arrayBuffer);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const sendInvoiceEmail = async (
|
||||
invoice: Stripe.Invoice,
|
||||
admin: { email: string; firstName: string },
|
||||
) => {
|
||||
if (!invoice.hosted_invoice_url) return;
|
||||
|
||||
try {
|
||||
const amountFormatted = formatAmount(invoice.amount_paid, invoice.currency);
|
||||
|
||||
const htmlContent = await renderAsync(
|
||||
InvoiceNotificationEmail({
|
||||
userName: admin.firstName || "User",
|
||||
invoiceNumber: invoice.number || invoice.id,
|
||||
amountPaid: amountFormatted,
|
||||
currency: invoice.currency,
|
||||
date: format(new Date(invoice.created * 1000), "MMM dd, yyyy"),
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
}),
|
||||
);
|
||||
|
||||
const attachments: { filename: string; content: Buffer }[] = [];
|
||||
|
||||
if (invoice.invoice_pdf) {
|
||||
const pdfBuffer = await downloadPdf(invoice.invoice_pdf);
|
||||
if (pdfBuffer) {
|
||||
attachments.push({
|
||||
filename: `dokploy-invoice-${invoice.number || invoice.id}.pdf`,
|
||||
content: pdfBuffer,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
email: admin.email,
|
||||
subject: `Dokploy Invoice ${invoice.number || ""} - ${amountFormatted}`,
|
||||
text: htmlContent,
|
||||
attachments,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Invoice email sent to ${admin.email} for invoice ${invoice.number}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send invoice email to ${admin.email}:`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const sendPaymentFailedEmail = async (
|
||||
invoice: Stripe.Invoice,
|
||||
admin: { email: string; firstName: string },
|
||||
) => {
|
||||
if (!invoice.hosted_invoice_url) return;
|
||||
|
||||
try {
|
||||
const amountFormatted = formatAmount(invoice.amount_due, invoice.currency);
|
||||
|
||||
const htmlContent = await renderAsync(
|
||||
PaymentFailedEmail({
|
||||
userName: admin.firstName || "User",
|
||||
invoiceNumber: invoice.number || invoice.id,
|
||||
amountDue: amountFormatted,
|
||||
currency: invoice.currency,
|
||||
date: format(new Date(invoice.created * 1000), "MMM dd, yyyy"),
|
||||
hostedInvoiceUrl: invoice.hosted_invoice_url,
|
||||
}),
|
||||
);
|
||||
|
||||
await sendEmail({
|
||||
email: admin.email,
|
||||
subject: `Action required: Dokploy payment failed - ${amountFormatted}`,
|
||||
text: htmlContent,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`Payment failed email sent to ${admin.email} for invoice ${invoice.number}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to send payment failed email to ${admin.email}:`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -33,7 +33,7 @@ app.use(async (c, next) => {
|
||||
|
||||
app.post("/create-backup", zValidator("json", jobQueueSchema), async (c) => {
|
||||
const data = c.req.valid("json");
|
||||
scheduleJob(data);
|
||||
await scheduleJob(data);
|
||||
logger.info({ data }, `[${data.type}] created successfully`);
|
||||
return c.json({ message: `[${data.type}] created successfully` });
|
||||
});
|
||||
@@ -70,7 +70,7 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
|
||||
}
|
||||
logger.info({ result }, "Job removed");
|
||||
}
|
||||
scheduleJob(data);
|
||||
await scheduleJob(data);
|
||||
logger.info("Backup updated successfully");
|
||||
|
||||
return c.json({ message: "Backup updated successfully" });
|
||||
@@ -103,8 +103,8 @@ process.on("uncaughtException", (err) => {
|
||||
logger.error(err, "Uncaught exception");
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
logger.error({ promise, reason }, "Unhandled Rejection at: Promise");
|
||||
process.on("unhandledRejection", (reason, _promise) => {
|
||||
logger.error(reason instanceof Error ? reason : { reason: String(reason) }, "Unhandled Rejection at: Promise");
|
||||
});
|
||||
|
||||
const port = Number.parseInt(process.env.PORT || "3000");
|
||||
|
||||
@@ -21,28 +21,28 @@ export const cleanQueue = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
export const scheduleJob = (job: QueueJob) => {
|
||||
export const scheduleJob = async (job: QueueJob) => {
|
||||
if (job.type === "backup") {
|
||||
jobQueue.add(job.backupId, job, {
|
||||
await jobQueue.add(job.backupId, job, {
|
||||
repeat: {
|
||||
pattern: job.cronSchedule,
|
||||
},
|
||||
});
|
||||
} else if (job.type === "server") {
|
||||
jobQueue.add(`${job.serverId}-cleanup`, job, {
|
||||
await jobQueue.add(`${job.serverId}-cleanup`, job, {
|
||||
repeat: {
|
||||
pattern: job.cronSchedule,
|
||||
},
|
||||
});
|
||||
} else if (job.type === "schedule") {
|
||||
jobQueue.add(job.scheduleId, job, {
|
||||
await jobQueue.add(job.scheduleId, job, {
|
||||
repeat: {
|
||||
pattern: job.cronSchedule,
|
||||
tz: job.timezone || "UTC",
|
||||
},
|
||||
});
|
||||
} else if (job.type === "volume-backup") {
|
||||
jobQueue.add(job.volumeBackupId, job, {
|
||||
await jobQueue.add(job.volumeBackupId, job, {
|
||||
repeat: {
|
||||
pattern: job.cronSchedule,
|
||||
},
|
||||
|
||||
@@ -135,11 +135,15 @@ export const initializeJobs = async () => {
|
||||
|
||||
for (const server of servers) {
|
||||
const { serverId } = server;
|
||||
scheduleJob({
|
||||
serverId,
|
||||
type: "server",
|
||||
cronSchedule: CLEANUP_CRON_JOB,
|
||||
});
|
||||
try {
|
||||
await scheduleJob({
|
||||
serverId,
|
||||
type: "server",
|
||||
cronSchedule: CLEANUP_CRON_JOB,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to schedule cleanup job for server ${serverId}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info({ Quantity: servers.length }, "Active Servers Initialized");
|
||||
@@ -157,11 +161,15 @@ export const initializeJobs = async () => {
|
||||
});
|
||||
|
||||
for (const backup of backupsResult) {
|
||||
scheduleJob({
|
||||
backupId: backup.backupId,
|
||||
type: "backup",
|
||||
cronSchedule: backup.schedule,
|
||||
});
|
||||
try {
|
||||
await scheduleJob({
|
||||
backupId: backup.backupId,
|
||||
type: "backup",
|
||||
cronSchedule: backup.schedule,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to schedule backup ${backup.backupId}`);
|
||||
}
|
||||
}
|
||||
logger.info({ Quantity: backupsResult.length }, "Backups Initialized");
|
||||
|
||||
@@ -197,11 +205,15 @@ export const initializeJobs = async () => {
|
||||
);
|
||||
|
||||
for (const schedule of filteredSchedulesBasedOnServerStatus) {
|
||||
scheduleJob({
|
||||
scheduleId: schedule.scheduleId,
|
||||
type: "schedule",
|
||||
cronSchedule: schedule.cronExpression,
|
||||
});
|
||||
try {
|
||||
await scheduleJob({
|
||||
scheduleId: schedule.scheduleId,
|
||||
type: "schedule",
|
||||
cronSchedule: schedule.cronExpression,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to schedule ${schedule.scheduleId}`);
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
|
||||
@@ -236,11 +248,15 @@ export const initializeJobs = async () => {
|
||||
);
|
||||
|
||||
for (const volumeBackup of filteredVolumeBackupsBasedOnServerStatus) {
|
||||
scheduleJob({
|
||||
volumeBackupId: volumeBackup.volumeBackupId,
|
||||
type: "volume-backup",
|
||||
cronSchedule: volumeBackup.cronExpression,
|
||||
});
|
||||
try {
|
||||
await scheduleJob({
|
||||
volumeBackupId: volumeBackup.volumeBackupId,
|
||||
type: "volume-backup",
|
||||
cronSchedule: volumeBackup.cronExpression,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to schedule volume backup ${volumeBackup.volumeBackupId}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
|
||||
Reference in New Issue
Block a user