chore: format

This commit is contained in:
2025-10-10 16:43:21 +02:00
parent f0aabd63b6
commit 75c29e0ba4
551 changed files with 433948 additions and 94145 deletions

View File

@@ -102,10 +102,7 @@
"filesystem": { "filesystem": {
"type": "stdio", "type": "stdio",
"command": "pnpm", "command": "pnpm",
"args": [ "args": ["mcp-server-filesystem", "repos/compose"],
"mcp-server-filesystem",
"repos/compose"
],
"env": {} "env": {}
} }
}, },

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@
!.zshenv !.zshenv
!.hushlogin !.hushlogin
!.last_pwd !.last_pwd
!biome.json
!/.github/ !/.github/
!/.github/** !/.github/**

View File

@@ -12,9 +12,7 @@
"Id": "Ant-Dark", "Id": "Ant-Dark",
"License": "GPL 3+", "License": "GPL 3+",
"Name": "Ant-Dark", "Name": "Ant-Dark",
"ServiceTypes": [ "ServiceTypes": ["Plasma/LookAndFeel"],
"Plasma/LookAndFeel"
],
"Version": "0.1", "Version": "0.1",
"Website": "https://github.com/EliverLara/Ant/tree/master/kde/Dark" "Website": "https://github.com/EliverLara/Ant/tree/master/kde/Dark"
}, },

View File

@@ -12,9 +12,7 @@
"Id": "Nordic-bluish", "Id": "Nordic-bluish",
"License": "GPL 3+", "License": "GPL 3+",
"Name": "Nordic-bluish", "Name": "Nordic-bluish",
"ServiceTypes": [ "ServiceTypes": ["Plasma/LookAndFeel"],
"Plasma/LookAndFeel"
],
"Version": "0.1", "Version": "0.1",
"Website": "https://github.com/EliverLara/Nordic" "Website": "https://github.com/EliverLara/Nordic"
}, },

View File

@@ -12,9 +12,7 @@
"Id": "Nordic-darker", "Id": "Nordic-darker",
"License": "GPL 3+", "License": "GPL 3+",
"Name": "Nordic-darker", "Name": "Nordic-darker",
"ServiceTypes": [ "ServiceTypes": ["Plasma/LookAndFeel"],
"Plasma/LookAndFeel"
],
"Version": "0.1", "Version": "0.1",
"Website": "https://github.com/EliverLara/Nordic" "Website": "https://github.com/EliverLara/Nordic"
}, },

View File

@@ -12,9 +12,7 @@
"Id": "Nordic", "Id": "Nordic",
"License": "GPL 3+", "License": "GPL 3+",
"Name": "Nordic", "Name": "Nordic",
"ServiceTypes": [ "ServiceTypes": ["Plasma/LookAndFeel"],
"Plasma/LookAndFeel"
],
"Version": "0.1", "Version": "0.1",
"Website": "https://github.com/EliverLara/Nordic" "Website": "https://github.com/EliverLara/Nordic"
}, },

View File

@@ -1,19 +1,19 @@
function isDark(color) { function isDark(color) {
var r = color.r; var r = color.r;
var g = color.g; var g = color.g;
var b = color.b; var b = color.b;
// Using the HSP value, determine whether the color is light or dark // Using the HSP value, determine whether the color is light or dark
var colorArray = [r, g , b ].map(v => { var colorArray = [r, g, b].map((v) => {
if (v <= 0.03928) { if (v <= 0.03928) {
return v / 12.92 return v / 12.92;
} }
return Math.pow((v + 0.055) / 1.055, 2.4) return Math.pow((v + 0.055) / 1.055, 2.4);
}) });
var luminance = 0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2] var luminance =
0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2];
return luminance <= 0.179 return luminance <= 0.179;
} }

View File

@@ -17,6 +17,6 @@
"Version": "0.6", "Version": "0.6",
"Website": "https://github.com/EliverLara/AndromedaLauncher" "Website": "https://github.com/EliverLara/AndromedaLauncher"
}, },
"X-Plasma-Provides": [ "org.kde.plasma.launchermenu" ], "X-Plasma-Provides": ["org.kde.plasma.launchermenu"],
"X-Plasma-API-Minimum-Version": "6.0" "X-Plasma-API-Minimum-Version": "6.0"
} }

View File

@@ -1,6 +1,5 @@
function updateBrightness(rootItem, source) { function updateBrightness(rootItem, source) {
if (rootItem.updateScreenBrightnessJob) if (rootItem.updateScreenBrightnessJob) return;
return;
if (!source.data["PowerDevil"]) { if (!source.data["PowerDevil"]) {
return; return;
@@ -9,7 +8,7 @@ function updateBrightness(rootItem, source) {
// we don't want passive brightness change send setBrightness call // we don't want passive brightness change send setBrightness call
rootItem.disableBrightnessUpdate = true; rootItem.disableBrightnessUpdate = true;
if (typeof source.data["PowerDevil"]["Screen Brightness"] === 'number') { if (typeof source.data["PowerDevil"]["Screen Brightness"] === "number") {
rootItem.screenBrightness = source.data["PowerDevil"]["Screen Brightness"]; rootItem.screenBrightness = source.data["PowerDevil"]["Screen Brightness"];
} }
rootItem.disableBrightnessUpdate = false; rootItem.disableBrightnessUpdate = false;

View File

@@ -1,21 +1,20 @@
function isDark(color) { function isDark(color) {
//color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/); //color = color.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+(?:\.\d+)?))?\)$/);
var r = color.r; var r = color.r;
var g = color.g; var g = color.g;
var b = color.b; var b = color.b;
var colorArray = [r, g, b].map((v) => {
var colorArray = [r, g , b ].map(v => {
if (v <= 0.03928) { if (v <= 0.03928) {
return v / 12.92 return v / 12.92;
} }
return Math.pow((v + 0.055) / 1.055, 2.4) return Math.pow((v + 0.055) / 1.055, 2.4);
}) });
var luminance = 0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2] var luminance =
0.2126 * colorArray[0] + 0.7152 * colorArray[1] + 0.0722 * colorArray[2];
return luminance <= 0.179 return luminance <= 0.179;
} }

View File

@@ -3,8 +3,8 @@ function getBtDevice() {
var status = { var status = {
active: false, active: false,
message: "" message: "",
} };
for (var i = 0; i < btManager.devices.length; ++i) { for (var i = 0; i < btManager.devices.length; ++i) {
var device = btManager.devices[i]; var device = btManager.devices[i];
@@ -34,8 +34,7 @@ function getBtDevice() {
return status; return status;
} }
function toggleBluetooth() function toggleBluetooth() {
{
var enable = !btManager.bluetoothOperational; var enable = !btManager.bluetoothOperational;
btManager.bluetoothBlocked = !enable; btManager.bluetoothBlocked = !enable;
@@ -45,7 +44,6 @@ function toggleBluetooth()
} }
} }
function checkInhibition() { function checkInhibition() {
var inhibited = false; var inhibited = false;
@@ -54,7 +52,7 @@ function checkInhibition() {
} }
var inhibitedUntil = notificationSettings.notificationsInhibitedUntil; var inhibitedUntil = notificationSettings.notificationsInhibitedUntil;
if (!isNaN(inhibitedUntil.getTime())) { if (!isNaN(inhibitedUntil.getTime())) {
inhibited |= (Date.now() < inhibitedUntil.getTime()); inhibited |= Date.now() < inhibitedUntil.getTime();
} }
if (notificationSettings.notificationsInhibitedByApplication) { if (notificationSettings.notificationsInhibitedByApplication) {
@@ -80,10 +78,10 @@ function toggleDnd() {
} }
var d = new Date(); var d = new Date();
d.setYear(d.getFullYear()+1) d.setYear(d.getFullYear() + 1);
notificationSettings.notificationsInhibitedUntil = d notificationSettings.notificationsInhibitedUntil = d;
notificationSettings.save() notificationSettings.save();
} }
function revokeInhibitions() { function revokeInhibitions() {
@@ -112,18 +110,23 @@ function toggleRedshiftInhibition() {
} }
function volumePercent(volume) { function volumePercent(volume) {
return volume / Vol.PulseAudio.NormalVolume * 100 return (volume / Vol.PulseAudio.NormalVolume) * 100;
} }
function boundVolume(volume) { function boundVolume(volume) {
return Math.max(Vol.PulseAudio.MinimalVolume, Math.min(volume, Vol.PulseAudio.NormalVolume)); return Math.max(
Vol.PulseAudio.MinimalVolume,
Math.min(volume, Vol.PulseAudio.NormalVolume),
);
} }
function changeVolumeByPercent(volumeObject, deltaPercent) { function changeVolumeByPercent(volumeObject, deltaPercent) {
const oldVolume = volumeObject.volume; const oldVolume = volumeObject.volume;
const oldPercent = volumePercent(oldVolume); const oldPercent = volumePercent(oldVolume);
const targetPercent = oldPercent + deltaPercent; const targetPercent = oldPercent + deltaPercent;
const newVolume = boundVolume(Math.round(Vol.PulseAudio.NormalVolume * (targetPercent/100))); const newVolume = boundVolume(
Math.round(Vol.PulseAudio.NormalVolume * (targetPercent / 100)),
);
const newPercent = volumePercent(newVolume); const newPercent = volumePercent(newVolume);
volumeObject.muted = newPercent == 0; volumeObject.muted = newPercent == 0;
volumeObject.volume = newVolume; volumeObject.volume = newVolume;
@@ -134,7 +137,7 @@ function volIconName(volume, muted, prefix) {
prefix = "audio-volume"; prefix = "audio-volume";
} }
var icon = null; var icon = null;
var percent = volume / Vol.PulseAudio.NormalVolume var percent = volume / Vol.PulseAudio.NormalVolume;
if (percent <= 0.0 || muted) { if (percent <= 0.0 || muted) {
icon = prefix + "-muted"; icon = prefix + "-muted";
} else if (percent <= 0.25) { } else if (percent <= 0.25) {
@@ -151,9 +154,11 @@ function getNetworkConnectionName() {
var status = network.networkStatus.activeConnections; var status = network.networkStatus.activeConnections;
var statusParts; var statusParts;
if(isAirplane){ return "On"; } if (isAirplane) {
return "On";
}
if(status && status !== "Disconnected") { if (status && status !== "Disconnected") {
statusParts = status.split(":"); statusParts = status.split(":");
var connectionName = statusParts[1]?.trim().split(" ").slice(2).join(" "); var connectionName = statusParts[1]?.trim().split(" ").slice(2).join(" ");
return connectionName || "Connected"; return connectionName || "Connected";

View File

@@ -1,9 +1,6 @@
function listProperty(item) { function listProperty(item) {
for (var p in item) for (var p in item) {
{ if (typeof item[p] != "function")
if( typeof item[p] != "function" ) if (p != "objectName") console.log(p + ":" + item[p]);
if(p != "objectName")
console.log(p + ":" + item[p]);
} }
} }

View File

@@ -18,9 +18,7 @@
"Name[de]": "KDE Kontrollzentrum", "Name[de]": "KDE Kontrollzentrum",
"Name[ko]": "KDE 제어 센터", "Name[ko]": "KDE 제어 센터",
"Name[pt_BR]": "Estação de controle KDE", "Name[pt_BR]": "Estação de controle KDE",
"ServiceTypes": [ "ServiceTypes": ["Plasma/Applet"],
"Plasma/Applet"
],
"Version": "0.1.0", "Version": "0.1.0",
"Website": "https://github.com/EliverLara/kde-control-station/tree/plasma6" "Website": "https://github.com/EliverLara/kde-control-station/tree/plasma6"
}, },

View File

@@ -12,9 +12,7 @@
"postCreateCommand": "dir=/workspaces/ohmyzsh; rm -rf $HOME/.oh-my-zsh && ln -s $dir $HOME/.oh-my-zsh && cp $dir/templates/minimal.zshrc $HOME/.zshrc && chgrp -R 1000 $dir && chmod g-w,o-w $dir", "postCreateCommand": "dir=/workspaces/ohmyzsh; rm -rf $HOME/.oh-my-zsh && ln -s $dir $HOME/.oh-my-zsh && cp $dir/templates/minimal.zshrc $HOME/.zshrc && chgrp -R 1000 $dir && chmod g-w,o-w $dir",
"customizations": { "customizations": {
"codespaces": { "codespaces": {
"openFiles": [ "openFiles": ["README.md"]
"README.md"
]
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
/* Custom keyframes for pulse */ /* Custom keyframes for pulse */
@keyframes pulse { @keyframes pulse {
0%, 100% { 0%,
100% {
opacity: 1; opacity: 1;
} }
50% { 50% {

View File

@@ -1,57 +1,65 @@
import type { Metadata } from 'next' import type { Metadata } from "next";
import { Inter } from 'next/font/google' import { Inter } from "next/font/google";
import './globals.css' import "./globals.css";
const inter = Inter({ subsets: ['latin'] }) const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Pivoine Docs - Documentation Hub', title: "Pivoine Docs - Documentation Hub",
description: 'Comprehensive documentation hub for all Pivoine projects by Valknar. Explore technical guides, API references, and tutorials.', description:
keywords: ['documentation', 'pivoine', 'valknar', 'developer', 'guides', 'api'], "Comprehensive documentation hub for all Pivoine projects by Valknar. Explore technical guides, API references, and tutorials.",
authors: [{ name: 'Valknar', url: 'https://pivoine.art' }], keywords: [
creator: 'Valknar', "documentation",
manifest: '/manifest.json', "pivoine",
"valknar",
"developer",
"guides",
"api",
],
authors: [{ name: "Valknar", url: "https://pivoine.art" }],
creator: "Valknar",
manifest: "/manifest.json",
icons: { icons: {
icon: [ icon: [
{ url: '/favicon.svg', type: 'image/svg+xml' }, { url: "/favicon.svg", type: "image/svg+xml" },
{ url: '/icon.svg', type: 'image/svg+xml', sizes: 'any' }, { url: "/icon.svg", type: "image/svg+xml", sizes: "any" },
], ],
apple: [ apple: [
{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }, { url: "/apple-touch-icon.png", sizes: "180x180", type: "image/png" },
], ],
}, },
appleWebApp: { appleWebApp: {
capable: true, capable: true,
statusBarStyle: 'black-translucent', statusBarStyle: "black-translucent",
title: 'Pivoine Docs', title: "Pivoine Docs",
}, },
openGraph: { openGraph: {
type: 'website', type: "website",
locale: 'en_US', locale: "en_US",
url: 'https://docs.pivoine.art', url: "https://docs.pivoine.art",
title: 'Pivoine Docs - Documentation Hub', title: "Pivoine Docs - Documentation Hub",
description: 'Comprehensive documentation hub for all Pivoine projects', description: "Comprehensive documentation hub for all Pivoine projects",
siteName: 'Pivoine Docs', siteName: "Pivoine Docs",
}, },
twitter: { twitter: {
card: 'summary_large_image', card: "summary_large_image",
title: 'Pivoine Docs - Documentation Hub', title: "Pivoine Docs - Documentation Hub",
description: 'Comprehensive documentation hub for all Pivoine projects', description: "Comprehensive documentation hub for all Pivoine projects",
}, },
robots: { robots: {
index: true, index: true,
follow: true, follow: true,
}, },
} };
export default function RootLayout({ export default function RootLayout({
children, children,
}: { }: {
children: React.ReactNode children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en" className="scroll-smooth"> <html lang="en" className="scroll-smooth">
<body className={inter.className}>{children}</body> <body className={inter.className}>{children}</body>
</html> </html>
) );
} }

View File

@@ -1,49 +1,56 @@
'use client' "use client";
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from "react";
import { BookOpen, Code2, Globe, ChevronRight, Sparkles, Terminal } from 'lucide-react' import {
import KomposeIcon from '@/components/icons/KomposeIcon' BookOpen,
import { PivoineDocsIcon } from '@/components/icons' Code2,
Globe,
ChevronRight,
Sparkles,
Terminal,
} from "lucide-react";
import KomposeIcon from "@/components/icons/KomposeIcon";
import { PivoineDocsIcon } from "@/components/icons";
export default function DocsHub() { export default function DocsHub() {
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }) const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
const [isHovering, setIsHovering] = useState<string | null>(null) const [isHovering, setIsHovering] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
const handleMouseMove = (e: MouseEvent) => { const handleMouseMove = (e: MouseEvent) => {
setMousePosition({ setMousePosition({
x: (e.clientX / window.innerWidth) * 20 - 10, x: (e.clientX / window.innerWidth) * 20 - 10,
y: (e.clientY / window.innerHeight) * 20 - 10, y: (e.clientY / window.innerHeight) * 20 - 10,
}) });
} };
window.addEventListener('mousemove', handleMouseMove) window.addEventListener("mousemove", handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove) return () => window.removeEventListener("mousemove", handleMouseMove);
}, []) }, []);
const projects = [ const projects = [
{ {
name: 'Kompose', name: "Kompose",
status: 'Active', status: "Active",
description: 'Comprehensive documentation for Kompose project', description: "Comprehensive documentation for Kompose project",
url: '/kompose', url: "/kompose",
gradient: 'from-violet-500 to-purple-600' gradient: "from-violet-500 to-purple-600",
} },
] ];
const links = [ const links = [
{ {
title: "Valknar's Blog", title: "Valknar's Blog",
icon: Globe, icon: Globe,
url: 'http://pivoine.art', url: "http://pivoine.art",
gradient: 'from-pink-500 to-rose-600' gradient: "from-pink-500 to-rose-600",
}, },
{ {
title: 'Source Code', title: "Source Code",
icon: Code2, icon: Code2,
url: 'https://code.pivoine.art', url: "https://code.pivoine.art",
gradient: 'from-cyan-500 to-blue-600' gradient: "from-cyan-500 to-blue-600",
} },
] ];
return ( return (
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900 text-white overflow-hidden"> <div className="min-h-screen bg-gradient-to-br from-gray-900 via-purple-900/20 to-gray-900 text-white overflow-hidden">
@@ -53,18 +60,21 @@ export default function DocsHub() {
className="absolute w-96 h-96 bg-purple-500/20 rounded-full blur-3xl top-0 -left-48 animate-pulse" className="absolute w-96 h-96 bg-purple-500/20 rounded-full blur-3xl top-0 -left-48 animate-pulse"
style={{ style={{
transform: `translate(${mousePosition.x}px, ${mousePosition.y}px)`, transform: `translate(${mousePosition.x}px, ${mousePosition.y}px)`,
transition: 'transform 0.3s ease-out' transition: "transform 0.3s ease-out",
}} }}
/> />
<div <div
className="absolute w-96 h-96 bg-pink-500/20 rounded-full blur-3xl bottom-0 -right-48 animate-pulse" className="absolute w-96 h-96 bg-pink-500/20 rounded-full blur-3xl bottom-0 -right-48 animate-pulse"
style={{ style={{
transform: `translate(${-mousePosition.x}px, ${-mousePosition.y}px)`, transform: `translate(${-mousePosition.x}px, ${-mousePosition.y}px)`,
transition: 'transform 0.3s ease-out', transition: "transform 0.3s ease-out",
animationDelay: '1s' animationDelay: "1s",
}} }}
/> />
<div className="absolute w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-pulse" style={{ animationDelay: '0.5s' }} /> <div
className="absolute w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 animate-pulse"
style={{ animationDelay: "0.5s" }}
/>
</div> </div>
{/* Main content */} {/* Main content */}
@@ -73,7 +83,11 @@ export default function DocsHub() {
<header className="text-center mb-20 pt-12"> <header className="text-center mb-20 pt-12">
{/* Hero Icon */} {/* Hero Icon */}
<div className="flex justify-center mb-8"> <div className="flex justify-center mb-8">
<PivoineDocsIcon size="200px" showLabel={false} interactive={true} /> <PivoineDocsIcon
size="200px"
showLabel={false}
interactive={true}
/>
</div> </div>
<div className="inline-flex items-center gap-2 mb-6 px-4 py-2 bg-white/5 backdrop-blur-sm rounded-full border border-white/10"> <div className="inline-flex items-center gap-2 mb-6 px-4 py-2 bg-white/5 backdrop-blur-sm rounded-full border border-white/10">
@@ -86,7 +100,8 @@ export default function DocsHub() {
</h1> </h1>
<p className="text-xl text-gray-300 max-w-2xl mx-auto leading-relaxed"> <p className="text-xl text-gray-300 max-w-2xl mx-auto leading-relaxed">
Comprehensive documentation for all projects by <span className="text-purple-400 font-semibold">Valknar</span>. Comprehensive documentation for all projects by{" "}
<span className="text-purple-400 font-semibold">Valknar</span>.
Explore technical guides, API references, and tutorials. Explore technical guides, API references, and tutorials.
</p> </p>
</header> </header>
@@ -107,17 +122,29 @@ export default function DocsHub() {
onMouseLeave={() => setIsHovering(null)} onMouseLeave={() => setIsHovering(null)}
className="group relative bg-white/5 backdrop-blur-md rounded-2xl p-8 border border-white/10 hover:border-purple-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-purple-500/20" className="group relative bg-white/5 backdrop-blur-md rounded-2xl p-8 border border-white/10 hover:border-purple-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-purple-500/20"
> >
<div className="absolute inset-0 bg-gradient-to-br opacity-0 group-hover:opacity-10 rounded-2xl transition-opacity duration-300" <div
style={{ background: `linear-gradient(135deg, rgb(168, 85, 247), rgb(147, 51, 234))` }} /> className="absolute inset-0 bg-gradient-to-br opacity-0 group-hover:opacity-10 rounded-2xl transition-opacity duration-300"
style={{
background: `linear-gradient(135deg, rgb(168, 85, 247), rgb(147, 51, 234))`,
}}
/>
<div className="relative"> <div className="relative">
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
{project.name === 'Kompose' ? ( {project.name === "Kompose" ? (
<div className={`relative w-14 h-14 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg flex items-center justify-center`}> <div
<KomposeIcon size="36px" interactive={false} className='' /> className={`relative w-14 h-14 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg flex items-center justify-center`}
>
<KomposeIcon
size="36px"
interactive={false}
className=""
/>
</div> </div>
) : ( ) : (
<div className={`p-3 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg`}> <div
className={`p-3 rounded-xl bg-gradient-to-br ${project.gradient} shadow-lg`}
>
<BookOpen className="w-8 h-8 text-white" /> <BookOpen className="w-8 h-8 text-white" />
</div> </div>
)} )}
@@ -148,7 +175,9 @@ export default function DocsHub() {
<div className="p-3 rounded-xl bg-gradient-to-br from-gray-600 to-gray-700 w-fit mb-4"> <div className="p-3 rounded-xl bg-gradient-to-br from-gray-600 to-gray-700 w-fit mb-4">
<BookOpen className="w-8 h-8 text-white" /> <BookOpen className="w-8 h-8 text-white" />
</div> </div>
<h3 className="text-2xl font-bold mb-3 text-gray-400">More Projects</h3> <h3 className="text-2xl font-bold mb-3 text-gray-400">
More Projects
</h3>
<p className="text-gray-500 leading-relaxed"> <p className="text-gray-500 leading-relaxed">
Additional documentation sites coming soon... Additional documentation sites coming soon...
</p> </p>
@@ -166,7 +195,7 @@ export default function DocsHub() {
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
{links.map((link, idx) => { {links.map((link, idx) => {
const Icon = link.icon const Icon = link.icon;
return ( return (
<a <a
key={idx} key={idx}
@@ -175,7 +204,9 @@ export default function DocsHub() {
rel="noopener noreferrer" rel="noopener noreferrer"
className="group bg-white/5 backdrop-blur-md rounded-2xl p-6 border border-white/10 hover:border-pink-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-pink-500/20 flex items-center gap-4" className="group bg-white/5 backdrop-blur-md rounded-2xl p-6 border border-white/10 hover:border-pink-500/50 transition-all duration-300 hover:scale-105 hover:shadow-2xl hover:shadow-pink-500/20 flex items-center gap-4"
> >
<div className={`p-4 rounded-xl bg-gradient-to-br ${link.gradient} shadow-lg group-hover:scale-110 transition-transform`}> <div
className={`p-4 rounded-xl bg-gradient-to-br ${link.gradient} shadow-lg group-hover:scale-110 transition-transform`}
>
<Icon className="w-7 h-7 text-white" /> <Icon className="w-7 h-7 text-white" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
@@ -186,7 +217,7 @@ export default function DocsHub() {
</div> </div>
<ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-pink-400 group-hover:translate-x-1 transition-all" /> <ChevronRight className="w-6 h-6 text-gray-400 group-hover:text-pink-400 group-hover:translate-x-1 transition-all" />
</a> </a>
) );
})} })}
</div> </div>
</section> </section>
@@ -194,11 +225,17 @@ export default function DocsHub() {
{/* Footer */} {/* Footer */}
<footer className="mt-20 pt-8 border-t border-white/10 text-center text-gray-400"> <footer className="mt-20 pt-8 border-t border-white/10 text-center text-gray-400">
<p className="text-sm"> <p className="text-sm">
Crafted with passion by <span className="text-purple-400 font-semibold">Valknar</span> · Crafted with passion by{" "}
<a href="http://pivoine.art" className="hover:text-purple-300 transition-colors ml-1">pivoine.art</a> <span className="text-purple-400 font-semibold">Valknar</span> ·
<a
href="http://pivoine.art"
className="hover:text-purple-300 transition-colors ml-1"
>
pivoine.art
</a>
</p> </p>
</footer> </footer>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,269 +1,368 @@
'use client' "use client";
import PivoineDocsIcon from './PivoineDocsIcon' import PivoineDocsIcon from "./PivoineDocsIcon";
export default function PivoineIconDemo() { export default function PivoineIconDemo() {
return ( return (
<div style={{ <div
minHeight: '100vh', style={{
background: 'linear-gradient(135deg, #1e293b 0%, #0f172a 100%)', minHeight: "100vh",
padding: '4rem 2rem', background: "linear-gradient(135deg, #1e293b 0%, #0f172a 100%)",
color: '#fff' padding: "4rem 2rem",
}}> color: "#fff",
<div style={{ }}
maxWidth: '1400px', >
margin: '0 auto' <div
}}> style={{
maxWidth: "1400px",
margin: "0 auto",
}}
>
{/* Header */} {/* Header */}
<div style={{ textAlign: 'center', marginBottom: '4rem' }}> <div style={{ textAlign: "center", marginBottom: "4rem" }}>
<h1 style={{ <h1
fontSize: '3rem', style={{
fontWeight: 'bold', fontSize: "3rem",
background: 'linear-gradient(135deg, #ec4899, #a855f7, #c084fc)', fontWeight: "bold",
backgroundClip: 'text', background: "linear-gradient(135deg, #ec4899, #a855f7, #c084fc)",
WebkitBackgroundClip: 'text', backgroundClip: "text",
WebkitTextFillColor: 'transparent', WebkitBackgroundClip: "text",
marginBottom: '1rem' WebkitTextFillColor: "transparent",
}}> marginBottom: "1rem",
}}
>
Pivoine Docs Icon Pivoine Docs Icon
</h1> </h1>
<p style={{ <p
fontSize: '1.25rem', style={{
color: '#94a3b8', fontSize: "1.25rem",
maxWidth: '600px', color: "#94a3b8",
margin: '0 auto' maxWidth: "600px",
}}> margin: "0 auto",
}}
>
A beautiful animated peony blossom icon with interactive states A beautiful animated peony blossom icon with interactive states
</p> </p>
</div> </div>
{/* Main Showcase */} {/* Main Showcase */}
<div style={{ <div
display: 'grid', style={{
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', display: "grid",
gap: '3rem', gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
marginBottom: '4rem' gap: "3rem",
}}> marginBottom: "4rem",
}}
>
{/* Large Interactive */} {/* Large Interactive */}
<div style={{ <div
background: 'rgba(255, 255, 255, 0.05)', style={{
borderRadius: '1rem', background: "rgba(255, 255, 255, 0.05)",
padding: '2rem', borderRadius: "1rem",
textAlign: 'center', padding: "2rem",
backdropFilter: 'blur(10px)', textAlign: "center",
border: '1px solid rgba(255, 255, 255, 0.1)' backdropFilter: "blur(10px)",
}}> border: "1px solid rgba(255, 255, 255, 0.1)",
<h3 style={{ marginBottom: '1.5rem', color: '#f472b6' }}> }}
>
<h3 style={{ marginBottom: "1.5rem", color: "#f472b6" }}>
Interactive (Hover & Click) Interactive (Hover & Click)
</h3> </h3>
<div style={{ <div
display: 'flex', style={{
justifyContent: 'center', display: "flex",
alignItems: 'center', justifyContent: "center",
minHeight: '320px' alignItems: "center",
}}> minHeight: "320px",
}}
>
<PivoineDocsIcon size="280px" /> <PivoineDocsIcon size="280px" />
</div> </div>
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}> <p
style={{
color: "#94a3b8",
fontSize: "0.875rem",
marginTop: "1rem",
}}
>
Hover to bloom Click to close Hover to bloom Click to close
</p> </p>
</div> </div>
{/* With Label */} {/* With Label */}
<div style={{ <div
background: 'rgba(255, 255, 255, 0.05)', style={{
borderRadius: '1rem', background: "rgba(255, 255, 255, 0.05)",
padding: '2rem', borderRadius: "1rem",
textAlign: 'center', padding: "2rem",
backdropFilter: 'blur(10px)', textAlign: "center",
border: '1px solid rgba(255, 255, 255, 0.1)' backdropFilter: "blur(10px)",
}}> border: "1px solid rgba(255, 255, 255, 0.1)",
<h3 style={{ marginBottom: '1.5rem', color: '#c084fc' }}> }}
>
<h3 style={{ marginBottom: "1.5rem", color: "#c084fc" }}>
With Label With Label
</h3> </h3>
<div style={{ <div
display: 'flex', style={{
justifyContent: 'center', display: "flex",
alignItems: 'center', justifyContent: "center",
minHeight: '320px' alignItems: "center",
}}> minHeight: "320px",
}}
>
<PivoineDocsIcon size="240px" showLabel /> <PivoineDocsIcon size="240px" showLabel />
</div> </div>
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}> <p
style={{
color: "#94a3b8",
fontSize: "0.875rem",
marginTop: "1rem",
}}
>
Perfect for hero sections Perfect for hero sections
</p> </p>
</div> </div>
{/* Non-Interactive */} {/* Non-Interactive */}
<div style={{ <div
background: 'rgba(255, 255, 255, 0.05)', style={{
borderRadius: '1rem', background: "rgba(255, 255, 255, 0.05)",
padding: '2rem', borderRadius: "1rem",
textAlign: 'center', padding: "2rem",
backdropFilter: 'blur(10px)', textAlign: "center",
border: '1px solid rgba(255, 255, 255, 0.1)' backdropFilter: "blur(10px)",
}}> border: "1px solid rgba(255, 255, 255, 0.1)",
<h3 style={{ marginBottom: '1.5rem', color: '#fb7185' }}> }}
>
<h3 style={{ marginBottom: "1.5rem", color: "#fb7185" }}>
Static (Non-Interactive) Static (Non-Interactive)
</h3> </h3>
<div style={{ <div
display: 'flex', style={{
justifyContent: 'center', display: "flex",
alignItems: 'center', justifyContent: "center",
minHeight: '320px' alignItems: "center",
}}> minHeight: "320px",
}}
>
<PivoineDocsIcon size="240px" interactive={false} /> <PivoineDocsIcon size="240px" interactive={false} />
</div> </div>
<p style={{ color: '#94a3b8', fontSize: '0.875rem', marginTop: '1rem' }}> <p
style={{
color: "#94a3b8",
fontSize: "0.875rem",
marginTop: "1rem",
}}
>
Ideal for favicons & PWA icons Ideal for favicons & PWA icons
</p> </p>
</div> </div>
</div> </div>
{/* Size Variations */} {/* Size Variations */}
<div style={{ <div
background: 'rgba(255, 255, 255, 0.05)', style={{
borderRadius: '1rem', background: "rgba(255, 255, 255, 0.05)",
padding: '3rem', borderRadius: "1rem",
backdropFilter: 'blur(10px)', padding: "3rem",
border: '1px solid rgba(255, 255, 255, 0.1)', backdropFilter: "blur(10px)",
marginBottom: '4rem' border: "1px solid rgba(255, 255, 255, 0.1)",
}}> marginBottom: "4rem",
<h2 style={{ }}
fontSize: '2rem', >
fontWeight: 'bold', <h2
marginBottom: '2rem', style={{
textAlign: 'center', fontSize: "2rem",
color: '#f0abfc' fontWeight: "bold",
}}> marginBottom: "2rem",
textAlign: "center",
color: "#f0abfc",
}}
>
Size Variations Size Variations
</h2> </h2>
<div style={{ <div
display: 'flex', style={{
justifyContent: 'space-around', display: "flex",
alignItems: 'flex-end', justifyContent: "space-around",
flexWrap: 'wrap', alignItems: "flex-end",
gap: '2rem', flexWrap: "wrap",
padding: '2rem' gap: "2rem",
}}> padding: "2rem",
<div style={{ textAlign: 'center' }}> }}
>
<div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="64px" /> <PivoineDocsIcon size="64px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}> <p
64px<br />Favicon style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
64px
<br />
Favicon
</p> </p>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="96px" /> <PivoineDocsIcon size="96px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}> <p
96px<br />Small style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
96px
<br />
Small
</p> </p>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="128px" /> <PivoineDocsIcon size="128px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}> <p
128px<br />Medium style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
128px
<br />
Medium
</p> </p>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="192px" /> <PivoineDocsIcon size="192px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}> <p
192px<br />Large style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
192px
<br />
Large
</p> </p>
</div> </div>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: "center" }}>
<PivoineDocsIcon size="256px" /> <PivoineDocsIcon size="256px" />
<p style={{ color: '#94a3b8', fontSize: '0.75rem', marginTop: '0.5rem' }}> <p
256px<br />X-Large style={{
color: "#94a3b8",
fontSize: "0.75rem",
marginTop: "0.5rem",
}}
>
256px
<br />
X-Large
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Feature List */} {/* Feature List */}
<div style={{ <div
background: 'rgba(255, 255, 255, 0.05)', style={{
borderRadius: '1rem', background: "rgba(255, 255, 255, 0.05)",
padding: '3rem', borderRadius: "1rem",
backdropFilter: 'blur(10px)', padding: "3rem",
border: '1px solid rgba(255, 255, 255, 0.1)' backdropFilter: "blur(10px)",
}}> border: "1px solid rgba(255, 255, 255, 0.1)",
<h2 style={{ }}
fontSize: '2rem', >
fontWeight: 'bold', <h2
marginBottom: '2rem', style={{
textAlign: 'center', fontSize: "2rem",
color: '#f0abfc' fontWeight: "bold",
}}> marginBottom: "2rem",
textAlign: "center",
color: "#f0abfc",
}}
>
Features Features
</h2> </h2>
<div style={{ <div
display: 'grid', style={{
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))', display: "grid",
gap: '2rem' gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
}}> gap: "2rem",
}}
>
{[ {[
{ {
icon: '🌸', icon: "🌸",
title: 'Realistic Design', title: "Realistic Design",
description: 'Multi-layered peony with natural gradients' description: "Multi-layered peony with natural gradients",
}, },
{ {
icon: '✨', icon: "✨",
title: 'Smooth Animations', title: "Smooth Animations",
description: 'Gentle breathing in normal state' description: "Gentle breathing in normal state",
}, },
{ {
icon: '🎭', icon: "🎭",
title: 'Interactive States', title: "Interactive States",
description: 'Bloom on hover, close on click' description: "Bloom on hover, close on click",
}, },
{ {
icon: '💫', icon: "💫",
title: 'Particle Effects', title: "Particle Effects",
description: '12 bloom particles flying around' description: "12 bloom particles flying around",
}, },
{ {
icon: '🎨', icon: "🎨",
title: 'Beautiful Colors', title: "Beautiful Colors",
description: 'Pink to purple gradient palette' description: "Pink to purple gradient palette",
}, },
{ {
icon: '♿', icon: "♿",
title: 'Accessible', title: "Accessible",
description: 'Reduced motion & touch support' description: "Reduced motion & touch support",
}, },
{ {
icon: '📱', icon: "📱",
title: 'Responsive', title: "Responsive",
description: 'Works perfectly on all devices' description: "Works perfectly on all devices",
}, },
{ {
icon: '⚡', icon: "⚡",
title: 'High Performance', title: "High Performance",
description: 'GPU-accelerated CSS animations' description: "GPU-accelerated CSS animations",
} },
].map((feature, i) => ( ].map((feature, i) => (
<div key={i} style={{ <div
padding: '1.5rem', key={i}
background: 'rgba(255, 255, 255, 0.03)', style={{
borderRadius: '0.75rem', padding: "1.5rem",
border: '1px solid rgba(255, 255, 255, 0.08)' background: "rgba(255, 255, 255, 0.03)",
}}> borderRadius: "0.75rem",
<div style={{ fontSize: '2rem', marginBottom: '0.75rem' }}> border: "1px solid rgba(255, 255, 255, 0.08)",
}}
>
<div style={{ fontSize: "2rem", marginBottom: "0.75rem" }}>
{feature.icon} {feature.icon}
</div> </div>
<h4 style={{ <h4
fontSize: '1.125rem', style={{
fontWeight: '600', fontSize: "1.125rem",
marginBottom: '0.5rem', fontWeight: "600",
color: '#fda4af' marginBottom: "0.5rem",
}}> color: "#fda4af",
}}
>
{feature.title} {feature.title}
</h4> </h4>
<p style={{ <p
fontSize: '0.875rem', style={{
color: '#94a3b8' fontSize: "0.875rem",
}}> color: "#94a3b8",
}}
>
{feature.description} {feature.description}
</p> </p>
</div> </div>
@@ -272,31 +371,37 @@ export default function PivoineIconDemo() {
</div> </div>
{/* Usage Example */} {/* Usage Example */}
<div style={{ <div
marginTop: '4rem', style={{
background: 'rgba(255, 255, 255, 0.05)', marginTop: "4rem",
borderRadius: '1rem', background: "rgba(255, 255, 255, 0.05)",
padding: '2rem', borderRadius: "1rem",
backdropFilter: 'blur(10px)', padding: "2rem",
border: '1px solid rgba(255, 255, 255, 0.1)' backdropFilter: "blur(10px)",
}}> border: "1px solid rgba(255, 255, 255, 0.1)",
<h2 style={{ }}
fontSize: '1.5rem', >
fontWeight: 'bold', <h2
marginBottom: '1rem', style={{
color: '#f0abfc' fontSize: "1.5rem",
}}> fontWeight: "bold",
marginBottom: "1rem",
color: "#f0abfc",
}}
>
Quick Start Quick Start
</h2> </h2>
<pre style={{ <pre
background: 'rgba(0, 0, 0, 0.3)', style={{
padding: '1.5rem', background: "rgba(0, 0, 0, 0.3)",
borderRadius: '0.5rem', padding: "1.5rem",
overflow: 'auto', borderRadius: "0.5rem",
fontSize: '0.875rem', overflow: "auto",
color: '#e2e8f0' fontSize: "0.875rem",
}}> color: "#e2e8f0",
{`import PivoineDocsIcon from '@/components/icons/PivoineDocsIcon' }}
>
{`import PivoineDocsIcon from '@/components/icons/PivoineDocsIcon'
// Basic usage // Basic usage
<PivoineDocsIcon size="256px" /> <PivoineDocsIcon size="256px" />
@@ -310,15 +415,17 @@ export default function PivoineIconDemo() {
</div> </div>
{/* Footer */} {/* Footer */}
<div style={{ <div
marginTop: '4rem', style={{
textAlign: 'center', marginTop: "4rem",
color: '#64748b', textAlign: "center",
fontSize: '0.875rem' color: "#64748b",
}}> fontSize: "0.875rem",
}}
>
<p>Made with 🌸 for beautiful documentation experiences</p> <p>Made with 🌸 for beautiful documentation experiences</p>
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@@ -47,7 +47,8 @@
} }
.kompose-icon-wrapper.is-interactive:hover .k-diagonal-bottom { .kompose-icon-wrapper.is-interactive:hover .k-diagonal-bottom {
animation: line-slide-diagonal-bottom 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) 0.2s; animation: line-slide-diagonal-bottom 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)
0.2s;
} }
.kompose-icon-wrapper.is-interactive:hover .status-dot { .kompose-icon-wrapper.is-interactive:hover .status-dot {

View File

@@ -1,48 +1,50 @@
'use client' "use client";
import React, { useState } from 'react' import React, { useState } from "react";
import './KomposeIcon.css' import "./KomposeIcon.css";
interface KomposeIconProps { interface KomposeIconProps {
size?: string size?: string;
interactive?: boolean interactive?: boolean;
className?: string className?: string;
} }
export default function KomposeIcon({ export default function KomposeIcon({
size = '192px', size = "192px",
interactive = true, interactive = true,
className = '' className = "",
}: KomposeIconProps) { }: KomposeIconProps) {
const [isClicked, setIsClicked] = useState(false) const [isClicked, setIsClicked] = useState(false);
const [showRipple, setShowRipple] = useState(false) const [showRipple, setShowRipple] = useState(false);
const handleClick = () => { const handleClick = () => {
if (!interactive) return if (!interactive) return;
setIsClicked(true) setIsClicked(true);
setShowRipple(true) setShowRipple(true);
setTimeout(() => { setTimeout(() => {
setIsClicked(false) setIsClicked(false);
}, 600) }, 600);
setTimeout(() => { setTimeout(() => {
setShowRipple(false) setShowRipple(false);
}, 800) }, 800);
} };
const handleTouch = (e: React.TouchEvent) => { const handleTouch = (e: React.TouchEvent) => {
if (!interactive) return if (!interactive) return;
handleClick() handleClick();
} };
const wrapperClasses = [ const wrapperClasses = [
'kompose-icon-wrapper', "kompose-icon-wrapper",
isClicked && 'is-clicked', isClicked && "is-clicked",
interactive && 'is-interactive', interactive && "is-interactive",
className className,
].filter(Boolean).join(' ') ]
.filter(Boolean)
.join(" ");
return ( return (
<div <div
@@ -58,23 +60,58 @@ export default function KomposeIcon({
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<defs> <defs>
<pattern id="carbon192" x="0" y="0" width="7.68" height="7.68" patternUnits="userSpaceOnUse"> <pattern
id="carbon192"
x="0"
y="0"
width="7.68"
height="7.68"
patternUnits="userSpaceOnUse"
>
<rect width="7.68" height="7.68" fill="#0a0e27"></rect> <rect width="7.68" height="7.68" fill="#0a0e27"></rect>
<path d="M0,0 L3.84,3.84 M3.84,0 L7.68,3.84 M0,3.84 L3.84,7.68" stroke="#060815" strokeWidth="1.5" opacity="0.5"></path> <path
d="M0,0 L3.84,3.84 M3.84,0 L7.68,3.84 M0,3.84 L3.84,7.68"
stroke="#060815"
strokeWidth="1.5"
opacity="0.5"
></path>
</pattern> </pattern>
<linearGradient id="bgGrad192" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient id="bgGrad192" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style={{ stopColor: '#1a1d2e', stopOpacity: 1 }}></stop> <stop
<stop offset="100%" style={{ stopColor: '#0a0e27', stopOpacity: 1 }}></stop> offset="0%"
style={{ stopColor: "#1a1d2e", stopOpacity: 1 }}
></stop>
<stop
offset="100%"
style={{ stopColor: "#0a0e27", stopOpacity: 1 }}
></stop>
</linearGradient> </linearGradient>
<linearGradient id="primaryGrad192" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient
<stop offset="0%" className="gradient-start" style={{ stopColor: '#00DC82', stopOpacity: 1 }}></stop> id="primaryGrad192"
<stop offset="100%" className="gradient-end" style={{ stopColor: '#00a86b', stopOpacity: 1 }}></stop> x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop
offset="0%"
className="gradient-start"
style={{ stopColor: "#00DC82", stopOpacity: 1 }}
></stop>
<stop
offset="100%"
className="gradient-end"
style={{ stopColor: "#00a86b", stopOpacity: 1 }}
></stop>
</linearGradient> </linearGradient>
<filter id="glow192"> <filter id="glow192">
<feGaussianBlur stdDeviation="6" result="coloredBlur"></feGaussianBlur> <feGaussianBlur
stdDeviation="6"
result="coloredBlur"
></feGaussianBlur>
<feMerge> <feMerge>
<feMergeNode in="coloredBlur"></feMergeNode> <feMergeNode in="coloredBlur"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode> <feMergeNode in="SourceGraphic"></feMergeNode>
@@ -82,7 +119,10 @@ export default function KomposeIcon({
</filter> </filter>
<filter id="intenseglow192"> <filter id="intenseglow192">
<feGaussianBlur stdDeviation="12" result="coloredBlur"></feGaussianBlur> <feGaussianBlur
stdDeviation="12"
result="coloredBlur"
></feGaussianBlur>
<feMerge> <feMerge>
<feMergeNode in="coloredBlur"></feMergeNode> <feMergeNode in="coloredBlur"></feMergeNode>
<feMergeNode in="SourceGraphic"></feMergeNode> <feMergeNode in="SourceGraphic"></feMergeNode>
@@ -91,29 +131,124 @@ export default function KomposeIcon({
</defs> </defs>
{/* Background */} {/* Background */}
<rect className="bg-rect" width="192" height="192" rx="24" fill="url(#bgGrad192)"></rect> <rect
<rect className="carbon-pattern" width="192" height="192" rx="24" fill="url(#carbon192)" opacity="0.4"></rect> className="bg-rect"
width="192"
height="192"
rx="24"
fill="url(#bgGrad192)"
></rect>
<rect
className="carbon-pattern"
width="192"
height="192"
rx="24"
fill="url(#carbon192)"
opacity="0.4"
></rect>
{/* Stylized K */} {/* Stylized K */}
<g className="k-letter" transform="translate(48, 48)"> <g className="k-letter" transform="translate(48, 48)">
<line className="k-line k-vertical" x1="0" y1="0" x2="0" y2="96" stroke="url(#primaryGrad192)" strokeWidth="15" strokeLinecap="round" filter="url(#glow192)"></line> <line
<line className="k-line k-diagonal-top" x1="0" y1="48" x2="57.6" y2="0" stroke="url(#primaryGrad192)" strokeWidth="15" strokeLinecap="round" filter="url(#glow192)"></line> className="k-line k-vertical"
<line className="k-line k-diagonal-bottom" x1="0" y1="48" x2="57.6" y2="96" stroke="url(#primaryGrad192)" strokeWidth="15" strokeLinecap="round" filter="url(#glow192)"></line> x1="0"
y1="0"
x2="0"
y2="96"
stroke="url(#primaryGrad192)"
strokeWidth="15"
strokeLinecap="round"
filter="url(#glow192)"
></line>
<line
className="k-line k-diagonal-top"
x1="0"
y1="48"
x2="57.6"
y2="0"
stroke="url(#primaryGrad192)"
strokeWidth="15"
strokeLinecap="round"
filter="url(#glow192)"
></line>
<line
className="k-line k-diagonal-bottom"
x1="0"
y1="48"
x2="57.6"
y2="96"
stroke="url(#primaryGrad192)"
strokeWidth="15"
strokeLinecap="round"
filter="url(#glow192)"
></line>
</g> </g>
{/* Animated status dot */} {/* Animated status dot */}
<circle className="status-dot" cx="163.2" cy="163.2" r="11.52" fill="#00DC82" opacity="0.9"></circle> <circle
<circle className="status-ring" cx="163.2" cy="163.2" r="17.28" fill="none" stroke="#00DC82" strokeWidth="3" opacity="0.3"></circle> className="status-dot"
cx="163.2"
cy="163.2"
r="11.52"
fill="#00DC82"
opacity="0.9"
></circle>
<circle
className="status-ring"
cx="163.2"
cy="163.2"
r="17.28"
fill="none"
stroke="#00DC82"
strokeWidth="3"
opacity="0.3"
></circle>
{/* Tech corners */} {/* Tech corners */}
<line className="corner corner-tl-h" x1="15.36" y1="15.36" x2="28.8" y2="15.36" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line> <line
<line className="corner corner-tl-v" x1="15.36" y1="15.36" x2="15.36" y2="28.8" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line> className="corner corner-tl-h"
<line className="corner corner-tr-h" x1="176.64" y1="15.36" x2="163.2" y2="15.36" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line> x1="15.36"
<line className="corner corner-tr-v" x1="176.64" y1="15.36" x2="176.64" y2="28.8" stroke="#00DC82" strokeWidth="3" opacity="0.4"></line> y1="15.36"
x2="28.8"
y2="15.36"
stroke="#00DC82"
strokeWidth="3"
opacity="0.4"
></line>
<line
className="corner corner-tl-v"
x1="15.36"
y1="15.36"
x2="15.36"
y2="28.8"
stroke="#00DC82"
strokeWidth="3"
opacity="0.4"
></line>
<line
className="corner corner-tr-h"
x1="176.64"
y1="15.36"
x2="163.2"
y2="15.36"
stroke="#00DC82"
strokeWidth="3"
opacity="0.4"
></line>
<line
className="corner corner-tr-v"
x1="176.64"
y1="15.36"
x2="176.64"
y2="28.8"
stroke="#00DC82"
strokeWidth="3"
opacity="0.4"
></line>
</svg> </svg>
{/* Ripple effect container */} {/* Ripple effect container */}
{showRipple && <div className="ripple"></div>} {showRipple && <div className="ripple"></div>}
</div> </div>
) );
} }

View File

@@ -99,14 +99,30 @@
animation: stamen-pulse 3s ease-in-out infinite; animation: stamen-pulse 3s ease-in-out infinite;
} }
.stamen-0 { animation-delay: 0s; } .stamen-0 {
.stamen-1 { animation-delay: 0.2s; } animation-delay: 0s;
.stamen-2 { animation-delay: 0.4s; } }
.stamen-3 { animation-delay: 0.6s; } .stamen-1 {
.stamen-4 { animation-delay: 0.8s; } animation-delay: 0.2s;
.stamen-5 { animation-delay: 1s; } }
.stamen-6 { animation-delay: 1.2s; } .stamen-2 {
.stamen-7 { animation-delay: 1.4s; } animation-delay: 0.4s;
}
.stamen-3 {
animation-delay: 0.6s;
}
.stamen-4 {
animation-delay: 0.8s;
}
.stamen-5 {
animation-delay: 1s;
}
.stamen-6 {
animation-delay: 1.2s;
}
.stamen-7 {
animation-delay: 1.4s;
}
/* Sparkles twinkle */ /* Sparkles twinkle */
.sparkle { .sparkle {
@@ -114,14 +130,30 @@
transform-origin: center; transform-origin: center;
} }
.sparkle-1 { animation-delay: 0s; } .sparkle-1 {
.sparkle-2 { animation-delay: 0.4s; } animation-delay: 0s;
.sparkle-3 { animation-delay: 0.8s; } }
.sparkle-4 { animation-delay: 1.2s; } .sparkle-2 {
.sparkle-5 { animation-delay: 1.6s; } animation-delay: 0.4s;
.sparkle-6 { animation-delay: 2s; } }
.sparkle-7 { animation-delay: 2.4s; } .sparkle-3 {
.sparkle-8 { animation-delay: 2.8s; } animation-delay: 0.8s;
}
.sparkle-4 {
animation-delay: 1.2s;
}
.sparkle-5 {
animation-delay: 1.6s;
}
.sparkle-6 {
animation-delay: 2s;
}
.sparkle-7 {
animation-delay: 2.4s;
}
.sparkle-8 {
animation-delay: 2.8s;
}
/* Pages subtle floating */ /* Pages subtle floating */
.page { .page {
@@ -129,20 +161,36 @@
animation: page-float 4s ease-in-out infinite; animation: page-float 4s ease-in-out infinite;
} }
.page-1 { animation-delay: 0s; } .page-1 {
.page-2 { animation-delay: 0.3s; } animation-delay: 0s;
.page-3 { animation-delay: 0.6s; } }
.page-2 {
animation-delay: 0.3s;
}
.page-3 {
animation-delay: 0.6s;
}
/* Text lines subtle shimmer */ /* Text lines subtle shimmer */
.text-line { .text-line {
animation: text-shimmer 4s ease-in-out infinite; animation: text-shimmer 4s ease-in-out infinite;
} }
.line-1 { animation-delay: 0s; } .line-1 {
.line-2 { animation-delay: 0.3s; } animation-delay: 0s;
.line-3 { animation-delay: 0.6s; } }
.line-4 { animation-delay: 0.9s; } .line-2 {
.line-5 { animation-delay: 1.2s; } animation-delay: 0.3s;
}
.line-3 {
animation-delay: 0.6s;
}
.line-4 {
animation-delay: 0.9s;
}
.line-5 {
animation-delay: 1.2s;
}
/* Bloom particles hidden in normal state */ /* Bloom particles hidden in normal state */
.bloom-particle { .bloom-particle {
@@ -179,7 +227,7 @@
} }
.pivoine-docs-icon-wrapper.is-interactive.is-hovered .inner-petal { .pivoine-docs-icon-wrapper.is-interactive.is-hovered .inner-petal {
transform: scale(1.0) translateX(16px); transform: scale(1) translateX(16px);
opacity: 0.95; opacity: 0.95;
filter: url(#petal-glow); filter: url(#petal-glow);
animation: petal-bloom-hover 2s ease-in-out infinite 0.4s; animation: petal-bloom-hover 2s ease-in-out infinite 0.4s;
@@ -273,7 +321,8 @@
/* Normal State Animations */ /* Normal State Animations */
@keyframes bg-breathe { @keyframes bg-breathe {
0%, 100% { 0%,
100% {
opacity: 0.06; opacity: 0.06;
transform: scale(1); transform: scale(1);
} }
@@ -284,7 +333,8 @@
} }
@keyframes petal-breathe { @keyframes petal-breathe {
0%, 100% { 0%,
100% {
transform: scale(0.3) translateX(8px); transform: scale(0.3) translateX(8px);
} }
50% { 50% {
@@ -293,7 +343,8 @@
} }
@keyframes petal-float { @keyframes petal-float {
0%, 100% { 0%,
100% {
transform: scale(0.75) translateX(20px) translateY(0); transform: scale(0.75) translateX(20px) translateY(0);
} }
50% { 50% {
@@ -302,7 +353,8 @@
} }
@keyframes center-breathe { @keyframes center-breathe {
0%, 100% { 0%,
100% {
transform: scale(1); transform: scale(1);
opacity: 1; opacity: 1;
} }
@@ -313,7 +365,8 @@
} }
@keyframes center-breathe-inner { @keyframes center-breathe-inner {
0%, 100% { 0%,
100% {
transform: scale(1); transform: scale(1);
opacity: 0.9; opacity: 0.9;
} }
@@ -333,7 +386,8 @@
} }
@keyframes stamen-pulse { @keyframes stamen-pulse {
0%, 100% { 0%,
100% {
transform: scale(1); transform: scale(1);
opacity: 0.8; opacity: 0.8;
} }
@@ -344,7 +398,8 @@
} }
@keyframes sparkle-twinkle { @keyframes sparkle-twinkle {
0%, 100% { 0%,
100% {
transform: scale(0.8); transform: scale(0.8);
opacity: 0.4; opacity: 0.4;
} }
@@ -355,7 +410,8 @@
} }
@keyframes page-float { @keyframes page-float {
0%, 100% { 0%,
100% {
transform: translateY(0) rotate(0deg); transform: translateY(0) rotate(0deg);
} }
50% { 50% {
@@ -364,7 +420,8 @@
} }
@keyframes text-shimmer { @keyframes text-shimmer {
0%, 100% { 0%,
100% {
opacity: 0.7; opacity: 0.7;
} }
50% { 50% {
@@ -374,7 +431,8 @@
/* Hover State Animations */ /* Hover State Animations */
@keyframes petal-bloom-hover { @keyframes petal-bloom-hover {
0%, 100% { 0%,
100% {
transform: scale(0.55) translateX(38px) rotate(0deg); transform: scale(0.55) translateX(38px) rotate(0deg);
} }
50% { 50% {
@@ -383,7 +441,8 @@
} }
@keyframes center-bloom-glow { @keyframes center-bloom-glow {
0%, 100% { 0%,
100% {
transform: scale(1.3); transform: scale(1.3);
opacity: 1; opacity: 1;
} }
@@ -394,7 +453,8 @@
} }
@keyframes center-bloom-inner { @keyframes center-bloom-inner {
0%, 100% { 0%,
100% {
transform: scale(1.4); transform: scale(1.4);
opacity: 1; opacity: 1;
} }
@@ -405,7 +465,8 @@
} }
@keyframes stamen-dance { @keyframes stamen-dance {
0%, 100% { 0%,
100% {
transform: scale(1) translateY(0); transform: scale(1) translateY(0);
} }
50% { 50% {
@@ -437,8 +498,7 @@
opacity: 0.7; opacity: 0.7;
} }
50% { 50% {
transform: transform: translate(
translate(
calc(cos(var(--particle-angle)) * var(--particle-distance)), calc(cos(var(--particle-angle)) * var(--particle-distance)),
calc(sin(var(--particle-angle)) * var(--particle-distance)) calc(sin(var(--particle-angle)) * var(--particle-distance))
) )
@@ -449,8 +509,7 @@
opacity: 0.5; opacity: 0.5;
} }
100% { 100% {
transform: transform: translate(
translate(
calc(cos(var(--particle-angle)) * var(--particle-distance) * 1.5), calc(cos(var(--particle-angle)) * var(--particle-distance) * 1.5),
calc(sin(var(--particle-angle)) * var(--particle-distance) * 1.5) calc(sin(var(--particle-angle)) * var(--particle-distance) * 1.5)
) )
@@ -460,7 +519,8 @@
} }
@keyframes bg-bloom-glow { @keyframes bg-bloom-glow {
0%, 100% { 0%,
100% {
opacity: 0.15; opacity: 0.15;
transform: scale(1.05); transform: scale(1.05);
} }
@@ -499,7 +559,8 @@
} }
@keyframes icon-pulse { @keyframes icon-pulse {
0%, 100% { 0%,
100% {
transform: scale(1); transform: scale(1);
} }
50% { 50% {
@@ -603,8 +664,7 @@
opacity: 0.7; opacity: 0.7;
} }
30% { 30% {
transform: transform: translate(
translate(
calc(cos(var(--particle-angle)) * var(--particle-distance) * 2), calc(cos(var(--particle-angle)) * var(--particle-distance) * 2),
calc(sin(var(--particle-angle)) * var(--particle-distance) * 2) calc(sin(var(--particle-angle)) * var(--particle-distance) * 2)
) )
@@ -612,8 +672,7 @@
opacity: 1; opacity: 1;
} }
100% { 100% {
transform: transform: translate(
translate(
calc(cos(var(--particle-angle)) * var(--particle-distance) * 0.3), calc(cos(var(--particle-angle)) * var(--particle-distance) * 0.3),
calc(sin(var(--particle-angle)) * var(--particle-distance) * 0.3) calc(sin(var(--particle-angle)) * var(--particle-distance) * 0.3)
) )
@@ -641,7 +700,8 @@
} }
@keyframes label-shimmer { @keyframes label-shimmer {
0%, 100% { 0%,
100% {
filter: brightness(1); filter: brightness(1);
} }
50% { 50% {
@@ -690,7 +750,9 @@
.label-text, .label-text,
.bg-glow { .bg-glow {
animation: none !important; animation: none !important;
transition: opacity 0.3s ease, transform 0.3s ease !important; transition:
opacity 0.3s ease,
transform 0.3s ease !important;
} }
.pivoine-docs-icon-wrapper.is-interactive.is-hovered { .pivoine-docs-icon-wrapper.is-interactive.is-hovered {

View File

@@ -1,64 +1,66 @@
'use client' "use client";
import React, { useState } from 'react' import React, { useState } from "react";
import './PivoineDocsIcon.css' import "./PivoineDocsIcon.css";
interface PivoineDocsIconProps { interface PivoineDocsIconProps {
size?: string size?: string;
interactive?: boolean interactive?: boolean;
className?: string className?: string;
showLabel?: boolean showLabel?: boolean;
} }
export default function PivoineDocsIcon({ export default function PivoineDocsIcon({
size = '256px', size = "256px",
interactive = true, interactive = true,
className = '', className = "",
showLabel = false showLabel = false,
}: PivoineDocsIconProps) { }: PivoineDocsIconProps) {
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false);
const [isClicked, setIsClicked] = useState(false) const [isClicked, setIsClicked] = useState(false);
const handleMouseEnter = () => { const handleMouseEnter = () => {
if (!interactive) return if (!interactive) return;
setIsHovered(true) setIsHovered(true);
} };
const handleMouseLeave = () => { const handleMouseLeave = () => {
if (!interactive) return if (!interactive) return;
setIsHovered(false) setIsHovered(false);
} };
const handleClick = () => { const handleClick = () => {
if (!interactive) return if (!interactive) return;
setIsClicked(true) setIsClicked(true);
setTimeout(() => { setTimeout(() => {
setIsClicked(false) setIsClicked(false);
}, 1200) }, 1200);
} };
const handleTouch = (e: React.TouchEvent) => { const handleTouch = (e: React.TouchEvent) => {
if (!interactive) return if (!interactive) return;
e.preventDefault() e.preventDefault();
setIsHovered(true) setIsHovered(true);
setTimeout(() => { setTimeout(() => {
handleClick() handleClick();
}, 50) }, 50);
setTimeout(() => { setTimeout(() => {
setIsHovered(false) setIsHovered(false);
}, 1500) }, 1500);
} };
const wrapperClasses = [ const wrapperClasses = [
'pivoine-docs-icon-wrapper', "pivoine-docs-icon-wrapper",
isHovered && 'is-hovered', isHovered && "is-hovered",
isClicked && 'is-clicked', isClicked && "is-clicked",
interactive && 'is-interactive', interactive && "is-interactive",
className className,
].filter(Boolean).join(' ') ]
.filter(Boolean)
.join(" ");
// Generate bloom particles with varied properties // Generate bloom particles with varied properties
const bloomParticles = Array.from({ length: 12 }, (_, i) => ({ const bloomParticles = Array.from({ length: 12 }, (_, i) => ({
@@ -67,7 +69,7 @@ export default function PivoineDocsIcon({
distance: 80 + Math.random() * 20, distance: 80 + Math.random() * 20,
size: 2 + Math.random() * 2, size: 2 + Math.random() * 2,
delay: i * 0.08, delay: i * 0.08,
})) }));
return ( return (
<div <div
@@ -76,7 +78,7 @@ export default function PivoineDocsIcon({
onMouseLeave={handleMouseLeave} onMouseLeave={handleMouseLeave}
onClick={handleClick} onClick={handleClick}
onTouchStart={handleTouch} onTouchStart={handleTouch}
style={{ width: size, height: size, rotate: '5deg' }} style={{ width: size, height: size, rotate: "5deg" }}
> >
<svg <svg
className="pivoine-docs-icon" className="pivoine-docs-icon"
@@ -87,49 +89,130 @@ export default function PivoineDocsIcon({
<defs> <defs>
{/* Enhanced Gradients for natural peony colors */} {/* Enhanced Gradients for natural peony colors */}
<radialGradient id="petal-gradient-1" cx="30%" cy="30%"> <radialGradient id="petal-gradient-1" cx="30%" cy="30%">
<stop offset="0%" style={{ stopColor: '#fdf4ff', stopOpacity: 1 }} /> <stop
<stop offset="40%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} /> offset="0%"
<stop offset="70%" style={{ stopColor: '#f0abfc', stopOpacity: 1 }} /> style={{ stopColor: "#fdf4ff", stopOpacity: 1 }}
<stop offset="100%" style={{ stopColor: '#d946ef', stopOpacity: 0.95 }} /> />
<stop
offset="40%"
style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
/>
<stop
offset="70%"
style={{ stopColor: "#f0abfc", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#d946ef", stopOpacity: 0.95 }}
/>
</radialGradient> </radialGradient>
<radialGradient id="petal-gradient-2" cx="30%" cy="30%"> <radialGradient id="petal-gradient-2" cx="30%" cy="30%">
<stop offset="0%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} /> <stop
<stop offset="40%" style={{ stopColor: '#f3e8ff', stopOpacity: 1 }} /> offset="0%"
<stop offset="70%" style={{ stopColor: '#e9d5ff', stopOpacity: 1 }} /> style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
<stop offset="100%" style={{ stopColor: '#c084fc', stopOpacity: 0.95 }} /> />
<stop
offset="40%"
style={{ stopColor: "#f3e8ff", stopOpacity: 1 }}
/>
<stop
offset="70%"
style={{ stopColor: "#e9d5ff", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#c084fc", stopOpacity: 0.95 }}
/>
</radialGradient> </radialGradient>
<radialGradient id="petal-gradient-3" cx="30%" cy="30%"> <radialGradient id="petal-gradient-3" cx="30%" cy="30%">
<stop offset="0%" style={{ stopColor: '#fdf4ff', stopOpacity: 1 }} /> <stop
<stop offset="40%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} /> offset="0%"
<stop offset="70%" style={{ stopColor: '#f0abfc', stopOpacity: 1 }} /> style={{ stopColor: "#fdf4ff", stopOpacity: 1 }}
<stop offset="100%" style={{ stopColor: '#d946ef', stopOpacity: 0.95 }} /> />
<stop
offset="40%"
style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
/>
<stop
offset="70%"
style={{ stopColor: "#f0abfc", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#d946ef", stopOpacity: 0.95 }}
/>
</radialGradient> </radialGradient>
<radialGradient id="petal-gradient-4" cx="30%" cy="30%"> <radialGradient id="petal-gradient-4" cx="30%" cy="30%">
<stop offset="0%" style={{ stopColor: '#fae8ff', stopOpacity: 1 }} /> <stop
<stop offset="40%" style={{ stopColor: '#f3e8ff', stopOpacity: 1 }} /> offset="0%"
<stop offset="70%" style={{ stopColor: '#e9d5ff', stopOpacity: 1 }} /> style={{ stopColor: "#fae8ff", stopOpacity: 1 }}
<stop offset="100%" style={{ stopColor: '#c084fc', stopOpacity: 0.95 }} /> />
<stop
offset="40%"
style={{ stopColor: "#f3e8ff", stopOpacity: 1 }}
/>
<stop
offset="70%"
style={{ stopColor: "#e9d5ff", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#c084fc", stopOpacity: 0.95 }}
/>
</radialGradient> </radialGradient>
<radialGradient id="center-gradient" cx="50%" cy="50%"> <radialGradient id="center-gradient" cx="50%" cy="50%">
<stop offset="0%" style={{ stopColor: '#fef3c7', stopOpacity: 1 }} /> <stop
<stop offset="30%" style={{ stopColor: '#fde68a', stopOpacity: 1 }} /> offset="0%"
<stop offset="60%" style={{ stopColor: '#fbbf24', stopOpacity: 1 }} /> style={{ stopColor: "#fef3c7", stopOpacity: 1 }}
<stop offset="100%" style={{ stopColor: '#f59e0b', stopOpacity: 1 }} /> />
<stop
offset="30%"
style={{ stopColor: "#fde68a", stopOpacity: 1 }}
/>
<stop
offset="60%"
style={{ stopColor: "#fbbf24", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#f59e0b", stopOpacity: 1 }}
/>
</radialGradient> </radialGradient>
<radialGradient id="center-inner-gradient" cx="50%" cy="50%"> <radialGradient id="center-inner-gradient" cx="50%" cy="50%">
<stop offset="0%" style={{ stopColor: '#fffbeb', stopOpacity: 1 }} /> <stop
<stop offset="50%" style={{ stopColor: '#fef3c7', stopOpacity: 1 }} /> offset="0%"
<stop offset="100%" style={{ stopColor: '#fde68a', stopOpacity: 1 }} /> style={{ stopColor: "#fffbeb", stopOpacity: 1 }}
/>
<stop
offset="50%"
style={{ stopColor: "#fef3c7", stopOpacity: 1 }}
/>
<stop
offset="100%"
style={{ stopColor: "#fde68a", stopOpacity: 1 }}
/>
</radialGradient> </radialGradient>
<linearGradient id="page-gradient" x1="0%" y1="0%" x2="100%" y2="100%"> <linearGradient
<stop offset="0%" style={{ stopColor: '#ffffff', stopOpacity: 0.98 }} /> id="page-gradient"
<stop offset="100%" style={{ stopColor: '#f3f4f6', stopOpacity: 0.98 }} /> x1="0%"
y1="0%"
x2="100%"
y2="100%"
>
<stop
offset="0%"
style={{ stopColor: "#ffffff", stopOpacity: 0.98 }}
/>
<stop
offset="100%"
style={{ stopColor: "#f3f4f6", stopOpacity: 0.98 }}
/>
</linearGradient> </linearGradient>
{/* Enhanced Filters */} {/* Enhanced Filters */}
@@ -175,7 +258,14 @@ export default function PivoineDocsIcon({
</defs> </defs>
{/* Subtle background glow */} {/* Subtle background glow */}
<circle className="bg-glow" cx="128" cy="128" r="120" fill="url(#petal-gradient-3)" opacity="0.08" /> <circle
className="bg-glow"
cx="128"
cy="128"
r="120"
fill="url(#petal-gradient-3)"
opacity="0.08"
/>
{/* Outer layer - Large petals (8 petals) */} {/* Outer layer - Large petals (8 petals) */}
<g className="outer-petals"> <g className="outer-petals">
@@ -198,7 +288,11 @@ export default function PivoineDocsIcon({
ry="68" ry="68"
fill={`url(#petal-gradient-${petal.gradient})`} fill={`url(#petal-gradient-${petal.gradient})`}
filter="url(#petal-glow)" filter="url(#petal-glow)"
style={{rotate: `${petal.angle}deg`, width: `${128 * petal.scaleX}px`, height: `${70 * petal.scaleY}px`}} style={{
rotate: `${petal.angle}deg`,
width: `${128 * petal.scaleX}px`,
height: `${70 * petal.scaleY}px`,
}}
/> />
))} ))}
</g> </g>
@@ -224,7 +318,11 @@ export default function PivoineDocsIcon({
ry="56" ry="56"
fill={`url(#petal-gradient-${petal.gradient})`} fill={`url(#petal-gradient-${petal.gradient})`}
filter="url(#petal-glow)" filter="url(#petal-glow)"
style={{rotate: `${petal.angle}deg`, width: `${128 * petal.scaleX}px`, height: `${70 * petal.scaleY}px`}} style={{
rotate: `${petal.angle}deg`,
width: `${128 * petal.scaleX}px`,
height: `${70 * petal.scaleY}px`,
}}
/> />
))} ))}
</g> </g>
@@ -250,7 +348,7 @@ export default function PivoineDocsIcon({
ry="44" ry="44"
fill={`url(#petal-gradient-${petal.gradient})`} fill={`url(#petal-gradient-${petal.gradient})`}
filter="url(#petal-glow)" filter="url(#petal-glow)"
style={{rotate: `${petal.angle}deg`}} style={{ rotate: `${petal.angle}deg` }}
/> />
))} ))}
</g> </g>
@@ -276,9 +374,9 @@ export default function PivoineDocsIcon({
{/* Center details - tiny stamens */} {/* Center details - tiny stamens */}
<g className="center-stamens"> <g className="center-stamens">
{Array.from({ length: 8 }).map((_, i) => { {Array.from({ length: 8 }).map((_, i) => {
const angle = (360 / 8) * i const angle = (360 / 8) * i;
const x = 128 + Math.cos((angle * Math.PI) / 180) * 10 const x = 128 + Math.cos((angle * Math.PI) / 180) * 10;
const y = 128 + Math.sin((angle * Math.PI) / 180) * 10 const y = 128 + Math.sin((angle * Math.PI) / 180) * 10;
return ( return (
<circle <circle
key={`stamen-${i}`} key={`stamen-${i}`}
@@ -289,20 +387,76 @@ export default function PivoineDocsIcon({
fill="#d97706" fill="#d97706"
opacity="0.8" opacity="0.8"
/> />
) );
})} })}
</g> </g>
{/* Sparkles - ambient magical effect */} {/* Sparkles - ambient magical effect */}
<g className="sparkles"> <g className="sparkles">
<circle className="sparkle sparkle-1" cx="180" cy="75" r="3" fill="#fbbf24" filter="url(#sparkle-glow)" /> <circle
<circle className="sparkle sparkle-2" cx="76" cy="76" r="2.5" fill="#a855f7" filter="url(#sparkle-glow)" /> className="sparkle sparkle-1"
<circle className="sparkle sparkle-3" cx="180" cy="180" r="2.5" fill="#ec4899" filter="url(#sparkle-glow)" /> cx="180"
<circle className="sparkle sparkle-4" cx="76" cy="180" r="3" fill="#c026d3" filter="url(#sparkle-glow)" /> cy="75"
<circle className="sparkle sparkle-5" cx="128" cy="50" r="2" fill="#f0abfc" filter="url(#sparkle-glow)" /> r="3"
<circle className="sparkle sparkle-6" cx="206" cy="128" r="2" fill="#fb7185" filter="url(#sparkle-glow)" /> fill="#fbbf24"
<circle className="sparkle sparkle-7" cx="128" cy="206" r="2.5" fill="#fbbf24" filter="url(#sparkle-glow)" /> filter="url(#sparkle-glow)"
<circle className="sparkle sparkle-8" cx="50" cy="128" r="2" fill="#c084fc" filter="url(#sparkle-glow)" /> />
<circle
className="sparkle sparkle-2"
cx="76"
cy="76"
r="2.5"
fill="#a855f7"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-3"
cx="180"
cy="180"
r="2.5"
fill="#ec4899"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-4"
cx="76"
cy="180"
r="3"
fill="#c026d3"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-5"
cx="128"
cy="50"
r="2"
fill="#f0abfc"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-6"
cx="206"
cy="128"
r="2"
fill="#fb7185"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-7"
cx="128"
cy="206"
r="2.5"
fill="#fbbf24"
filter="url(#sparkle-glow)"
/>
<circle
className="sparkle sparkle-8"
cx="50"
cy="128"
r="2"
fill="#c084fc"
filter="url(#sparkle-glow)"
/>
</g> </g>
{/* Flying bloom particles (visible on hover) */} {/* Flying bloom particles (visible on hover) */}
@@ -317,11 +471,13 @@ export default function PivoineDocsIcon({
fill={`url(#petal-gradient-${(particle.id % 4) + 1})`} fill={`url(#petal-gradient-${(particle.id % 4) + 1})`}
opacity="0" opacity="0"
filter="url(#sparkle-glow)" filter="url(#sparkle-glow)"
style={{ style={
'--particle-angle': `${particle.angle}deg`, {
'--particle-distance': `${particle.distance}px`, "--particle-angle": `${particle.angle}deg`,
'--particle-delay': `${particle.delay}s`, "--particle-distance": `${particle.distance}px`,
} as React.CSSProperties} "--particle-delay": `${particle.delay}s`,
} as React.CSSProperties
}
/> />
))} ))}
</g> </g>
@@ -334,5 +490,5 @@ export default function PivoineDocsIcon({
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,2 +1,2 @@
export { default as KomposeIcon } from './KomposeIcon' export { default as KomposeIcon } from "./KomposeIcon";
export { default as PivoineDocsIcon } from './PivoineDocsIcon' export { default as PivoineDocsIcon } from "./PivoineDocsIcon";

View File

@@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'export', output: "export",
reactStrictMode: true, reactStrictMode: true,
// Next.js 15 uses turbopack by default for dev // Next.js 15 uses turbopack by default for dev
@@ -8,39 +8,39 @@ const nextConfig = {
// Optimize production build // Optimize production build
compiler: { compiler: {
removeConsole: process.env.NODE_ENV === 'production', removeConsole: process.env.NODE_ENV === "production",
}, },
// Image optimization // Image optimization
images: { images: {
formats: ['image/avif', 'image/webp'], formats: ["image/avif", "image/webp"],
}, },
// Headers for security // Headers for security
async headers() { async headers() {
return [ return [
{ {
source: '/:path*', source: "/:path*",
headers: [ headers: [
{ {
key: 'X-DNS-Prefetch-Control', key: "X-DNS-Prefetch-Control",
value: 'on' value: "on",
}, },
{ {
key: 'X-Frame-Options', key: "X-Frame-Options",
value: 'SAMEORIGIN' value: "SAMEORIGIN",
}, },
{ {
key: 'X-Content-Type-Options', key: "X-Content-Type-Options",
value: 'nosniff' value: "nosniff",
}, },
{ {
key: 'Referrer-Policy', key: "Referrer-Policy",
value: 'origin-when-cross-origin' value: "origin-when-cross-origin",
} },
] ],
} },
] ];
}, },
// Enable experimental features if needed // Enable experimental features if needed
@@ -50,8 +50,8 @@ const nextConfig = {
}, },
turbopack: { turbopack: {
root: '.' root: ".",
} },
} };
export default nextConfig export default nextConfig;

View File

@@ -2,4 +2,4 @@ export default {
plugins: { plugins: {
"@tailwindcss/postcss": {}, "@tailwindcss/postcss": {},
}, },
} };

View File

@@ -1,8 +1,8 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: [ content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}', "./pages/**/*.{js,ts,jsx,tsx,mdx}",
'./components/**/*.{js,ts,jsx,tsx,mdx}', "./components/**/*.{js,ts,jsx,tsx,mdx}",
'./app/**/*.{js,ts,jsx,tsx,mdx}', "./app/**/*.{js,ts,jsx,tsx,mdx}",
], ],
} };

View File

@@ -4,60 +4,55 @@
// gem install scss-lint // gem install scss-lint
module.exports = function (grunt) { module.exports = function (grunt) {
'use strict'; "use strict";
// Project configuration // Project configuration
grunt.initConfig({ grunt.initConfig({
// Metadata // Metadata
pkg: grunt.file.readJSON('package.json'), pkg: grunt.file.readJSON("package.json"),
banner: '/*! <%= pkg.name %> - v<%= pkg.version %> - ' + banner:
"/*! <%= pkg.name %> - v<%= pkg.version %> - " +
'<%= grunt.template.today("yyyy-mm-dd") %>\n' + '<%= grunt.template.today("yyyy-mm-dd") %>\n' +
'<%= pkg.homepage ? "* " + pkg.homepage + "\\n" : "" %>' + '<%= pkg.homepage ? "* " + pkg.homepage + "\\n" : "" %>' +
'* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' + '* Copyright (c) <%= grunt.template.today("yyyy") %> <%= pkg.author.name %>;' +
' Licensed <%= props.license %> */\n', " Licensed <%= props.license %> */\n",
webfont: { webfont: {
icons: { icons: {
src: [ src: ["icons/sbed/*.svg", "icons/lorc/*.svg"],
'icons/sbed/*.svg', dest: "fonts",
'icons/lorc/*.svg'
],
dest: 'fonts',
options: { options: {
styles: 'font,icon,extra', styles: "font,icon,extra",
fontFilename: 'game-icons', fontFilename: "game-icons",
types: ['eot', 'woff2', 'woff', 'ttf', 'svg'], types: ["eot", "woff2", "woff", "ttf", "svg"],
syntax: 'bootstrap', syntax: "bootstrap",
destCss: 'css', destCss: "css",
destScss: 'scss', destScss: "scss",
templateOptions: { templateOptions: {
baseClass: 'gi', baseClass: "gi",
classPrefix: 'gi-' classPrefix: "gi-",
}, },
fontFamilyName: 'GameIcons', fontFamilyName: "GameIcons",
font: 'game-icons', font: "game-icons",
stylesheets: ['css', 'scss'], stylesheets: ["css", "scss"],
fontPathVariables: true, fontPathVariables: true,
htmlDemo: false, htmlDemo: false,
} },
} },
}, },
// CSS Min // CSS Min
// ======= // =======
cssmin: { cssmin: {
target: { target: {
files: { files: {
'css/game-icons.min.css': 'css/game-icons.css' "css/game-icons.min.css": "css/game-icons.css",
} },
} },
} },
}); });
// These plugins provide necessary tasks // These plugins provide necessary tasks
grunt.loadNpmTasks('grunt-webfont'); grunt.loadNpmTasks("grunt-webfont");
grunt.loadNpmTasks('grunt-contrib-cssmin'); grunt.loadNpmTasks("grunt-contrib-cssmin");
grunt.registerTask('default', [ grunt.registerTask("default", ["webfont", "cssmin"]);
'webfont',
'cssmin'
]);
}; };

View File

@@ -2,3 +2,4 @@
.DS_Store .DS_Store
*.log* *.log*

View File

@@ -1,58 +1,63 @@
export default defineAppConfig({ export default defineAppConfig({
ui: { ui: {
colors: { colors: {
primary: 'emerald', primary: "emerald",
secondary: 'fuchsia', secondary: "fuchsia",
neutral: 'zinc' neutral: "zinc",
}, },
footer: { footer: {
slots: { slots: {
root: 'border-t border-default', root: "border-t border-default",
left: 'text-sm text-muted' left: "text-sm text-muted",
} },
} },
}, },
seo: { seo: {
siteName: 'Kompose' siteName: "Kompose",
}, },
header: { header: {
title: '', title: "",
to: '/', to: "/",
logo: { logo: {
alt: '', alt: "",
light: '', light: "",
dark: '' dark: "",
}, },
search: true, search: true,
colorMode: true, colorMode: true,
links: [{ links: [
'icon': 'i-simple-icons-github', {
'to': 'https://github.com/nuxt-ui-templates/docs', icon: "i-simple-icons-github",
'target': '_blank', to: "https://github.com/nuxt-ui-templates/docs",
'aria-label': 'GitHub' target: "_blank",
}] "aria-label": "GitHub",
},
],
}, },
footer: { footer: {
credits: `kompose © Valknar ${new Date().getFullYear()}`, credits: `kompose © Valknar ${new Date().getFullYear()}`,
colorMode: false, colorMode: false,
links: [{ links: [
'icon': 'i-simple-icons-x', {
'to': 'https://x.com/bordeaux1981', icon: "i-simple-icons-x",
'target': '_blank', to: "https://x.com/bordeaux1981",
'aria-label': 'Nuxt on X' target: "_blank",
}, { "aria-label": "Nuxt on X",
'icon': 'i-simple-icons-github', },
'to': 'https://github.com/valknarogg', {
'target': '_blank', icon: "i-simple-icons-github",
'aria-label': 'Valknar on GitHub' to: "https://github.com/valknarogg",
}] target: "_blank",
"aria-label": "Valknar on GitHub",
},
],
}, },
toc: { toc: {
title: 'Table of Contents', title: "Table of Contents",
bottom: { bottom: {
title: 'Community', title: "Community",
edit: 'https://code.pivoine.art/valknar/kompose/src/branch/main/docs/content', edit: "https://code.pivoine.art/valknar/kompose/src/branch/main/docs/content",
links: [] links: [],
} },
} },
}) });

View File

@@ -1,27 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
const { seo } = useAppConfig() const { seo } = useAppConfig();
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs')) const { data: navigation } = await useAsyncData("navigation", () =>
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), { queryCollectionNavigation("docs"),
server: false );
}) const { data: files } = useLazyAsyncData(
"search",
() => queryCollectionSearchSections("docs"),
{
server: false,
},
);
useHead({ useHead({
meta: [ meta: [{ name: "viewport", content: "width=device-width, initial-scale=1" }],
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
htmlAttrs: { htmlAttrs: {
lang: 'en' lang: "en",
} },
}) });
useSeoMeta({ useSeoMeta({
titleTemplate: `%s - ${seo?.siteName}`, titleTemplate: `%s - ${seo?.siteName}`,
ogSiteName: seo?.siteName, ogSiteName: seo?.siteName,
twitterCard: 'summary_large_image' twitterCard: "summary_large_image",
}) });
provide('navigation', navigation) provide("navigation", navigation);
</script> </script>
<template> <template>

View File

@@ -5,19 +5,19 @@
@theme static { @theme static {
--container-8xl: 90rem; --container-8xl: 90rem;
--font-sans: 'Public Sans', sans-serif; --font-sans: "Public Sans", sans-serif;
--color-green-50: #EFFDF5; --color-green-50: #effdf5;
--color-green-100: #D9FBE8; --color-green-100: #d9fbe8;
--color-green-200: #B3F5D1; --color-green-200: #b3f5d1;
--color-green-300: #75EDAE; --color-green-300: #75edae;
--color-green-400: #00DC82; --color-green-400: #00dc82;
--color-green-500: #00C16A; --color-green-500: #00c16a;
--color-green-600: #00A155; --color-green-600: #00a155;
--color-green-700: #007F45; --color-green-700: #007f45;
--color-green-800: #016538; --color-green-800: #016538;
--color-green-900: #0A5331; --color-green-900: #0a5331;
--color-green-950: #052E16; --color-green-950: #052e16;
} }
:root { :root {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const { footer } = useAppConfig() const { footer } = useAppConfig();
</script> </script>
<template> <template>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content' import type { ContentNavigationItem } from "@nuxt/content";
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation') const navigation = inject<Ref<ContentNavigationItem[]>>("navigation");
const { header } = useAppConfig() const { header } = useAppConfig();
</script> </script>
<template> <template>

View File

@@ -75,50 +75,50 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from "vue";
interface Props { interface Props {
size?: string size?: string;
interactive?: boolean interactive?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
size: '192px', size: "192px",
interactive: true interactive: true,
}) });
const isClicked = ref(false) const isClicked = ref(false);
const showRipple = ref(false) const showRipple = ref(false);
const handleClick = () => { const handleClick = () => {
if (!props.interactive) return if (!props.interactive) return;
isClicked.value = true isClicked.value = true;
showRipple.value = true showRipple.value = true;
setTimeout(() => { setTimeout(() => {
isClicked.value = false isClicked.value = false;
}, 600) }, 600);
setTimeout(() => { setTimeout(() => {
showRipple.value = false showRipple.value = false;
}, 800) }, 800);
} };
const handleHover = () => { const handleHover = () => {
if (!props.interactive) return if (!props.interactive) return;
// Hover animations are handled by CSS // Hover animations are handled by CSS
} };
const handleLeave = () => { const handleLeave = () => {
if (!props.interactive) return if (!props.interactive) return;
// Leave animations are handled by CSS // Leave animations are handled by CSS
} };
const handleTouch = (e: TouchEvent) => { const handleTouch = (e: TouchEvent) => {
if (!props.interactive) return if (!props.interactive) return;
handleClick() handleClick();
} };
</script> </script>
<style scoped> <style scoped>

View File

@@ -6,7 +6,7 @@
--> -->
<script setup> <script setup>
import AppIcon from './AppIcon.vue' import AppIcon from "./AppIcon.vue";
</script> </script>
<template> <template>

View File

@@ -1,21 +1,22 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from "vue";
const props = defineProps({ const props = defineProps({
size: { size: {
type: String, type: String,
default: '42px' // Can be: '24px', '32px', '42px', '56px', etc. default: "42px", // Can be: '24px', '32px', '42px', '56px', etc.
} },
}) });
const isHovered = ref(false) const isHovered = ref(false);
// Load Google Font // Load Google Font
if (typeof document !== 'undefined') { if (typeof document !== "undefined") {
const link = document.createElement('link') const link = document.createElement("link");
link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@800;900&display=swap' link.href =
link.rel = 'stylesheet' "https://fonts.googleapis.com/css2?family=Inter:wght@800;900&display=swap";
document.head.appendChild(link) link.rel = "stylesheet";
document.head.appendChild(link);
} }
</script> </script>

View File

@@ -1,11 +1,14 @@
<script lang="ts" setup> <script lang="ts" setup>
const props = withDefaults(defineProps<{ title?: string, description?: string, headline?: string }>(), { const props = withDefaults(
title: 'title', defineProps<{ title?: string; description?: string; headline?: string }>(),
description: 'description' {
}) title: "title",
description: "description",
},
);
const title = computed(() => (props.title || '').slice(0, 60)) const title = computed(() => (props.title || "").slice(0, 60));
const description = computed(() => (props.description || '').slice(0, 200)) const description = computed(() => (props.description || "").slice(0, 200));
</script> </script>
<template> <template>

View File

@@ -1,51 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import { useClipboard } from '@vueuse/core' import { useClipboard } from "@vueuse/core";
const route = useRoute() const route = useRoute();
const toast = useToast() const toast = useToast();
const { copy, copied } = useClipboard() const { copy, copied } = useClipboard();
const site = useSiteConfig() const site = useSiteConfig();
const isCopying = ref(false) const isCopying = ref(false);
console.log(site) console.log(site);
const mdPath = computed(() => `${site.url}/raw${route.path}.md`) const mdPath = computed(() => `${site.url}/raw${route.path}.md`);
const items = [ const items = [
{ {
label: 'Copy Markdown link', label: "Copy Markdown link",
icon: 'i-lucide-link', icon: "i-lucide-link",
onSelect() { onSelect() {
copy(mdPath.value) copy(mdPath.value);
toast.add({ toast.add({
title: 'Copied to clipboard', title: "Copied to clipboard",
icon: 'i-lucide-check-circle' icon: "i-lucide-check-circle",
}) });
} },
}, },
{ {
label: 'View as Markdown', label: "View as Markdown",
icon: 'i-simple-icons:markdown', icon: "i-simple-icons:markdown",
target: '_blank', target: "_blank",
to: `/raw${route.path}.md` to: `/raw${route.path}.md`,
}, },
{ {
label: 'Open in ChatGPT', label: "Open in ChatGPT",
icon: 'i-simple-icons:openai', icon: "i-simple-icons:openai",
target: '_blank', target: "_blank",
to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}` to: `https://chatgpt.com/?hints=search&q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`,
}, },
{ {
label: 'Open in Claude', label: "Open in Claude",
icon: 'i-simple-icons:anthropic', icon: "i-simple-icons:anthropic",
target: '_blank', target: "_blank",
to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}` to: `https://claude.ai/new?q=${encodeURIComponent(`Read ${mdPath.value} so I can ask questions about it.`)}`,
} },
] ];
async function copyPage() { async function copyPage() {
isCopying.value = true isCopying.value = true;
copy(await $fetch<string>(`/raw${route.path}.md`)) copy(await $fetch<string>(`/raw${route.path}.md`));
isCopying.value = false isCopying.value = false;
} }
</script> </script>

View File

@@ -1,17 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
const { isLoading } = useLoadingIndicator() const { isLoading } = useLoadingIndicator();
const appear = ref(false) const appear = ref(false);
const appeared = ref(false) const appeared = ref(false);
onMounted(() => { onMounted(() => {
setTimeout(() => { setTimeout(() => {
appear.value = true appear.value = true;
setTimeout(() => { setTimeout(() => {
appeared.value = true appeared.value = true;
}, 1000) }, 1000);
}, 0) }, 0);
}) });
</script> </script>
<template> <template>

View File

@@ -1,58 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
interface Star { interface Star {
x: number x: number;
y: number y: number;
size: number size: number;
} }
const props = withDefaults(defineProps<{ const props = withDefaults(
starCount?: number defineProps<{
color?: string starCount?: number;
speed?: 'slow' | 'normal' | 'fast' color?: string;
size?: { min: number, max: number } speed?: "slow" | "normal" | "fast";
}>(), { size?: { min: number; max: number };
}>(),
{
starCount: 300, starCount: 300,
color: 'var(--ui-primary)', color: "var(--ui-primary)",
speed: 'normal', speed: "normal",
size: () => ({ size: () => ({
min: 1, min: 1,
max: 2 max: 2,
}) }),
}) },
);
// Generate random star positions and sizes // Generate random star positions and sizes
const generateStars = (count: number): Star[] => { const generateStars = (count: number): Star[] => {
return Array.from({ length: count }, () => ({ return Array.from({ length: count }, () => ({
x: Math.floor(Math.random() * 2000), x: Math.floor(Math.random() * 2000),
y: Math.floor(Math.random() * 2000), y: Math.floor(Math.random() * 2000),
size: typeof props.size === 'number' size:
typeof props.size === "number"
? props.size ? props.size
: Math.random() * (props.size.max - props.size.min) + props.size.min : Math.random() * (props.size.max - props.size.min) + props.size.min,
})) }));
} };
// Define speed configurations once // Define speed configurations once
const speedMap = { const speedMap = {
slow: { duration: 200, opacity: 0.5, ratio: 0.3 }, slow: { duration: 200, opacity: 0.5, ratio: 0.3 },
normal: { duration: 150, opacity: 0.75, ratio: 0.3 }, normal: { duration: 150, opacity: 0.75, ratio: 0.3 },
fast: { duration: 100, opacity: 1, ratio: 0.4 } fast: { duration: 100, opacity: 1, ratio: 0.4 },
} };
// Use a more efficient approach to generate and store stars // Use a more efficient approach to generate and store stars
const stars = useState<{ slow: Star[], normal: Star[], fast: Star[] }>('stars', () => { const stars = useState<{ slow: Star[]; normal: Star[]; fast: Star[] }>(
"stars",
() => {
return { return {
slow: generateStars(Math.floor(props.starCount * speedMap.slow.ratio)), slow: generateStars(Math.floor(props.starCount * speedMap.slow.ratio)),
normal: generateStars(Math.floor(props.starCount * speedMap.normal.ratio)), normal: generateStars(
fast: generateStars(Math.floor(props.starCount * speedMap.fast.ratio)) Math.floor(props.starCount * speedMap.normal.ratio),
} ),
}) fast: generateStars(Math.floor(props.starCount * speedMap.fast.ratio)),
};
},
);
// Compute star layers with different speeds and opacities // Compute star layers with different speeds and opacities
const starLayers = computed(() => [ const starLayers = computed(() => [
{ stars: stars.value.fast, ...speedMap.fast }, { stars: stars.value.fast, ...speedMap.fast },
{ stars: stars.value.normal, ...speedMap.normal }, { stars: stars.value.normal, ...speedMap.normal },
{ stars: stars.value.slow, ...speedMap.slow } { stars: stars.value.slow, ...speedMap.slow },
]) ]);
</script> </script>
<template> <template>

View File

@@ -1,27 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import type { NuxtError } from '#app' import type { NuxtError } from "#app";
defineProps<{ defineProps<{
error: NuxtError error: NuxtError;
}>() }>();
useHead({ useHead({
htmlAttrs: { htmlAttrs: {
lang: 'en' lang: "en",
} },
}) });
useSeoMeta({ useSeoMeta({
title: 'Page not found', title: "Page not found",
description: 'We are sorry but this page could not be found.' description: "We are sorry but this page could not be found.",
}) });
const { data: navigation } = await useAsyncData('navigation', () => queryCollectionNavigation('docs')) const { data: navigation } = await useAsyncData("navigation", () =>
const { data: files } = useLazyAsyncData('search', () => queryCollectionSearchSections('docs'), { queryCollectionNavigation("docs"),
server: false );
}) const { data: files } = useLazyAsyncData(
"search",
() => queryCollectionSearchSections("docs"),
{
server: false,
},
);
provide('navigation', navigation) provide("navigation", navigation);
</script> </script>
<template> <template>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content' import type { ContentNavigationItem } from "@nuxt/content";
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation') const navigation = inject<Ref<ContentNavigationItem[]>>("navigation");
</script> </script>
<template> <template>

View File

@@ -1,55 +1,63 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content' import type { ContentNavigationItem } from "@nuxt/content";
import { findPageHeadline } from '@nuxt/content/utils' import { findPageHeadline } from "@nuxt/content/utils";
definePageMeta({ definePageMeta({
layout: 'docs' layout: "docs",
}) });
const route = useRoute() const route = useRoute();
const { toc } = useAppConfig() const { toc } = useAppConfig();
const navigation = inject<Ref<ContentNavigationItem[]>>('navigation') const navigation = inject<Ref<ContentNavigationItem[]>>("navigation");
const { data: page } = await useAsyncData(route.path, () => queryCollection('docs').path(route.path).first()) const { data: page } = await useAsyncData(route.path, () =>
queryCollection("docs").path(route.path).first(),
);
if (!page.value) { if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true }) throw createError({
statusCode: 404,
statusMessage: "Page not found",
fatal: true,
});
} }
const { data: surround } = await useAsyncData(`${route.path}-surround`, () => { const { data: surround } = await useAsyncData(`${route.path}-surround`, () => {
return queryCollectionItemSurroundings('docs', route.path, { return queryCollectionItemSurroundings("docs", route.path, {
fields: ['description'] fields: ["description"],
}) });
}) });
const title = page.value.seo?.title || page.value.title const title = page.value.seo?.title || page.value.title;
const description = page.value.seo?.description || page.value.description const description = page.value.seo?.description || page.value.description;
useSeoMeta({ useSeoMeta({
title, title,
ogTitle: title, ogTitle: title,
description, description,
ogDescription: description ogDescription: description,
}) });
const headline = computed(() => findPageHeadline(navigation?.value, page.value?.path)) const headline = computed(() =>
findPageHeadline(navigation?.value, page.value?.path),
);
defineOgImageComponent('Docs', { defineOgImageComponent("Docs", {
headline: headline.value headline: headline.value,
}) });
const links = computed(() => { const links = computed(() => {
const links = [] const links = [];
if (toc?.bottom?.edit) { if (toc?.bottom?.edit) {
links.push({ links.push({
icon: 'i-lucide-external-link', icon: "i-lucide-external-link",
label: 'Edit this page', label: "Edit this page",
to: `${toc.bottom.edit}/${page?.value?.stem}.${page?.value?.extension}`, to: `${toc.bottom.edit}/${page?.value?.stem}.${page?.value?.extension}`,
target: '_blank' target: "_blank",
}) });
} }
return [...links, ...(toc?.bottom?.links || [])].filter(Boolean) return [...links, ...(toc?.bottom?.links || [])].filter(Boolean);
}) });
</script> </script>
<template> <template>

View File

@@ -1,24 +1,28 @@
import { defineContentConfig, defineCollection, z } from '@nuxt/content' import { defineContentConfig, defineCollection, z } from "@nuxt/content";
export default defineContentConfig({ export default defineContentConfig({
collections: { collections: {
landing: defineCollection({ landing: defineCollection({
type: 'page', type: "page",
source: 'index.md' source: "index.md",
}), }),
docs: defineCollection({ docs: defineCollection({
type: 'page', type: "page",
source: { source: {
include: '**', include: "**",
}, },
schema: z.object({ schema: z.object({
links: z.array(z.object({ links: z
.array(
z.object({
label: z.string(), label: z.string(),
icon: z.string(), icon: z.string(),
to: z.string(), to: z.string(),
target: z.string().optional() target: z.string().optional(),
})).optional() }),
}) )
}) .optional(),
} }),
}) }),
},
});

View File

@@ -1,6 +1,6 @@
// @ts-check // @ts-check
import withNuxt from './.nuxt/eslint.config.mjs' import withNuxt from "./.nuxt/eslint.config.mjs";
export default withNuxt( export default withNuxt(
// Your custom configs here // Your custom configs here
) );

View File

@@ -1,15 +1,15 @@
// https://nuxt.com/docs/api/configuration/nuxt-config // https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({ export default defineNuxtConfig({
app: { app: {
baseURL: '/kompose/', baseURL: "/kompose/",
}, },
modules: [ modules: [
'@nuxt/eslint', "@nuxt/eslint",
'@nuxt/image', "@nuxt/image",
'@nuxt/ui', "@nuxt/ui",
'@nuxt/content', "@nuxt/content",
'nuxt-og-image', "nuxt-og-image",
'nuxt-llms' "nuxt-llms",
], ],
// content: { // content: {
@@ -35,69 +35,68 @@ export default defineNuxtConfig({
// }, // },
devtools: { devtools: {
enabled: false enabled: false,
}, },
css: ['~/assets/css/main.css'], css: ["~/assets/css/main.css"],
content: { content: {
build: { build: {
markdown: { markdown: {
toc: { toc: {
searchDepth: 1 searchDepth: 1,
} },
} },
} },
}, },
compatibilityDate: '2024-07-11', compatibilityDate: "2024-07-11",
nitro: { nitro: {
prerender: { prerender: {
routes: [ routes: ["/"],
'/'
],
crawlLinks: true, crawlLinks: true,
autoSubfolderIndex: false autoSubfolderIndex: false,
} },
}, },
eslint: { eslint: {
config: { config: {
stylistic: { stylistic: {
commaDangle: 'never', commaDangle: "never",
braceStyle: '1tbs' braceStyle: "1tbs",
} },
} },
}, },
icon: { icon: {
provider: 'iconify' provider: "iconify",
}, },
llms: { llms: {
domain: 'https://docs-template.nuxt.dev/', domain: "https://docs-template.nuxt.dev/",
title: 'Nuxt Docs Template', title: "Nuxt Docs Template",
description: 'A template for building documentation with Nuxt UI and Nuxt Content.', description:
"A template for building documentation with Nuxt UI and Nuxt Content.",
full: { full: {
title: 'Nuxt Docs Template - Full Documentation', title: "Nuxt Docs Template - Full Documentation",
description: 'This is the full documentation for the Nuxt Docs Template.' description: "This is the full documentation for the Nuxt Docs Template.",
}, },
sections: [ sections: [
{ {
title: 'Getting Started', title: "Getting Started",
contentCollection: 'docs', contentCollection: "docs",
contentFilters: [ contentFilters: [
{ field: 'path', operator: 'LIKE', value: '/getting-started%' } { field: "path", operator: "LIKE", value: "/getting-started%" },
] ],
}, },
{ {
title: 'Essentials', title: "Essentials",
contentCollection: 'docs', contentCollection: "docs",
contentFilters: [ contentFilters: [
{ field: 'path', operator: 'LIKE', value: '/essentials%' } { field: "path", operator: "LIKE", value: "/essentials%" },
] ],
} },
] ],
} },
}) });

View File

@@ -1,13 +1,13 @@
{ {
"extends": [ "extends": ["github>nuxt/renovate-config-nuxt"],
"github>nuxt/renovate-config-nuxt"
],
"lockFileMaintenance": { "lockFileMaintenance": {
"enabled": true "enabled": true
}, },
"packageRules": [{ "packageRules": [
{
"matchDepTypes": ["resolutions"], "matchDepTypes": ["resolutions"],
"enabled": false "enabled": false
}], }
],
"postUpdateOptions": ["pnpmDedupe"] "postUpdateOptions": ["pnpmDedupe"]
} }

View File

@@ -1,27 +1,40 @@
import { withLeadingSlash } from 'ufo' import { withLeadingSlash } from "ufo";
import { stringify } from 'minimark/stringify' import { stringify } from "minimark/stringify";
import { queryCollection } from '@nuxt/content/nitro' import { queryCollection } from "@nuxt/content/nitro";
import type { Collections } from '@nuxt/content' import type { Collections } from "@nuxt/content";
export default eventHandler(async (event) => { export default eventHandler(async (event) => {
const slug = getRouterParams(event)['slug.md'] const slug = getRouterParams(event)["slug.md"];
if (!slug?.endsWith('.md')) { if (!slug?.endsWith(".md")) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true }) throw createError({
statusCode: 404,
statusMessage: "Page not found",
fatal: true,
});
} }
const path = withLeadingSlash(slug.replace('.md', '')) const path = withLeadingSlash(slug.replace(".md", ""));
const page = await queryCollection(event, 'docs' as keyof Collections).path(path).first() const page = await queryCollection(event, "docs" as keyof Collections)
.path(path)
.first();
if (!page) { if (!page) {
throw createError({ statusCode: 404, statusMessage: 'Page not found', fatal: true }) throw createError({
statusCode: 404,
statusMessage: "Page not found",
fatal: true,
});
} }
// Add title and description to the top of the page if missing // Add title and description to the top of the page if missing
if (page.body.value[0]?.[0] !== 'h1') { if (page.body.value[0]?.[0] !== "h1") {
page.body.value.unshift(['blockquote', {}, page.description]) page.body.value.unshift(["blockquote", {}, page.description]);
page.body.value.unshift(['h1', {}, page.title]) page.body.value.unshift(["h1", {}, page.title]);
} }
setHeader(event, 'Content-Type', 'text/markdown; charset=utf-8') setHeader(event, "Content-Type", "text/markdown; charset=utf-8");
return stringify({ ...page.body, type: 'minimark' }, { format: 'markdown/html' }) return stringify(
}) { ...page.body, type: "minimark" },
{ format: "markdown/html" },
);
});

View File

@@ -1,6 +1,6 @@
import globals from "globals" import globals from "globals";
import pluginJs from "@eslint/js" import pluginJs from "@eslint/js";
import * as tseslint from "typescript-eslint" import * as tseslint from "typescript-eslint";
export default tseslint.config({ export default tseslint.config({
files: ["src/**/*.{js,mjs,cjs,ts}"], files: ["src/**/*.{js,mjs,cjs,ts}"],
@@ -22,4 +22,4 @@ export default tseslint.config({
"@typescript-eslint/no-unused-vars": ["warn", { caughtErrors: "none" }], "@typescript-eslint/no-unused-vars": ["warn", { caughtErrors: "none" }],
"no-unused-vars": "off", "no-unused-vars": "off",
}, },
}) });

View File

@@ -1,7 +1,7 @@
import { hashPassword } from "../src/utils/auth" import { hashPassword } from "../src/utils/auth";
import { prisma } from "../src/utils/prisma" import { prisma } from "../src/utils/prisma";
import { SmtpEncryption } from "./client" import { SmtpEncryption } from "./client";
import dayjs from "dayjs" import dayjs from "dayjs";
async function seed() { async function seed() {
if (!(await prisma.organization.findFirst())) { if (!(await prisma.organization.findFirst())) {
@@ -27,7 +27,7 @@ async function seed() {
}, },
}, },
}, },
}) });
} }
const orgId = ( const orgId = (
@@ -36,10 +36,10 @@ async function seed() {
createdAt: "asc", createdAt: "asc",
}, },
}) })
)?.id )?.id;
if (!orgId) { if (!orgId) {
throw new Error("not reachable") throw new Error("not reachable");
} }
if (!(await prisma.user.findFirst())) { if (!(await prisma.user.findFirst())) {
@@ -54,7 +54,7 @@ async function seed() {
}, },
}, },
}, },
}) });
} }
// Create 5000 subscribers // Create 5000 subscribers
@@ -63,17 +63,17 @@ async function seed() {
email: `subscriber${i + 1}@example.com`, email: `subscriber${i + 1}@example.com`,
organizationId: orgId, organizationId: orgId,
createdAt: dayjs().subtract(12, "days").toDate(), createdAt: dayjs().subtract(12, "days").toDate(),
})) }));
await prisma.subscriber.createMany({ await prisma.subscriber.createMany({
data: subscribers, data: subscribers,
skipDuplicates: true, skipDuplicates: true,
}) });
// Then 10 more for each day for 10 days // Then 10 more for each day for 10 days
const now = new Date() const now = new Date();
for (let d = 0; d < 10; d++) { for (let d = 0; d < 10; d++) {
const day = dayjs(now) const day = dayjs(now)
.subtract(d + 1, "day") .subtract(d + 1, "day")
.toDate() .toDate();
const dailySubs = Array.from({ length: 10 }, (_, i) => ({ const dailySubs = Array.from({ length: 10 }, (_, i) => ({
name: `DailySub ${d + 1}-${i + 1}`, name: `DailySub ${d + 1}-${i + 1}`,
@@ -81,19 +81,19 @@ async function seed() {
organizationId: orgId, organizationId: orgId,
createdAt: day, createdAt: day,
updatedAt: day, updatedAt: day,
})) }));
await prisma.subscriber.createMany({ await prisma.subscriber.createMany({
data: dailySubs, data: dailySubs,
skipDuplicates: true, skipDuplicates: true,
}) });
} }
} }
seed() seed()
.then(async () => { .then(async () => {
await prisma.$disconnect() await prisma.$disconnect();
}) })
.catch(async (e) => { .catch(async (e) => {
console.error(e) console.error(e);
await prisma.$disconnect() await prisma.$disconnect();
}) });

View File

@@ -1,26 +1,26 @@
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import express, { NextFunction } from "express" import express, { NextFunction } from "express";
export const authenticateApiKey = async ( export const authenticateApiKey = async (
req: express.Request, req: express.Request,
res: express.Response, res: express.Response,
next: NextFunction next: NextFunction,
) => { ) => {
const apiKey = req.header("x-api-key") const apiKey = req.header("x-api-key");
if (!apiKey) { if (!apiKey) {
res.status(401).json({ error: "Missing API Key" }) res.status(401).json({ error: "Missing API Key" });
return return;
} }
try { try {
const keyRecord = await prisma.apiKey.findUnique({ const keyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey }, where: { key: apiKey },
select: { id: true, Organization: true }, select: { id: true, Organization: true },
}) });
if (!keyRecord) { if (!keyRecord) {
res.status(401).json({ error: "Invalid API Key" }) res.status(401).json({ error: "Invalid API Key" });
return return;
} }
// Update lastUsed timestamp asynchronously, don't await // Update lastUsed timestamp asynchronously, don't await
@@ -33,14 +33,14 @@ export const authenticateApiKey = async (
// Log the error but don't block the request // Log the error but don't block the request
console.error( console.error(
"Failed to update API key lastUsed timestamp", "Failed to update API key lastUsed timestamp",
updateError updateError,
) );
}) });
req.organization = keyRecord.Organization req.organization = keyRecord.Organization;
next() next();
} catch (error) { } catch (error) {
console.error("Error validating API key", error) console.error("Error validating API key", error);
res.status(500).json({ error: "Server error" }) res.status(500).json({ error: "Server error" });
} }
} };

View File

@@ -1,17 +1,17 @@
import express from "express" import express from "express";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { authenticateApiKey } from "./middleware" import { authenticateApiKey } from "./middleware";
import { z } from "zod" import { z } from "zod";
import { Prisma } from "../../prisma/client" import { Prisma } from "../../prisma/client";
import crypto from "crypto" import crypto from "crypto";
import { Mailer } from "../lib/Mailer" import { Mailer } from "../lib/Mailer";
import fs from "fs/promises" import fs from "fs/promises";
import path from "path" import path from "path";
import dayjs from "dayjs" import dayjs from "dayjs";
export const apiRouter = express.Router() export const apiRouter = express.Router();
apiRouter.use(authenticateApiKey) apiRouter.use(authenticateApiKey);
/** /**
* @swagger * @swagger
@@ -123,13 +123,13 @@ apiRouter.post("/subscribers", async (req, res) => {
emailVerified: z.boolean().optional(), emailVerified: z.boolean().optional(),
metadata: z.record(z.string(), z.string()).optional(), metadata: z.record(z.string(), z.string()).optional(),
}) })
.safeParse(req.body) .safeParse(req.body);
if (validationError) { if (validationError) {
res.status(400).json({ res.status(400).json({
error: validationError.issues[0]?.message || "Invalid input data", error: validationError.issues[0]?.message || "Invalid input data",
}) });
return return;
} }
const { const {
@@ -139,7 +139,7 @@ apiRouter.post("/subscribers", async (req, res) => {
doubleOptIn, doubleOptIn,
emailVerified, emailVerified,
metadata: newMetadata, metadata: newMetadata,
} = body } = body;
const existingLists = await prisma.list.findMany({ const existingLists = await prisma.list.findMany({
where: { where: {
@@ -148,13 +148,15 @@ apiRouter.post("/subscribers", async (req, res) => {
}, },
organizationId: req.organization.id, organizationId: req.organization.id,
}, },
}) });
if (existingLists.length !== lists.length) { if (existingLists.length !== lists.length) {
const foundListIds = existingLists.map((list) => list.id) const foundListIds = existingLists.map((list) => list.id);
const missingListId = lists.find((id) => !foundListIds.includes(id)) const missingListId = lists.find((id) => !foundListIds.includes(id));
res.status(400).json({ error: `List with id ${missingListId} not found` }) res
return .status(400)
.json({ error: `List with id ${missingListId} not found` });
return;
} }
const existingSubscriber = await prisma.subscriber.findFirst({ const existingSubscriber = await prisma.subscriber.findFirst({
@@ -170,92 +172,92 @@ apiRouter.post("/subscribers", async (req, res) => {
}, },
Metadata: true, Metadata: true,
}, },
}) });
const existingListIds = const existingListIds =
existingSubscriber?.ListSubscribers.map((list) => list.List.id) || [] existingSubscriber?.ListSubscribers.map((list) => list.List.id) || [];
const allLists = existingListIds.concat(lists) const allLists = existingListIds.concat(lists);
const uniqueLists = [...new Set(allLists)] const uniqueLists = [...new Set(allLists)];
const isExpired = existingSubscriber?.emailVerificationTokenExpiresAt const isExpired = existingSubscriber?.emailVerificationTokenExpiresAt
? dayjs(existingSubscriber.emailVerificationTokenExpiresAt).isBefore( ? dayjs(existingSubscriber.emailVerificationTokenExpiresAt).isBefore(
dayjs() dayjs(),
) )
: true : true;
const shouldSendVerificationEmail = const shouldSendVerificationEmail =
doubleOptIn && !existingSubscriber?.emailVerified && isExpired doubleOptIn && !existingSubscriber?.emailVerified && isExpired;
if (shouldSendVerificationEmail) { if (shouldSendVerificationEmail) {
const emailVerificationToken = crypto.randomBytes(32).toString("hex") const emailVerificationToken = crypto.randomBytes(32).toString("hex");
const emailVerificationTokenExpiresAt = dayjs().add(24, "hours").toDate() const emailVerificationTokenExpiresAt = dayjs().add(24, "hours").toDate();
const emailVerified = false const emailVerified = false;
try { try {
const smtpSettings = await prisma.smtpSettings.findFirst({ const smtpSettings = await prisma.smtpSettings.findFirst({
where: { organizationId: req.organization.id }, where: { organizationId: req.organization.id },
}) });
if (!smtpSettings) { if (!smtpSettings) {
console.error( console.error(
`SMTP settings not found for organization ${req.organization.id}.` `SMTP settings not found for organization ${req.organization.id}.`,
) );
res.status(422).json({ res.status(422).json({
error: error:
"SMTP settings not configured for this organization. Cannot send verification email.", "SMTP settings not configured for this organization. Cannot send verification email.",
}) });
return return;
} }
const generalSettings = await prisma.generalSettings.findFirst({ const generalSettings = await prisma.generalSettings.findFirst({
where: { organizationId: req.organization.id }, where: { organizationId: req.organization.id },
}) });
if (!generalSettings || !generalSettings.baseURL) { if (!generalSettings || !generalSettings.baseURL) {
console.error( console.error(
`General settings (especially baseURL) not found for organization ${req.organization.id}.` `General settings (especially baseURL) not found for organization ${req.organization.id}.`,
) );
res.status(422).json({ res.status(422).json({
error: error:
"Base URL not configured in general settings for this organization. Cannot send verification email.", "Base URL not configured in general settings for this organization. Cannot send verification email.",
}) });
return return;
} }
const fromEmailAddress = const fromEmailAddress =
smtpSettings.fromEmail || generalSettings.defaultFromEmail smtpSettings.fromEmail || generalSettings.defaultFromEmail;
if (!fromEmailAddress) { if (!fromEmailAddress) {
console.error( console.error(
`Sender email (fromEmail/defaultFromEmail) not configured for organization ${req.organization.id}.` `Sender email (fromEmail/defaultFromEmail) not configured for organization ${req.organization.id}.`,
) );
res.status(422).json({ res.status(422).json({
error: error:
"Sender email not configured for this organization. Cannot send verification email.", "Sender email not configured for this organization. Cannot send verification email.",
}) });
return return;
} }
const mailer = new Mailer(smtpSettings) const mailer = new Mailer(smtpSettings);
const verificationLink = `${generalSettings.baseURL.replace(/\/$/, "")}/verify-email?token=${emailVerificationToken}` const verificationLink = `${generalSettings.baseURL.replace(/\/$/, "")}/verify-email?token=${emailVerificationToken}`;
const templatePath = path.join( const templatePath = path.join(
__dirname, __dirname,
"../../templates/verificationEmail.html" "../../templates/verificationEmail.html",
) );
let emailHtmlContent = await fs.readFile(templatePath, "utf-8") let emailHtmlContent = await fs.readFile(templatePath, "utf-8");
emailHtmlContent = emailHtmlContent emailHtmlContent = emailHtmlContent
.replace(/{{name}}/g, name || "there") .replace(/{{name}}/g, name || "there")
.replace(/{{verificationLink}}/g, verificationLink) .replace(/{{verificationLink}}/g, verificationLink)
.replace(/{{currentYear}}/g, new Date().getFullYear().toString()) .replace(/{{currentYear}}/g, new Date().getFullYear().toString());
await mailer.sendEmail({ await mailer.sendEmail({
to: email, to: email,
from: fromEmailAddress, from: fromEmailAddress,
subject: "Verify Your Email Address", subject: "Verify Your Email Address",
html: emailHtmlContent, html: emailHtmlContent,
}) });
const subscriber = await prisma.subscriber.upsert({ const subscriber = await prisma.subscriber.upsert({
where: { id: existingSubscriber?.id || "create" }, where: { id: existingSubscriber?.id || "create" },
@@ -310,7 +312,7 @@ apiRouter.post("/subscribers", async (req, res) => {
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
}) });
const data = { const data = {
id: subscriber.id, id: subscriber.id,
@@ -323,29 +325,29 @@ apiRouter.post("/subscribers", async (req, res) => {
})), })),
metadata: subscriber.Metadata.reduce( metadata: subscriber.Metadata.reduce(
(acc, meta) => { (acc, meta) => {
acc[meta.key] = meta.value acc[meta.key] = meta.value;
return acc return acc;
}, },
{} as Record<string, string> {} as Record<string, string>,
), ),
emailVerified: subscriber.emailVerified, emailVerified: subscriber.emailVerified,
createdAt: subscriber.createdAt, createdAt: subscriber.createdAt,
updatedAt: subscriber.updatedAt, updatedAt: subscriber.updatedAt,
} };
console.log("data", data) console.log("data", data);
res.status(201).json(data) res.status(201).json(data);
return return;
} catch (emailError: any) { } catch (emailError: any) {
console.error( console.error(
`Error sending verification email to ${email}:`, `Error sending verification email to ${email}:`,
emailError emailError,
) );
res.status(422).json({ res.status(422).json({
error: `Failed to send verification email: ${emailError.message || "Unknown reason"}`, error: `Failed to send verification email: ${emailError.message || "Unknown reason"}`,
}) });
return return;
} }
} }
@@ -404,7 +406,7 @@ apiRouter.post("/subscribers", async (req, res) => {
}, },
Metadata: true, Metadata: true,
}, },
}) });
const data = { const data = {
id: subscriber.id, id: subscriber.id,
@@ -417,22 +419,22 @@ apiRouter.post("/subscribers", async (req, res) => {
})), })),
metadata: subscriber.Metadata.reduce( metadata: subscriber.Metadata.reduce(
(acc, meta) => { (acc, meta) => {
acc[meta.key] = meta.value acc[meta.key] = meta.value;
return acc return acc;
}, },
{} as Record<string, string> {} as Record<string, string>,
), ),
emailVerified: subscriber.emailVerified, emailVerified: subscriber.emailVerified,
createdAt: subscriber.createdAt, createdAt: subscriber.createdAt,
updatedAt: subscriber.updatedAt, updatedAt: subscriber.updatedAt,
} };
res.status(201).json(data) res.status(201).json(data);
} catch (error) { } catch (error) {
console.error("Error creating subscriber", error) console.error("Error creating subscriber", error);
res.status(500).json({ error: "Server error" }) res.status(500).json({ error: "Server error" });
} }
}) });
/** /**
* @swagger * @swagger
@@ -498,22 +500,22 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
metadata: z.record(z.string(), z.string()).optional(), metadata: z.record(z.string(), z.string()).optional(),
emailVerified: z.boolean().optional(), emailVerified: z.boolean().optional(),
}) })
.safeParse(req.body) .safeParse(req.body);
if (error) { if (error) {
res res
.status(400) .status(400)
.json({ error: error.issues[0]?.message || "Invalid input data" }) .json({ error: error.issues[0]?.message || "Invalid input data" });
return return;
} }
const { email, name, lists, metadata: newMetadata, emailVerified } = body const { email, name, lists, metadata: newMetadata, emailVerified } = body;
const id = req.params.id const id = req.params.id;
if (typeof id !== "string") { if (typeof id !== "string") {
res.status(400).json({ error: "Invalid id" }) res.status(400).json({ error: "Invalid id" });
return return;
} }
const subscriber = await prisma.subscriber.findFirst({ const subscriber = await prisma.subscriber.findFirst({
@@ -529,10 +531,10 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
}, },
Metadata: true, Metadata: true,
}, },
}) });
if (!subscriber) { if (!subscriber) {
res.status(404).json({ error: "Subscriber not found" }) res.status(404).json({ error: "Subscriber not found" });
return return;
} }
if (lists?.length) { if (lists?.length) {
@@ -543,15 +545,15 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
}, },
organizationId: req.organization.id, organizationId: req.organization.id,
}, },
}) });
if (existingLists.length !== lists.length) { if (existingLists.length !== lists.length) {
const foundListIds = existingLists.map((list) => list.id) const foundListIds = existingLists.map((list) => list.id);
const missingListId = lists.find((id) => !foundListIds.includes(id)) const missingListId = lists.find((id) => !foundListIds.includes(id));
res res
.status(400) .status(400)
.json({ error: `List with id ${missingListId} not found` }) .json({ error: `List with id ${missingListId} not found` });
return return;
} }
} }
@@ -591,7 +593,7 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
}, },
Metadata: true, Metadata: true,
}, },
}) });
const data = { const data = {
id: updatedSubscriber.id, id: updatedSubscriber.id,
@@ -604,21 +606,21 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
})), })),
metadata: updatedSubscriber.Metadata.reduce( metadata: updatedSubscriber.Metadata.reduce(
(acc, meta) => { (acc, meta) => {
acc[meta.key] = meta.value acc[meta.key] = meta.value;
return acc return acc;
}, },
{} as Record<string, string> {} as Record<string, string>,
), ),
createdAt: updatedSubscriber.createdAt, createdAt: updatedSubscriber.createdAt,
updatedAt: updatedSubscriber.updatedAt, updatedAt: updatedSubscriber.updatedAt,
} };
res.json(data) res.json(data);
} catch (error) { } catch (error) {
console.error("Error updating subscriber", error) console.error("Error updating subscriber", error);
res.status(500).json({ error: "Server error" }) res.status(500).json({ error: "Server error" });
} }
}) });
/** /**
* @swagger * @swagger
@@ -655,11 +657,11 @@ apiRouter.put("/subscribers/:id", async (req, res) => {
*/ */
apiRouter.delete("/subscribers/:id", async (req, res) => { apiRouter.delete("/subscribers/:id", async (req, res) => {
try { try {
const { id } = req.params const { id } = req.params;
if (typeof id !== "string") { if (typeof id !== "string") {
res.status(400).json({ error: "Invalid id" }) res.status(400).json({ error: "Invalid id" });
return return;
} }
const subscriber = await prisma.subscriber.findFirst({ const subscriber = await prisma.subscriber.findFirst({
@@ -667,25 +669,25 @@ apiRouter.delete("/subscribers/:id", async (req, res) => {
id, id,
organizationId: req.organization.id, organizationId: req.organization.id,
}, },
}) });
if (!subscriber) { if (!subscriber) {
res.status(404).json({ error: "Subscriber not found" }) res.status(404).json({ error: "Subscriber not found" });
return return;
} }
await prisma.subscriber.delete({ await prisma.subscriber.delete({
where: { where: {
id, id,
}, },
}) });
res.json({ success: true }) res.json({ success: true });
} catch (error) { } catch (error) {
console.error("Error deleting subscriber", error) console.error("Error deleting subscriber", error);
res.status(500).json({ error: "Server error" }) res.status(500).json({ error: "Server error" });
} }
}) });
/** /**
* @swagger * @swagger
@@ -719,11 +721,11 @@ apiRouter.delete("/subscribers/:id", async (req, res) => {
*/ */
apiRouter.get("/subscribers/:id", async (req, res) => { apiRouter.get("/subscribers/:id", async (req, res) => {
try { try {
const { id } = req.params const { id } = req.params;
if (typeof id !== "string") { if (typeof id !== "string") {
res.status(400).json({ error: "Invalid id" }) res.status(400).json({ error: "Invalid id" });
return return;
} }
const subscriber = await prisma.subscriber.findFirst({ const subscriber = await prisma.subscriber.findFirst({
@@ -740,10 +742,10 @@ apiRouter.get("/subscribers/:id", async (req, res) => {
Metadata: true, Metadata: true,
}, },
orderBy: [{ createdAt: "desc" }, { id: "desc" }], orderBy: [{ createdAt: "desc" }, { id: "desc" }],
}) });
if (!subscriber) { if (!subscriber) {
res.status(404).json({ error: "Subscriber not found" }) res.status(404).json({ error: "Subscriber not found" });
return return;
} }
const subscriberData = { const subscriberData = {
@@ -757,21 +759,21 @@ apiRouter.get("/subscribers/:id", async (req, res) => {
})), })),
metadata: subscriber.Metadata.reduce( metadata: subscriber.Metadata.reduce(
(acc, meta) => { (acc, meta) => {
acc[meta.key] = meta.value acc[meta.key] = meta.value;
return acc return acc;
}, },
{} as Record<string, string> {} as Record<string, string>,
), ),
createdAt: subscriber.createdAt, createdAt: subscriber.createdAt,
updatedAt: subscriber.updatedAt, updatedAt: subscriber.updatedAt,
} };
res.json(subscriberData) res.json(subscriberData);
} catch (error) { } catch (error) {
console.error("Error fetching subscriber", error) console.error("Error fetching subscriber", error);
res.status(500).json({ error: "Server error" }) res.status(500).json({ error: "Server error" });
} }
}) });
/** /**
* @swagger * @swagger
@@ -852,30 +854,30 @@ apiRouter.get("/subscribers", async (req, res) => {
emailEquals: z.string().optional(), emailEquals: z.string().optional(),
nameEquals: z.string().optional(), nameEquals: z.string().optional(),
}) })
.safeParse(req.query) .safeParse(req.query);
if (error) { if (error) {
res res
.status(400) .status(400)
.json({ error: error.issues[0]?.message || "Invalid input data" }) .json({ error: error.issues[0]?.message || "Invalid input data" });
return return;
} }
const { page, perPage, emailEquals, nameEquals } = query const { page, perPage, emailEquals, nameEquals } = query;
const where: Prisma.SubscriberWhereInput = { const where: Prisma.SubscriberWhereInput = {
organizationId: req.organization.id, organizationId: req.organization.id,
} };
if (emailEquals) { if (emailEquals) {
where.email = { equals: emailEquals } where.email = { equals: emailEquals };
} }
if (nameEquals) { if (nameEquals) {
where.name = { equals: nameEquals } where.name = { equals: nameEquals };
} }
const total = await prisma.subscriber.count({ where }) const total = await prisma.subscriber.count({ where });
const subscribers = await prisma.subscriber.findMany({ const subscribers = await prisma.subscriber.findMany({
where, where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }], orderBy: [{ createdAt: "desc" }, { id: "desc" }],
@@ -889,9 +891,9 @@ apiRouter.get("/subscribers", async (req, res) => {
}, },
Metadata: true, Metadata: true,
}, },
}) });
const totalPages = Math.ceil(total / perPage) const totalPages = Math.ceil(total / perPage);
const subscribersFormatted = subscribers.map((subscriber) => ({ const subscribersFormatted = subscribers.map((subscriber) => ({
id: subscriber.id, id: subscriber.id,
@@ -904,14 +906,14 @@ apiRouter.get("/subscribers", async (req, res) => {
})), })),
metadata: subscriber.Metadata.reduce( metadata: subscriber.Metadata.reduce(
(acc, meta) => { (acc, meta) => {
acc[meta.key] = meta.value acc[meta.key] = meta.value;
return acc return acc;
}, },
{} as Record<string, string> {} as Record<string, string>,
), ),
createdAt: subscriber.createdAt, createdAt: subscriber.createdAt,
updatedAt: subscriber.updatedAt, updatedAt: subscriber.updatedAt,
})) }));
res.json({ res.json({
data: subscribersFormatted, data: subscribersFormatted,
@@ -922,9 +924,9 @@ apiRouter.get("/subscribers", async (req, res) => {
totalPages, totalPages,
hasMore: page < totalPages, hasMore: page < totalPages,
}, },
}) });
} catch (error) { } catch (error) {
console.error("Error fetching subscribers", error) console.error("Error fetching subscribers", error);
res.status(500).json({ error: "Server error" }) res.status(500).json({ error: "Server error" });
} }
}) });

View File

@@ -1,24 +1,24 @@
import * as trpcExpress from "@trpc/server/adapters/express" import * as trpcExpress from "@trpc/server/adapters/express";
import path from "path" import path from "path";
import express from "express" import express from "express";
import cors from "cors" import cors from "cors";
import { prisma } from "./utils/prisma" import { prisma } from "./utils/prisma";
import swaggerUi from "swagger-ui-express" import swaggerUi from "swagger-ui-express";
import { createContext, router } from "./trpc" import { createContext, router } from "./trpc";
import { userRouter } from "./user/router" import { userRouter } from "./user/router";
import { listRouter } from "./list/router" import { listRouter } from "./list/router";
import { organizationRouter } from "./organization/router" import { organizationRouter } from "./organization/router";
import { subscriberRouter } from "./subscriber/router" import { subscriberRouter } from "./subscriber/router";
import { templateRouter } from "./template/router" import { templateRouter } from "./template/router";
import { campaignRouter } from "./campaign/router" import { campaignRouter } from "./campaign/router";
import { messageRouter } from "./message/router" import { messageRouter } from "./message/router";
import { settingsRouter } from "./settings/router" import { settingsRouter } from "./settings/router";
import swaggerSpec from "./swagger" import swaggerSpec from "./swagger";
import { apiRouter } from "./api/server" import { apiRouter } from "./api/server";
import { dashboardRouter } from "./dashboard/router" import { dashboardRouter } from "./dashboard/router";
import { statsRouter } from "./stats/router" import { statsRouter } from "./stats/router";
import { ONE_PX_PNG } from "./constants" import { ONE_PX_PNG } from "./constants";
const appRouter = router({ const appRouter = router({
user: userRouter, user: userRouter,
@@ -31,36 +31,36 @@ const appRouter = router({
settings: settingsRouter, settings: settingsRouter,
dashboard: dashboardRouter, dashboard: dashboardRouter,
stats: statsRouter, stats: statsRouter,
}) });
export type AppRouter = typeof appRouter export type AppRouter = typeof appRouter;
export const app = express() export const app = express();
app.use( app.use(
cors({ cors({
origin: ["http://localhost:3000", "http://localhost:4173"], origin: ["http://localhost:3000", "http://localhost:4173"],
}) }),
) );
app.use(express.json()) app.use(express.json());
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)) app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.get("/t/:id", async (req, res) => { app.get("/t/:id", async (req, res) => {
try { try {
const { id } = req.params const { id } = req.params;
const subscriberId = req.query.sid const subscriberId = req.query.sid;
const trackedLink = await prisma.trackedLink.findUnique({ const trackedLink = await prisma.trackedLink.findUnique({
where: { id }, where: { id },
}) });
if (!trackedLink) { if (!trackedLink) {
res.status(404).send("Link not found") res.status(404).send("Link not found");
return return;
} }
res.redirect(trackedLink.url) res.redirect(trackedLink.url);
if (subscriberId && typeof subscriberId === "string") { if (subscriberId && typeof subscriberId === "string") {
await prisma await prisma
@@ -71,9 +71,9 @@ app.get("/t/:id", async (req, res) => {
subscriberId, subscriberId,
trackedLinkId: trackedLink.id, trackedLinkId: trackedLink.id,
}, },
}) });
if (!trackedLink.campaignId) return if (!trackedLink.campaignId) return;
const message = await tx.message.findFirst({ const message = await tx.message.findFirst({
where: { where: {
@@ -83,9 +83,9 @@ app.get("/t/:id", async (req, res) => {
not: "CLICKED", not: "CLICKED",
}, },
}, },
}) });
if (!message) return if (!message) return;
await tx.message.update({ await tx.message.update({
where: { where: {
@@ -94,27 +94,27 @@ app.get("/t/:id", async (req, res) => {
data: { data: {
status: "CLICKED", status: "CLICKED",
}, },
}) });
}) })
.catch((error) => { .catch((error) => {
console.error("Error updating message status", error) console.error("Error updating message status", error);
}) });
} }
} catch (error) { } catch (error) {
res.status(404).send("Link not found") res.status(404).send("Link not found");
} }
}) });
app.get("/img/:id/img.png", async (req, res) => { app.get("/img/:id/img.png", async (req, res) => {
// Send pixel immediately // Send pixel immediately
const pixel = Buffer.from(ONE_PX_PNG, "base64") const pixel = Buffer.from(ONE_PX_PNG, "base64");
res.setHeader("Content-Type", "image/png") res.setHeader("Content-Type", "image/png");
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate") res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Pragma", "no-cache") res.setHeader("Pragma", "no-cache");
res.setHeader("Expires", "0") res.setHeader("Expires", "0");
res.end(pixel) res.end(pixel);
const id = req.params.id const id = req.params.id;
try { try {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
@@ -125,41 +125,41 @@ app.get("/img/:id/img.png", async (req, res) => {
openTracking: true, openTracking: true,
}, },
}, },
}) });
if (!message) { if (!message) {
return return;
} }
if (message.status !== "SENT") return if (message.status !== "SENT") return;
await tx.message.update({ await tx.message.update({
where: { id }, where: { id },
data: { data: {
status: "OPENED", status: "OPENED",
}, },
}) });
}) });
} catch (error) { } catch (error) {
console.error("Error updating message status", error) console.error("Error updating message status", error);
} }
}) });
app.use("/api", apiRouter) app.use("/api", apiRouter);
app.use( app.use(
"/trpc", "/trpc",
trpcExpress.createExpressMiddleware({ trpcExpress.createExpressMiddleware({
router: appRouter, router: appRouter,
createContext, createContext,
}) }),
) );
const staticPath = path.join(__dirname, "..", "..", "web", "dist") const staticPath = path.join(__dirname, "..", "..", "web", "dist");
// serve SPA content // serve SPA content
app.use(express.static(staticPath)) app.use(express.static(staticPath));
app.get("*", (_, res) => { app.get("*", (_, res) => {
res.sendFile(path.join(staticPath, "index.html")) res.sendFile(path.join(staticPath, "index.html"));
}) });

View File

@@ -1,15 +1,15 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import pMap from "p-map" import pMap from "p-map";
import { Mailer } from "../lib/Mailer" import { Mailer } from "../lib/Mailer";
const createCampaignSchema = z.object({ const createCampaignSchema = z.object({
title: z.string().min(1, "Campaign title is required"), title: z.string().min(1, "Campaign title is required"),
description: z.string().optional(), description: z.string().optional(),
organizationId: z.string(), organizationId: z.string(),
}) });
export const createCampaign = authProcedure export const createCampaign = authProcedure
.input(createCampaignSchema) .input(createCampaignSchema)
@@ -19,13 +19,13 @@ export const createCampaign = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const campaign = await prisma.campaign.create({ const campaign = await prisma.campaign.create({
@@ -43,10 +43,10 @@ export const createCampaign = authProcedure
}, },
}, },
}, },
}) });
return { campaign } return { campaign };
}) });
const updateCampaignSchema = z.object({ const updateCampaignSchema = z.object({
id: z.string(), id: z.string(),
@@ -59,7 +59,7 @@ const updateCampaignSchema = z.object({
scheduledAt: z.date().optional().nullable(), scheduledAt: z.date().optional().nullable(),
content: z.string().optional().nullable(), content: z.string().optional().nullable(),
openTracking: z.boolean().optional(), openTracking: z.boolean().optional(),
}) });
export const updateCampaign = authProcedure export const updateCampaign = authProcedure
.input(updateCampaignSchema) .input(updateCampaignSchema)
@@ -69,13 +69,13 @@ export const updateCampaign = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const campaign = await prisma.campaign.findFirst({ const campaign = await prisma.campaign.findFirst({
@@ -83,20 +83,20 @@ export const updateCampaign = authProcedure
id: input.id, id: input.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!campaign) { if (!campaign) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Campaign not found", message: "Campaign not found",
}) });
} }
if (campaign.status !== "DRAFT") { if (campaign.status !== "DRAFT") {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Campaign can not be updated!", message: "Campaign can not be updated!",
}) });
} }
// If a templateId is provided, ensure it exists // If a templateId is provided, ensure it exists
@@ -106,13 +106,13 @@ export const updateCampaign = authProcedure
id: input.templateId, id: input.templateId,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!template) { if (!template) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Template not found", message: "Template not found",
}) });
} }
} }
@@ -122,13 +122,13 @@ export const updateCampaign = authProcedure
id: { in: input.listIds }, id: { in: input.listIds },
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (lists.length !== input.listIds.length) { if (lists.length !== input.listIds.length) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "One or more lists not found", message: "One or more lists not found",
}) });
} }
} }
@@ -157,17 +157,17 @@ export const updateCampaign = authProcedure
}, },
}, },
}, },
}) });
return { campaign: updatedCampaign } return { campaign: updatedCampaign };
}) });
export const deleteCampaign = authProcedure export const deleteCampaign = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -175,13 +175,13 @@ export const deleteCampaign = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const campaign = await prisma.campaign.findFirst({ const campaign = await prisma.campaign.findFirst({
@@ -189,29 +189,29 @@ export const deleteCampaign = authProcedure
id: input.id, id: input.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!campaign) { if (!campaign) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Campaign not found", message: "Campaign not found",
}) });
} }
// On Delete: Cascade delete all messages // On Delete: Cascade delete all messages
await prisma.campaign.delete({ await prisma.campaign.delete({
where: { id: input.id }, where: { id: input.id },
}) });
return { success: true } return { success: true };
}) });
export const startCampaign = authProcedure export const startCampaign = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -219,13 +219,13 @@ export const startCampaign = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const [smtpSettings, emailSettings] = await Promise.all([ const [smtpSettings, emailSettings] = await Promise.all([
@@ -239,14 +239,14 @@ export const startCampaign = authProcedure
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}), }),
]) ]);
if (!smtpSettings) { if (!smtpSettings) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: message:
"You must configure your SMTP settings before running a campaign", "You must configure your SMTP settings before running a campaign",
}) });
} }
if (!emailSettings) { if (!emailSettings) {
@@ -254,7 +254,7 @@ export const startCampaign = authProcedure
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: message:
"You must configure your email delivery settings before running a campaign", "You must configure your email delivery settings before running a campaign",
}) });
} }
const campaign = await prisma.campaign.findFirst({ const campaign = await prisma.campaign.findFirst({
@@ -288,13 +288,13 @@ export const startCampaign = authProcedure
}, },
}, },
}, },
}) });
if (!campaign) { if (!campaign) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Campaign not found", message: "Campaign not found",
}) });
} }
// Check campaign status // Check campaign status
@@ -302,14 +302,14 @@ export const startCampaign = authProcedure
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Campaign can only be started from DRAFT status", message: "Campaign can only be started from DRAFT status",
}) });
} }
if (!campaign.subject) { if (!campaign.subject) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Email Subject is required", message: "Email Subject is required",
}) });
} }
// Check campaign has lists // Check campaign has lists
@@ -317,7 +317,7 @@ export const startCampaign = authProcedure
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Campaign must have at least one list", message: "Campaign must have at least one list",
}) });
} }
if (!campaign.content) { if (!campaign.content) {
@@ -325,75 +325,78 @@ export const startCampaign = authProcedure
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: message:
"Can not send an empty campaign. Write some content in the editor to start sending.", "Can not send an empty campaign. Write some content in the editor to start sending.",
}) });
} }
type Subscriber = type Subscriber =
(typeof campaign)["CampaignLists"][0]["List"]["ListSubscribers"][0]["Subscriber"] & { (typeof campaign)["CampaignLists"][0]["List"]["ListSubscribers"][0]["Subscriber"] & {
Metadata: { key: string; value: string }[] Metadata: { key: string; value: string }[];
} };
const subscribers = new Map<string, Subscriber>() const subscribers = new Map<string, Subscriber>();
await pMap(campaign.CampaignLists, (campaignList) => { await pMap(campaign.CampaignLists, (campaignList) => {
return pMap(campaignList.List.ListSubscribers, (listSubscriber) => { return pMap(campaignList.List.ListSubscribers, (listSubscriber) => {
subscribers.set(listSubscriber.Subscriber.id, listSubscriber.Subscriber) subscribers.set(
}) listSubscriber.Subscriber.id,
}) listSubscriber.Subscriber,
);
});
});
if (subscribers.size === 0) { if (subscribers.size === 0) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Campaign must have at least one recipient", message: "Campaign must have at least one recipient",
}) });
} }
const organization = await prisma.organization.findUnique({ const organization = await prisma.organization.findUnique({
where: { id: input.organizationId }, where: { id: input.organizationId },
select: { name: true }, select: { name: true },
}) });
if (!organization) { if (!organization) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Organization details could not be retrieved.", message: "Organization details could not be retrieved.",
}) });
} }
const generalSettings = await prisma.generalSettings.findFirst({ const generalSettings = await prisma.generalSettings.findFirst({
where: { where: {
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!generalSettings?.baseURL) { if (!generalSettings?.baseURL) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: message:
"Base URL must be configured in settings before running a campaign", "Base URL must be configured in settings before running a campaign",
}) });
} }
const status = const status =
campaign.scheduledAt && campaign.scheduledAt > new Date() campaign.scheduledAt && campaign.scheduledAt > new Date()
? "SCHEDULED" ? "SCHEDULED"
: "CREATING" : "CREATING";
const updatedCampaign = await prisma.campaign.update({ const updatedCampaign = await prisma.campaign.update({
where: { id: campaign.id }, where: { id: campaign.id },
data: { data: {
status, status,
}, },
}) });
return { campaign: updatedCampaign } return { campaign: updatedCampaign };
}) });
export const cancelCampaign = authProcedure export const cancelCampaign = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const campaign = await prisma.campaign.findFirst({ const campaign = await prisma.campaign.findFirst({
@@ -401,20 +404,20 @@ export const cancelCampaign = authProcedure
id: input.id, id: input.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!campaign) { if (!campaign) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Campaign not found", message: "Campaign not found",
}) });
} }
if (!["CREATING", "SENDING", "SCHEDULED"].includes(campaign.status)) { if (!["CREATING", "SENDING", "SCHEDULED"].includes(campaign.status)) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Campaign cannot be cancelled", message: "Campaign cannot be cancelled",
}) });
} }
await prisma.$transaction([ await prisma.$transaction([
@@ -437,10 +440,10 @@ export const cancelCampaign = authProcedure
status: "CANCELLED", status: "CANCELLED",
}, },
}), }),
]) ]);
return { success: true } return { success: true };
}) });
export const sendTestEmail = authProcedure export const sendTestEmail = authProcedure
.input( .input(
@@ -448,7 +451,7 @@ export const sendTestEmail = authProcedure
campaignId: z.string(), campaignId: z.string(),
organizationId: z.string(), organizationId: z.string(),
email: z.string().email(), email: z.string().email(),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -456,27 +459,27 @@ export const sendTestEmail = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const settings = await prisma.smtpSettings.findFirst({ const settings = await prisma.smtpSettings.findFirst({
where: { where: {
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!settings) { if (!settings) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: message:
"You must configure your SMTP settings before sending test emails", "You must configure your SMTP settings before sending test emails",
}) });
} }
const campaign = await prisma.campaign.findFirst({ const campaign = await prisma.campaign.findFirst({
@@ -487,56 +490,56 @@ export const sendTestEmail = authProcedure
include: { include: {
Template: true, Template: true,
}, },
}) });
if (!campaign) { if (!campaign) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Campaign not found", message: "Campaign not found",
}) });
} }
if (!campaign.content) { if (!campaign.content) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Campaign must have content", message: "Campaign must have content",
}) });
} }
if (!campaign.subject) { if (!campaign.subject) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Email Subject is required", message: "Email Subject is required",
}) });
} }
const content = campaign.Template const content = campaign.Template
? campaign.Template.content.replace(/{{content}}/g, campaign.content) ? campaign.Template.content.replace(/{{content}}/g, campaign.content)
: campaign.content : campaign.content;
const mailer = new Mailer(settings) const mailer = new Mailer(settings);
const result = await mailer.sendEmail({ const result = await mailer.sendEmail({
to: input.email, to: input.email,
subject: `[Test] ${campaign.subject}`, subject: `[Test] ${campaign.subject}`,
html: content, html: content,
from: `${settings.fromName} <${settings.fromEmail}>`, from: `${settings.fromName} <${settings.fromEmail}>`,
}) });
if (!result.success) { if (!result.success) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to send test email", message: "Failed to send test email",
}) });
} }
return { success: true } return { success: true };
}) });
const duplicateCampaignSchema = z.object({ const duplicateCampaignSchema = z.object({
id: z.string(), id: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) });
export const duplicateCampaign = authProcedure export const duplicateCampaign = authProcedure
.input(duplicateCampaignSchema) .input(duplicateCampaignSchema)
@@ -546,13 +549,13 @@ export const duplicateCampaign = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const originalCampaign = await prisma.campaign.findFirst({ const originalCampaign = await prisma.campaign.findFirst({
@@ -568,13 +571,13 @@ export const duplicateCampaign = authProcedure
}, },
}, },
}, },
}) });
if (!originalCampaign) { if (!originalCampaign) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Campaign not found", message: "Campaign not found",
}) });
} }
const newCampaign = await prisma.campaign.create({ const newCampaign = await prisma.campaign.create({
@@ -601,7 +604,7 @@ export const duplicateCampaign = authProcedure
}, },
}, },
}, },
}) });
return { campaign: newCampaign } return { campaign: newCampaign };
}) });

View File

@@ -1,10 +1,10 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas" import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client" import { Prisma } from "../../prisma/client";
import { resolveProps } from "../utils/pProps" import { resolveProps } from "../utils/pProps";
export const listCampaigns = authProcedure export const listCampaigns = authProcedure
.input(z.object({ organizationId: z.string() }).merge(paginationSchema)) .input(z.object({ organizationId: z.string() }).merge(paginationSchema))
@@ -14,13 +14,13 @@ export const listCampaigns = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const where: Prisma.CampaignWhereInput = { const where: Prisma.CampaignWhereInput = {
@@ -34,7 +34,7 @@ export const listCampaigns = authProcedure
], ],
} }
: {}), : {}),
} };
const [total, campaigns] = await prisma.$transaction([ const [total, campaigns] = await prisma.$transaction([
prisma.campaign.count({ where }), prisma.campaign.count({ where }),
@@ -67,9 +67,9 @@ export const listCampaigns = authProcedure
}, },
}, },
}), }),
]) ]);
const totalPages = Math.ceil(total / input.perPage) const totalPages = Math.ceil(total / input.perPage);
return { return {
campaigns, campaigns,
@@ -80,15 +80,15 @@ export const listCampaigns = authProcedure
perPage: input.perPage, perPage: input.perPage,
hasMore: input.page < totalPages, hasMore: input.page < totalPages,
}, },
} };
}) });
export const getCampaign = authProcedure export const getCampaign = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -96,13 +96,13 @@ export const getCampaign = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const campaign = await prisma.campaign.findFirst({ const campaign = await prisma.campaign.findFirst({
@@ -118,13 +118,13 @@ export const getCampaign = authProcedure
}, },
}, },
}, },
}) });
if (!campaign) { if (!campaign) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Campaign not found", message: "Campaign not found",
}) });
} }
const listSubscribers = await prisma.listSubscriber.findMany({ const listSubscribers = await prisma.listSubscriber.findMany({
@@ -138,7 +138,7 @@ export const getCampaign = authProcedure
id: true, id: true,
}, },
distinct: ["subscriberId"], distinct: ["subscriberId"],
}) });
// Add the count to each list for backward compatibility // Add the count to each list for backward compatibility
const campaignWithCounts = { const campaignWithCounts = {
@@ -150,7 +150,7 @@ export const getCampaign = authProcedure
listId: cl.listId, listId: cl.listId,
unsubscribedAt: null, unsubscribedAt: null,
}, },
}) });
return { return {
...cl, ...cl,
@@ -160,12 +160,12 @@ export const getCampaign = authProcedure
ListSubscribers: count, ListSubscribers: count,
}, },
}, },
} };
}) }),
), ),
// Add the unique subscriber count directly to the campaign object // Add the unique subscriber count directly to the campaign object
uniqueRecipientCount: listSubscribers.length, uniqueRecipientCount: listSubscribers.length,
} };
const promises = { const promises = {
totalMessages: prisma.message.count({ totalMessages: prisma.message.count({
@@ -221,9 +221,9 @@ export const getCampaign = authProcedure
}, },
}, },
}), }),
} };
const result = await resolveProps(promises) const result = await resolveProps(promises);
return { return {
campaign: campaignWithCounts, campaign: campaignWithCounts,
@@ -245,5 +245,5 @@ export const getCampaign = authProcedure
? (result.opened / result.sentMessages) * 100 ? (result.opened / result.sentMessages) * 100
: 0, : 0,
}, },
} };
}) });

View File

@@ -1,4 +1,4 @@
import { router } from "../trpc" import { router } from "../trpc";
import { import {
createCampaign, createCampaign,
updateCampaign, updateCampaign,
@@ -7,8 +7,8 @@ import {
cancelCampaign, cancelCampaign,
sendTestEmail, sendTestEmail,
duplicateCampaign, duplicateCampaign,
} from "./mutation" } from "./mutation";
import { getCampaign, listCampaigns } from "./query" import { getCampaign, listCampaigns } from "./query";
export const campaignRouter = router({ export const campaignRouter = router({
create: createCampaign, create: createCampaign,
@@ -20,4 +20,4 @@ export const campaignRouter = router({
cancel: cancelCampaign, cancel: cancelCampaign,
sendTestEmail, sendTestEmail,
duplicate: duplicateCampaign, duplicate: duplicateCampaign,
}) });

View File

@@ -1,11 +1,11 @@
import { z } from "zod" import { z } from "zod";
export const env = z export const env = z
.object({ .object({
JWT_SECRET: z.string().min(1, "JWT_SECRET is required"), JWT_SECRET: z.string().min(1, "JWT_SECRET is required"),
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"), DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
}) })
.parse(process.env) .parse(process.env);
export const ONE_PX_PNG = export const ONE_PX_PNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";

View File

@@ -1,57 +1,57 @@
import cron from "node-cron" import cron from "node-cron";
import { sendMessagesCron } from "./sendMessages" import { sendMessagesCron } from "./sendMessages";
import { dailyMaintenanceCron } from "./dailyMaintenance" import { dailyMaintenanceCron } from "./dailyMaintenance";
import { processQueuedCampaigns } from "./processQueuedCampaigns" import { processQueuedCampaigns } from "./processQueuedCampaigns";
type CronJob = { type CronJob = {
name: string name: string;
schedule: string schedule: string;
job: () => Promise<void> job: () => Promise<void>;
enabled: boolean enabled: boolean;
} };
const sendMessagesJob: CronJob = { const sendMessagesJob: CronJob = {
name: "send-queued-messages", name: "send-queued-messages",
schedule: "*/5 * * * * *", // Runs every 5 seconds schedule: "*/5 * * * * *", // Runs every 5 seconds
job: sendMessagesCron, job: sendMessagesCron,
enabled: true, enabled: true,
} };
const dailyMaintenanceJob: CronJob = { const dailyMaintenanceJob: CronJob = {
name: "daily-maintenance", name: "daily-maintenance",
schedule: "0 0 * * *", // Runs daily at midnight schedule: "0 0 * * *", // Runs daily at midnight
job: dailyMaintenanceCron, job: dailyMaintenanceCron,
enabled: true, enabled: true,
} };
const processQueuedCampaignsJob: CronJob = { const processQueuedCampaignsJob: CronJob = {
name: "process-queued-campaigns", name: "process-queued-campaigns",
schedule: "* * * * * *", // Runs every second schedule: "* * * * * *", // Runs every second
job: processQueuedCampaigns, job: processQueuedCampaigns,
enabled: true, enabled: true,
} };
const cronJobs: CronJob[] = [ const cronJobs: CronJob[] = [
sendMessagesJob, sendMessagesJob,
dailyMaintenanceJob, dailyMaintenanceJob,
processQueuedCampaignsJob, processQueuedCampaignsJob,
] ];
export const initializeCronJobs = () => { export const initializeCronJobs = () => {
const scheduledJobs = cronJobs const scheduledJobs = cronJobs
.filter((job) => job.enabled) .filter((job) => job.enabled)
.map((job) => { .map((job) => {
const task = cron.schedule(job.schedule, job.job) const task = cron.schedule(job.schedule, job.job);
console.log( console.log(
`Cron job '${job.name}' scheduled with cron expression: ${job.schedule}` `Cron job '${job.name}' scheduled with cron expression: ${job.schedule}`,
) );
return { name: job.name, task } return { name: job.name, task };
}) });
console.log(`${scheduledJobs.length} cron jobs initialized`) console.log(`${scheduledJobs.length} cron jobs initialized`);
return { return {
jobs: scheduledJobs, jobs: scheduledJobs,
stop: () => scheduledJobs.forEach(({ task }) => task.stop()), stop: () => scheduledJobs.forEach(({ task }) => task.stop()),
} };
} };

View File

@@ -1,4 +1,4 @@
const runningJobs = new Map<string, boolean>() const runningJobs = new Map<string, boolean>();
/** /**
* A wrapper for cron jobs * A wrapper for cron jobs
@@ -6,17 +6,17 @@ const runningJobs = new Map<string, boolean>()
export function cronJob(name: string, cronFn: () => Promise<void>) { export function cronJob(name: string, cronFn: () => Promise<void>) {
return async () => { return async () => {
if (runningJobs.get(name)) { if (runningJobs.get(name)) {
return return;
} }
runningJobs.set(name, true) runningJobs.set(name, true);
try { try {
await cronFn() await cronFn();
} catch (error) { } catch (error) {
console.error("Cron Error:", `[${name}]`, error) console.error("Cron Error:", `[${name}]`, error);
} finally { } finally {
runningJobs.set(name, false) runningJobs.set(name, false);
}
} }
};
} }

View File

@@ -1,21 +1,21 @@
import { cronJob } from "./cron.utils" import { cronJob } from "./cron.utils";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import dayjs from "dayjs" import dayjs from "dayjs";
export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => { export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => {
const organizations = await prisma.organization.findMany({ const organizations = await prisma.organization.findMany({
include: { include: {
GeneralSettings: true, GeneralSettings: true,
}, },
}) });
let totalDeletedMessages = 0 let totalDeletedMessages = 0;
for (const org of organizations) { for (const org of organizations) {
const cleanupIntervalDays = org.GeneralSettings?.cleanupInterval ?? 30 const cleanupIntervalDays = org.GeneralSettings?.cleanupInterval ?? 30;
const cleanupOlderThanDate = dayjs() const cleanupOlderThanDate = dayjs()
.subtract(cleanupIntervalDays, "days") .subtract(cleanupIntervalDays, "days")
.toDate() .toDate();
try { try {
const messagesToClean = await prisma.message.findMany({ const messagesToClean = await prisma.message.findMany({
@@ -33,7 +33,7 @@ export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => {
select: { select: {
id: true, id: true,
}, },
}) });
await prisma.message.updateMany({ await prisma.message.updateMany({
data: { data: {
@@ -44,25 +44,25 @@ export const dailyMaintenanceCron = cronJob("daily-maintenance", async () => {
in: messagesToClean.map((msg) => msg.id), in: messagesToClean.map((msg) => msg.id),
}, },
}, },
}) });
if (messagesToClean.length > 0) { if (messagesToClean.length > 0) {
console.log( console.log(
`Daily maintenance for org ${org.id}: Deleted ${messagesToClean.length} messages older than ${cleanupIntervalDays} days.` `Daily maintenance for org ${org.id}: Deleted ${messagesToClean.length} messages older than ${cleanupIntervalDays} days.`,
) );
totalDeletedMessages += messagesToClean.length totalDeletedMessages += messagesToClean.length;
} }
} catch (error) { } catch (error) {
console.error(`Error deleting messages for org ${org.id}: ${error}`) console.error(`Error deleting messages for org ${org.id}: ${error}`);
continue continue;
} }
} }
if (totalDeletedMessages > 0) { if (totalDeletedMessages > 0) {
console.log( console.log(
`Daily maintenance job finished. Total deleted messages: ${totalDeletedMessages}.` `Daily maintenance job finished. Total deleted messages: ${totalDeletedMessages}.`,
) );
} else { } else {
console.log("Daily maintenance job finished. No messages to delete.") console.log("Daily maintenance job finished. No messages to delete.");
} }
}) });

View File

@@ -1,23 +1,23 @@
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { LinkTracker } from "../lib/LinkTracker" import { LinkTracker } from "../lib/LinkTracker";
import { v4 as uuidV4 } from "uuid" import { v4 as uuidV4 } from "uuid";
import { import {
replacePlaceholders, replacePlaceholders,
PlaceholderDataKey, PlaceholderDataKey,
} from "../utils/placeholder-parser" } from "../utils/placeholder-parser";
import pMap from "p-map" import pMap from "p-map";
import { Subscriber, Prisma, SubscriberMetadata } from "../../prisma/client" import { Subscriber, Prisma, SubscriberMetadata } from "../../prisma/client";
import { cronJob } from "./cron.utils" import { cronJob } from "./cron.utils";
// TODO: Make this a config // TODO: Make this a config
const BATCH_SIZE = 100 const BATCH_SIZE = 100;
async function getSubscribersForCampaign( async function getSubscribersForCampaign(
campaignId: string, campaignId: string,
selectedListIds: string[] selectedListIds: string[],
): Promise<Map<string, Subscriber & { Metadata: SubscriberMetadata[] }>> { ): Promise<Map<string, Subscriber & { Metadata: SubscriberMetadata[] }>> {
if (selectedListIds.length === 0) { if (selectedListIds.length === 0) {
return new Map() return new Map();
} }
const subscribers = await prisma.subscriber.findMany({ const subscribers = await prisma.subscriber.findMany({
@@ -34,19 +34,19 @@ async function getSubscribersForCampaign(
include: { include: {
Metadata: true, Metadata: true,
}, },
}) });
if (!subscribers.length) return new Map() if (!subscribers.length) return new Map();
const subscribersMap = new Map< const subscribersMap = new Map<
string, string,
Subscriber & { Metadata: SubscriberMetadata[] } Subscriber & { Metadata: SubscriberMetadata[] }
>() >();
await pMap(subscribers, async (subscriber) => { await pMap(subscribers, async (subscriber) => {
subscribersMap.set(subscriber.id, subscriber) subscribersMap.set(subscriber.id, subscriber);
}) });
return subscribersMap return subscribersMap;
} }
const logged = { const logged = {
@@ -56,18 +56,18 @@ const logged = {
missingCampaignContent: false, missingCampaignContent: false,
missingCampaignSubject: false, missingCampaignSubject: false,
errorProcessingCampaign: false, errorProcessingCampaign: false,
} };
const oneTimeLogger = (key: keyof typeof logged, ...messages: unknown[]) => { const oneTimeLogger = (key: keyof typeof logged, ...messages: unknown[]) => {
if (!logged[key]) { if (!logged[key]) {
console.log(...messages) console.log(...messages);
logged[key] = true logged[key] = true;
} }
} };
const turnOnLogger = (key: keyof typeof logged) => { const turnOnLogger = (key: keyof typeof logged) => {
logged[key] = false logged[key] = false;
} };
export const processQueuedCampaigns = cronJob( export const processQueuedCampaigns = cronJob(
"process-queued-campaigns", "process-queued-campaigns",
@@ -88,17 +88,17 @@ export const processQueuedCampaigns = cronJob(
}, },
Template: true, Template: true,
}, },
}) });
if (queuedCampaigns.length === 0) { if (queuedCampaigns.length === 0) {
oneTimeLogger( oneTimeLogger(
"noQueuedCampaigns", "noQueuedCampaigns",
"Cron job: No queued campaigns to process." "Cron job: No queued campaigns to process.",
) );
return return;
} }
turnOnLogger("noQueuedCampaigns") turnOnLogger("noQueuedCampaigns");
for (const campaign of queuedCampaigns) { for (const campaign of queuedCampaigns) {
try { try {
@@ -111,82 +111,82 @@ export const processQueuedCampaigns = cronJob(
) { ) {
oneTimeLogger( oneTimeLogger(
"missingCampaignData", "missingCampaignData",
`Cron job: Campaign ${campaign.id} is missing required data (content, subject, organization, or baseURL). Skipping.` `Cron job: Campaign ${campaign.id} is missing required data (content, subject, organization, or baseURL). Skipping.`,
) );
// Optionally, update status to FAILED or similar // Optionally, update status to FAILED or similar
// await prisma.campaign.update({ where: { id: campaign.id }, data: { status: 'FAILED', statusReason: 'Missing critical data for processing' } }); // await prisma.campaign.update({ where: { id: campaign.id }, data: { status: 'FAILED', statusReason: 'Missing critical data for processing' } });
continue continue;
} }
turnOnLogger("missingCampaignData") turnOnLogger("missingCampaignData");
const generalSettings = campaign.Organization.GeneralSettings const generalSettings = campaign.Organization.GeneralSettings;
const selectedListIds = campaign.CampaignLists.map((cl) => cl.listId) const selectedListIds = campaign.CampaignLists.map((cl) => cl.listId);
const allSubscribersMap = await getSubscribersForCampaign( const allSubscribersMap = await getSubscribersForCampaign(
campaign.id, campaign.id,
selectedListIds selectedListIds,
) );
if (allSubscribersMap.size === 0) { if (allSubscribersMap.size === 0) {
oneTimeLogger( oneTimeLogger(
"noSubscribers", "noSubscribers",
`Cron job: Campaign ${campaign.id} has no subscribers. Skipping.` `Cron job: Campaign ${campaign.id} has no subscribers. Skipping.`,
) );
continue continue;
} }
turnOnLogger("noSubscribers") turnOnLogger("noSubscribers");
const messageSubscriberIds = ( const messageSubscriberIds = (
await prisma.message.findMany({ await prisma.message.findMany({
where: { campaignId: campaign.id }, where: { campaignId: campaign.id },
select: { subscriberId: true }, select: { subscriberId: true },
}) })
).map((m) => m.subscriberId) ).map((m) => m.subscriberId);
const subscribersWithMessage = new Set(messageSubscriberIds) const subscribersWithMessage = new Set(messageSubscriberIds);
const subscribersToProcess = Array.from( const subscribersToProcess = Array.from(
allSubscribersMap.values() allSubscribersMap.values(),
).filter((sub) => !subscribersWithMessage.has(sub.id)) ).filter((sub) => !subscribersWithMessage.has(sub.id));
if (subscribersToProcess.length === 0) { if (subscribersToProcess.length === 0) {
continue continue;
} }
await prisma.$transaction( await prisma.$transaction(
async (tx) => { async (tx) => {
const linkTracker = new LinkTracker(tx) const linkTracker = new LinkTracker(tx);
const messagesToCreate: Prisma.MessageCreateManyInput[] = [] const messagesToCreate: Prisma.MessageCreateManyInput[] = [];
for (const subscriber of subscribersToProcess) { for (const subscriber of subscribersToProcess) {
const messageId = uuidV4() const messageId = uuidV4();
if (!campaign.content) { if (!campaign.content) {
oneTimeLogger( oneTimeLogger(
"missingCampaignContent", "missingCampaignContent",
`Cron job: Campaign ${campaign.id} has no content. Skipping.` `Cron job: Campaign ${campaign.id} has no content. Skipping.`,
) );
continue continue;
} }
turnOnLogger("missingCampaignContent") turnOnLogger("missingCampaignContent");
let emailContent = campaign.Template let emailContent = campaign.Template
? campaign.Template.content.replace( ? campaign.Template.content.replace(
/{{content}}/g, /{{content}}/g,
campaign.content campaign.content,
) )
: campaign.content : campaign.content;
if (!campaign.subject) { if (!campaign.subject) {
oneTimeLogger( oneTimeLogger(
"missingCampaignSubject", "missingCampaignSubject",
`Cron job: Campaign ${campaign.id} has no subject. Skipping.` `Cron job: Campaign ${campaign.id} has no subject. Skipping.`,
) );
continue continue;
} }
turnOnLogger("missingCampaignSubject") turnOnLogger("missingCampaignSubject");
const placeholderData: Partial< const placeholderData: Partial<
Record<PlaceholderDataKey, string> Record<PlaceholderDataKey, string>
@@ -197,37 +197,37 @@ export const processQueuedCampaigns = cronJob(
"organization.name": campaign.Organization.name, "organization.name": campaign.Organization.name,
unsubscribe_link: `${generalSettings.baseURL}/unsubscribe?sid=${subscriber.id}&cid=${campaign.id}&mid=${messageId}`, unsubscribe_link: `${generalSettings.baseURL}/unsubscribe?sid=${subscriber.id}&cid=${campaign.id}&mid=${messageId}`,
current_date: new Date().toLocaleDateString("en-CA"), current_date: new Date().toLocaleDateString("en-CA"),
} };
if (campaign.openTracking) { if (campaign.openTracking) {
emailContent += `<img src="${generalSettings.baseURL}/img/${messageId}/img.png" alt="" width="1" height="1" style="display:none" />` emailContent += `<img src="${generalSettings.baseURL}/img/${messageId}/img.png" alt="" width="1" height="1" style="display:none" />`;
} }
if (subscriber.name) { if (subscriber.name) {
placeholderData["subscriber.name"] = subscriber.name placeholderData["subscriber.name"] = subscriber.name;
} }
if (subscriber.Metadata) { if (subscriber.Metadata) {
for (const meta of subscriber.Metadata) { for (const meta of subscriber.Metadata) {
placeholderData[`subscriber.metadata.${meta.key}`] = placeholderData[`subscriber.metadata.${meta.key}`] =
meta.value meta.value;
} }
} }
emailContent = replacePlaceholders(emailContent, placeholderData) emailContent = replacePlaceholders(emailContent, placeholderData);
if (!generalSettings.baseURL) { if (!generalSettings.baseURL) {
console.error( console.error(
`Cron job: Campaign ${campaign.id} has no baseURL. Skipping.` `Cron job: Campaign ${campaign.id} has no baseURL. Skipping.`,
) );
continue continue;
} }
const { content: finalContent } = const { content: finalContent } =
await linkTracker.replaceMessageContentWithTrackedLinks( await linkTracker.replaceMessageContentWithTrackedLinks(
emailContent, emailContent,
campaign.id, campaign.id,
generalSettings.baseURL generalSettings.baseURL,
) );
messagesToCreate.push({ messagesToCreate.push({
id: messageId, id: messageId,
@@ -235,13 +235,13 @@ export const processQueuedCampaigns = cronJob(
subscriberId: subscriber.id, subscriberId: subscriber.id,
content: finalContent, content: finalContent,
status: "QUEUED", status: "QUEUED",
}) });
} }
if (messagesToCreate.length > 0) { if (messagesToCreate.length > 0) {
await tx.message.createMany({ await tx.message.createMany({
data: messagesToCreate, data: messagesToCreate,
}) });
const subscribersLeft = await tx.subscriber.count({ const subscribersLeft = await tx.subscriber.count({
where: { where: {
@@ -253,33 +253,33 @@ export const processQueuedCampaigns = cronJob(
}, },
}, },
}, },
}) });
if (subscribersLeft === 0) { if (subscribersLeft === 0) {
await tx.campaign.update({ await tx.campaign.update({
where: { id: campaign.id }, where: { id: campaign.id },
data: { status: "SENDING" }, data: { status: "SENDING" },
}) });
} }
console.log( console.log(
`Cron job: Created ${messagesToCreate.length} messages for campaign ${campaign.id}.` `Cron job: Created ${messagesToCreate.length} messages for campaign ${campaign.id}.`,
) );
} }
}, },
{ timeout: 60_000 } { timeout: 60_000 },
) // End transaction ); // End transaction
turnOnLogger("errorProcessingCampaign") turnOnLogger("errorProcessingCampaign");
} catch (error) { } catch (error) {
oneTimeLogger( oneTimeLogger(
"errorProcessingCampaign", "errorProcessingCampaign",
`Cron job: Error processing campaign ${campaign.id}:`, `Cron job: Error processing campaign ${campaign.id}:`,
error error,
) );
// Optionally, mark campaign as FAILED // Optionally, mark campaign as FAILED
// await prisma.campaign.update({ where: { id: basicCampaignInfo.id }, data: { status: 'FAILED', statusReason: error.message }}); // await prisma.campaign.update({ where: { id: basicCampaignInfo.id }, data: { status: 'FAILED', statusReason: error.message }});
} }
} }
} },
) );

View File

@@ -1,13 +1,13 @@
import pMap from "p-map" import pMap from "p-map";
import { Mailer } from "../lib/Mailer" import { Mailer } from "../lib/Mailer";
import { logger } from "../utils/logger" import { logger } from "../utils/logger";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { cronJob } from "./cron.utils" import { cronJob } from "./cron.utils";
import { subSeconds } from "date-fns" import { subSeconds } from "date-fns";
export const sendMessagesCron = cronJob("sendMessages", async () => { export const sendMessagesCron = cronJob("sendMessages", async () => {
const organizations = await prisma.organization.findMany() const organizations = await prisma.organization.findMany();
for (const organization of organizations) { for (const organization of organizations) {
const [smtpSettings, emailSettings, generalSettings] = await Promise.all([ const [smtpSettings, emailSettings, generalSettings] = await Promise.all([
@@ -20,16 +20,16 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
prisma.generalSettings.findFirst({ prisma.generalSettings.findFirst({
where: { organizationId: organization.id }, where: { organizationId: organization.id },
}), }),
]) ]);
if (!smtpSettings || !emailSettings) { if (!smtpSettings || !emailSettings) {
logger.warn( logger.warn(
`Required settings not found for org ${organization.id}, skipping` `Required settings not found for org ${organization.id}, skipping`,
) );
continue continue;
} }
const windowStart = subSeconds(new Date(), emailSettings.rateWindow) const windowStart = subSeconds(new Date(), emailSettings.rateWindow);
const sentInWindow = await prisma.message.count({ const sentInWindow = await prisma.message.count({
where: { where: {
status: { status: {
@@ -42,12 +42,12 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
organizationId: organization.id, organizationId: organization.id,
}, },
}, },
}) });
const availableSlots = Math.max(0, emailSettings.rateLimit - sentInWindow) const availableSlots = Math.max(0, emailSettings.rateLimit - sentInWindow);
if (availableSlots === 0) { if (availableSlots === 0) {
continue continue;
} }
// Message status is now independent of campaign status. // Message status is now independent of campaign status.
@@ -81,7 +81,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
}, },
}, },
take: availableSlots, take: availableSlots,
}) });
const noMoreRetryingMessages = await prisma.message.count({ const noMoreRetryingMessages = await prisma.message.count({
where: { where: {
@@ -90,7 +90,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
organizationId: organization.id, organizationId: organization.id,
}, },
}, },
}) });
if (!messages.length && noMoreRetryingMessages === 0) { if (!messages.length && noMoreRetryingMessages === 0) {
await prisma.campaign.updateMany({ await prisma.campaign.updateMany({
@@ -109,39 +109,39 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
status: "COMPLETED", status: "COMPLETED",
completedAt: new Date(), completedAt: new Date(),
}, },
}) });
continue continue;
} }
logger.info(`Found ${messages.length} messages to send`) logger.info(`Found ${messages.length} messages to send`);
const mailer = new Mailer({ const mailer = new Mailer({
...smtpSettings, ...smtpSettings,
timeout: emailSettings.connectionTimeout, timeout: emailSettings.connectionTimeout,
}) });
const fromName = const fromName =
smtpSettings.fromName ?? generalSettings?.defaultFromName ?? "" smtpSettings.fromName ?? generalSettings?.defaultFromName ?? "";
const fromEmail = const fromEmail =
smtpSettings.fromEmail ?? generalSettings?.defaultFromEmail ?? "" smtpSettings.fromEmail ?? generalSettings?.defaultFromEmail ?? "";
if (!fromName || !fromEmail) { if (!fromName || !fromEmail) {
logger.warn("No from name or email found, message will not be sent") logger.warn("No from name or email found, message will not be sent");
continue continue;
} }
await pMap( await pMap(
messages, messages,
async (message) => { async (message) => {
if (!message.Campaign.subject) { if (!message.Campaign.subject) {
logger.warn("No subject found for campaign") logger.warn("No subject found for campaign");
return return;
} }
await prisma.message.update({ await prisma.message.update({
where: { id: message.id }, where: { id: message.id },
data: { status: "PENDING" }, data: { status: "PENDING" },
}) });
try { try {
const result = await mailer.sendEmail({ const result = await mailer.sendEmail({
@@ -149,7 +149,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
subject: message.Campaign.subject, subject: message.Campaign.subject,
html: message.content, html: message.content,
from: `${fromName} <${fromEmail}>`, from: `${fromName} <${fromEmail}>`,
}) });
await prisma.message.update({ await prisma.message.update({
where: { id: message.id }, where: { id: message.id },
@@ -166,7 +166,7 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
}, },
lastTriedAt: new Date(), lastTriedAt: new Date(),
}, },
}) });
} catch (error) { } catch (error) {
await prisma.message.update({ await prisma.message.update({
where: { id: message.id }, where: { id: message.id },
@@ -181,10 +181,10 @@ export const sendMessagesCron = cronJob("sendMessages", async () => {
}, },
lastTriedAt: new Date(), lastTriedAt: new Date(),
}, },
}) });
} }
}, },
{ concurrency: emailSettings.concurrency } { concurrency: emailSettings.concurrency },
) );
} }
}) });

View File

@@ -1,17 +1,17 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { MessageStatus } from "../../prisma/client" import { MessageStatus } from "../../prisma/client";
import { countDbSize, subscriberGrowthQuery } from "../../prisma/client/sql" import { countDbSize, subscriberGrowthQuery } from "../../prisma/client/sql";
import pMap from "p-map" import pMap from "p-map";
import { subMonths } from "date-fns" import { subMonths } from "date-fns";
export const getDashboardStats = authProcedure export const getDashboardStats = authProcedure
.input( .input(
z.object({ z.object({
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -19,17 +19,17 @@ export const getDashboardStats = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const from = subMonths(new Date(), 6) const from = subMonths(new Date(), 6);
const to = new Date() const to = new Date();
const dateFilter = { const dateFilter = {
...(from && to ...(from && to
@@ -40,7 +40,7 @@ export const getDashboardStats = authProcedure
}, },
} }
: {}), : {}),
} };
const [messageStats, recentCampaigns, subscriberGrowth, [dbSize]] = const [messageStats, recentCampaigns, subscriberGrowth, [dbSize]] =
await Promise.all([ await Promise.all([
@@ -76,20 +76,20 @@ export const getDashboardStats = authProcedure
// Subscriber growth over time // Subscriber growth over time
prisma.$queryRawTyped( prisma.$queryRawTyped(
subscriberGrowthQuery(input.organizationId, from, to) subscriberGrowthQuery(input.organizationId, from, to),
), ),
prisma.$queryRawTyped(countDbSize(input.organizationId)), prisma.$queryRawTyped(countDbSize(input.organizationId)),
]) ]);
// Process message stats // Process message stats
const messageStatsByStatus = messageStats.reduce( const messageStatsByStatus = messageStats.reduce(
(acc, stat) => { (acc, stat) => {
acc[stat.status as MessageStatus] = stat._count acc[stat.status as MessageStatus] = stat._count;
return acc return acc;
}, },
{} as Record<MessageStatus, number> {} as Record<MessageStatus, number>,
) );
// Process recent campaigns // Process recent campaigns
const processedCampaigns = await pMap(recentCampaigns, async (campaign) => { const processedCampaigns = await pMap(recentCampaigns, async (campaign) => {
@@ -105,7 +105,7 @@ export const getDashboardStats = authProcedure
prisma.message.count({ prisma.message.count({
where: { campaignId: campaign.id }, where: { campaignId: campaign.id },
}), }),
]) ]);
return { return {
id: campaign.id, id: campaign.id,
@@ -116,24 +116,24 @@ export const getDashboardStats = authProcedure
totalMessages: totalCount, totalMessages: totalCount,
sentMessages: deliveredCount, sentMessages: deliveredCount,
createdAt: campaign.createdAt, createdAt: campaign.createdAt,
} };
}) });
const subscriberGrowthCumulative: { date: Date; count: number }[] = [] const subscriberGrowthCumulative: { date: Date; count: number }[] = [];
for (let i = 0; i < subscriberGrowth.length; i++) { for (let i = 0; i < subscriberGrowth.length; i++) {
const point = subscriberGrowth[i] const point = subscriberGrowth[i];
if (!point?.date) { if (!point?.date) {
continue continue;
} }
const prev = subscriberGrowthCumulative[i - 1]?.count || 0 const prev = subscriberGrowthCumulative[i - 1]?.count || 0;
subscriberGrowthCumulative.push({ subscriberGrowthCumulative.push({
date: point.date, date: point.date,
count: Number(point.count) + Number(prev), count: Number(point.count) + Number(prev),
}) });
} }
return { return {
@@ -141,5 +141,5 @@ export const getDashboardStats = authProcedure
recentCampaigns: processedCampaigns, recentCampaigns: processedCampaigns,
subscriberGrowth: subscriberGrowthCumulative, subscriberGrowth: subscriberGrowthCumulative,
dbSize, dbSize,
} };
}) });

View File

@@ -1,6 +1,6 @@
import { router } from "../trpc" import { router } from "../trpc";
import { getDashboardStats } from "./query" import { getDashboardStats } from "./query";
export const dashboardRouter = router({ export const dashboardRouter = router({
getStats: getDashboardStats, getStats: getDashboardStats,
}) });

View File

@@ -1,17 +1,17 @@
export type * from "./app" export type * from "./app";
export type * from "../prisma/client" export type * from "../prisma/client";
export type * from "./types" export type * from "./types";
import { app } from "./app" import { app } from "./app";
import { initializeCronJobs } from "./cron/cron" import { initializeCronJobs } from "./cron/cron";
import { prisma } from "./utils/prisma" import { prisma } from "./utils/prisma";
const cronController = initializeCronJobs() const cronController = initializeCronJobs();
const PORT = process.env.PORT || 5000 const PORT = process.env.PORT || 5000;
prisma.$connect().then(async () => { prisma.$connect().then(async () => {
console.log("Connected to database") console.log("Connected to database");
// For backwards compatibility, set all messages that have campaign status === "CANCELLED" to "CANCELLED" // For backwards compatibility, set all messages that have campaign status === "CANCELLED" to "CANCELLED"
await prisma.message.updateMany({ await prisma.message.updateMany({
@@ -26,19 +26,19 @@ prisma.$connect().then(async () => {
data: { data: {
status: "CANCELLED", status: "CANCELLED",
}, },
}) });
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`) console.log(`Server is running on port ${PORT}`);
}) });
}) });
// Handle graceful shutdown // Handle graceful shutdown
const shutdown = () => { const shutdown = () => {
console.log("Shutting down cron jobs...") console.log("Shutting down cron jobs...");
cronController.stop() cronController.stop();
process.exit(0) process.exit(0);
} };
process.on("SIGINT", shutdown) process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown) process.on("SIGTERM", shutdown);

View File

@@ -1,19 +1,19 @@
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
type TransactionClient = Parameters< type TransactionClient = Parameters<
Parameters<typeof prisma.$transaction>[0] Parameters<typeof prisma.$transaction>[0]
>[0] >[0];
export class LinkTracker { export class LinkTracker {
private readonly trackSuffix = "@TRACK" private readonly trackSuffix = "@TRACK";
private readonly tx: TransactionClient private readonly tx: TransactionClient;
constructor(tx: TransactionClient) { constructor(tx: TransactionClient) {
this.tx = tx this.tx = tx;
} }
private async getOrCreateTrackLink(url: string, campaignId: string) { private async getOrCreateTrackLink(url: string, campaignId: string) {
const originalUrl = url.replace(this.trackSuffix, "") const originalUrl = url.replace(this.trackSuffix, "");
try { try {
const trackedLink = await this.tx.trackedLink.upsert({ const trackedLink = await this.tx.trackedLink.upsert({
@@ -28,9 +28,9 @@ export class LinkTracker {
campaignId, campaignId,
}, },
update: {}, update: {},
}) });
return trackedLink return trackedLink;
} catch (error) { } catch (error) {
// In case of race condition, try to fetch the existing record // In case of race condition, try to fetch the existing record
return await this.tx.trackedLink.findFirstOrThrow({ return await this.tx.trackedLink.findFirstOrThrow({
@@ -38,65 +38,65 @@ export class LinkTracker {
url: originalUrl, url: originalUrl,
campaignId, campaignId,
}, },
}) });
} }
} }
private findTrackingLinks(content: string) { private findTrackingLinks(content: string) {
const regex = /https?:\/\/[^\s<>"']+@TRACK/g const regex = /https?:\/\/[^\s<>"']+@TRACK/g;
const matches = content.match(regex) const matches = content.match(regex);
if (!matches) { if (!matches) {
return [] return [];
} }
return matches return matches;
} }
async findTrackingLinksAndCreate({ async findTrackingLinksAndCreate({
content, content,
campaignId, campaignId,
}: { }: {
content: string content: string;
campaignId: string campaignId: string;
}) { }) {
const links = this.findTrackingLinks(content) const links = this.findTrackingLinks(content);
const trackingLinks = await Promise.all( const trackingLinks = await Promise.all(
links.map((link) => this.getOrCreateTrackLink(link, campaignId)) links.map((link) => this.getOrCreateTrackLink(link, campaignId)),
) );
return trackingLinks return trackingLinks;
} }
async replaceMessageContentWithTrackedLinks( async replaceMessageContentWithTrackedLinks(
content: string, content: string,
campaignId: string, campaignId: string,
baseURL: string baseURL: string,
) { ) {
const links = this.findTrackingLinks(content) const links = this.findTrackingLinks(content);
let updatedContent = content let updatedContent = content;
const trackedLinkResults = await Promise.all( const trackedLinkResults = await Promise.all(
links.map(async (link) => { links.map(async (link) => {
const trackedLink = await this.getOrCreateTrackLink(link, campaignId) const trackedLink = await this.getOrCreateTrackLink(link, campaignId);
const trackingUrl = `${baseURL}/r/${trackedLink.id}` const trackingUrl = `${baseURL}/r/${trackedLink.id}`;
return { return {
originalLink: link, originalLink: link,
trackedLinkId: trackedLink.id, trackedLinkId: trackedLink.id,
trackingUrl, trackingUrl,
} };
}) }),
) );
trackedLinkResults.forEach(({ originalLink, trackingUrl }) => { trackedLinkResults.forEach(({ originalLink, trackingUrl }) => {
updatedContent = updatedContent.replace(originalLink, trackingUrl) updatedContent = updatedContent.replace(originalLink, trackingUrl);
}) });
return { return {
content: updatedContent, content: updatedContent,
trackedIds: trackedLinkResults.map(({ trackedLinkId }) => trackedLinkId), trackedIds: trackedLinkResults.map(({ trackedLinkId }) => trackedLinkId),
} };
} }
} }

View File

@@ -1,42 +1,42 @@
import SMTPTransport from "nodemailer/lib/smtp-transport" import SMTPTransport from "nodemailer/lib/smtp-transport";
import { SmtpSettings } from "../../prisma/client" import { SmtpSettings } from "../../prisma/client";
import nodemailer from "nodemailer" import nodemailer from "nodemailer";
type SendMailOptions = { type SendMailOptions = {
from: string from: string;
to: string to: string;
subject: string subject: string;
html?: string | null html?: string | null;
text?: string | null text?: string | null;
} };
interface Envelope { interface Envelope {
from: string from: string;
to: string[] to: string[];
} }
interface SMTPResponse { interface SMTPResponse {
accepted: string[] accepted: string[];
rejected: string[] rejected: string[];
ehlo: string[] ehlo: string[];
envelopeTime: number envelopeTime: number;
messageTime: number messageTime: number;
messageSize: number messageSize: number;
response: string response: string;
envelope: Envelope envelope: Envelope;
messageId: string messageId: string;
} }
interface SendEmailResponse { interface SendEmailResponse {
success: boolean success: boolean;
from: string from: string;
messageId?: string messageId?: string;
} }
type TransportOptions = SMTPTransport | SMTPTransport.Options | string type TransportOptions = SMTPTransport | SMTPTransport.Options | string;
export class Mailer { export class Mailer {
private transporter: nodemailer.Transporter private transporter: nodemailer.Transporter;
constructor(smtpSettings: SmtpSettings) { constructor(smtpSettings: SmtpSettings) {
let transportOptions: TransportOptions = { let transportOptions: TransportOptions = {
@@ -47,7 +47,7 @@ export class Mailer {
user: smtpSettings.username, user: smtpSettings.username,
pass: smtpSettings.password, pass: smtpSettings.password,
}, },
} };
if (smtpSettings.encryption === "STARTTLS") { if (smtpSettings.encryption === "STARTTLS") {
transportOptions = { transportOptions = {
@@ -55,13 +55,13 @@ export class Mailer {
port: smtpSettings.port || 587, // Default STARTTLS port port: smtpSettings.port || 587, // Default STARTTLS port
secure: false, // Use STARTTLS secure: false, // Use STARTTLS
requireTLS: true, // Require STARTTLS upgrade requireTLS: true, // Require STARTTLS upgrade
} };
} else if (smtpSettings.encryption === "SSL_TLS") { } else if (smtpSettings.encryption === "SSL_TLS") {
transportOptions = { transportOptions = {
...transportOptions, ...transportOptions,
port: smtpSettings.port || 465, // Default SSL/TLS port port: smtpSettings.port || 465, // Default SSL/TLS port
secure: true, // Use direct TLS connection secure: true, // Use direct TLS connection
} };
} else { } else {
// NONE encryption // NONE encryption
transportOptions = { transportOptions = {
@@ -70,10 +70,10 @@ export class Mailer {
secure: false, secure: false,
requireTLS: false, // Explicitly disable TLS requirement requireTLS: false, // Explicitly disable TLS requirement
ignoreTLS: true, // Optionally ignore TLS advertised by server if needed ignoreTLS: true, // Optionally ignore TLS advertised by server if needed
} };
} }
this.transporter = nodemailer.createTransport(transportOptions) this.transporter = nodemailer.createTransport(transportOptions);
} }
async sendEmail(options: SendMailOptions): Promise<SendEmailResponse> { async sendEmail(options: SendMailOptions): Promise<SendEmailResponse> {
@@ -84,20 +84,20 @@ export class Mailer {
// TODO: Handle plain text // TODO: Handle plain text
text: options.text || undefined, text: options.text || undefined,
html: options.html || undefined, html: options.html || undefined,
}) });
let response: SendEmailResponse = { let response: SendEmailResponse = {
success: false, success: false,
messageId: result.messageId, messageId: result.messageId,
from: options.from, from: options.from,
} };
if (result.accepted.length > 0) { if (result.accepted.length > 0) {
response.success = true response.success = true;
} else if (result.rejected.length > 0) { } else if (result.rejected.length > 0) {
response.success = false response.success = false;
} }
return response return response;
} }
} }

View File

@@ -1,13 +1,13 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
const createListSchema = z.object({ const createListSchema = z.object({
name: z.string().min(1, "List name is required"), name: z.string().min(1, "List name is required"),
description: z.string().optional(), description: z.string().optional(),
organizationId: z.string(), organizationId: z.string(),
}) });
export const createList = authProcedure export const createList = authProcedure
.input(createListSchema) .input(createListSchema)
@@ -17,13 +17,13 @@ export const createList = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Organization not found", message: "Organization not found",
}) });
} }
const list = await prisma.list.create({ const list = await prisma.list.create({
@@ -38,18 +38,18 @@ export const createList = authProcedure
description: true, description: true,
createdAt: true, createdAt: true,
}, },
}) });
return { return {
list, list,
} };
}) });
const updateListSchema = z.object({ const updateListSchema = z.object({
id: z.string(), id: z.string(),
name: z.string().min(1, "List name is required"), name: z.string().min(1, "List name is required"),
description: z.string().optional(), description: z.string().optional(),
}) });
export const updateList = authProcedure export const updateList = authProcedure
.input(updateListSchema) .input(updateListSchema)
@@ -61,13 +61,13 @@ export const updateList = authProcedure
include: { include: {
Organization: true, Organization: true,
}, },
}) });
if (!list) { if (!list) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "List not found", message: "List not found",
}) });
} }
// Verify user has access to organization // Verify user has access to organization
@@ -76,13 +76,13 @@ export const updateList = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: list.organizationId, organizationId: list.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "You don't have access to this list", message: "You don't have access to this list",
}) });
} }
const updatedList = await prisma.list.update({ const updatedList = await prisma.list.update({
@@ -100,12 +100,12 @@ export const updateList = authProcedure
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
}) });
return { return {
list: updatedList, list: updatedList,
} };
}) });
export const deleteList = authProcedure export const deleteList = authProcedure
.input(z.object({ id: z.string() })) .input(z.object({ id: z.string() }))
@@ -117,13 +117,13 @@ export const deleteList = authProcedure
include: { include: {
Organization: true, Organization: true,
}, },
}) });
if (!list) { if (!list) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "List not found", message: "List not found",
}) });
} }
// Verify user has access to organization // Verify user has access to organization
@@ -132,20 +132,20 @@ export const deleteList = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: list.organizationId, organizationId: list.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "You don't have access to this list", message: "You don't have access to this list",
}) });
} }
await prisma.list.delete({ await prisma.list.delete({
where: { where: {
id: input.id, id: input.id,
}, },
}) });
return { success: true } return { success: true };
}) });

View File

@@ -1,9 +1,9 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas" import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client" import { Prisma } from "../../prisma/client";
export const getLists = authProcedure export const getLists = authProcedure
.input( .input(
@@ -11,7 +11,7 @@ export const getLists = authProcedure
.object({ .object({
organizationId: z.string(), organizationId: z.string(),
}) })
.merge(paginationSchema) .merge(paginationSchema),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
// Verify user has access to organization // Verify user has access to organization
@@ -20,13 +20,13 @@ export const getLists = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Organization not found", message: "Organization not found",
}) });
} }
const where: Prisma.ListWhereInput = { const where: Prisma.ListWhereInput = {
@@ -39,7 +39,7 @@ export const getLists = authProcedure
], ],
} }
: {}), : {}),
} };
const [total, lists] = await Promise.all([ const [total, lists] = await Promise.all([
prisma.list.count({ where }), prisma.list.count({ where }),
@@ -60,9 +60,9 @@ export const getLists = authProcedure
skip: (input.page - 1) * input.perPage, skip: (input.page - 1) * input.perPage,
take: input.perPage, take: input.perPage,
}), }),
]) ]);
const totalPages = Math.ceil(total / input.perPage) const totalPages = Math.ceil(total / input.perPage);
return { return {
lists, lists,
@@ -73,14 +73,14 @@ export const getLists = authProcedure
perPage: input.perPage, perPage: input.perPage,
hasMore: input.page < totalPages, hasMore: input.page < totalPages,
}, },
} };
}) });
export const getList = authProcedure export const getList = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const list = await prisma.list.findUnique({ const list = await prisma.list.findUnique({
@@ -95,13 +95,13 @@ export const getList = authProcedure
}, },
}, },
}, },
}) });
if (!list) { if (!list) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "List not found", message: "List not found",
}) });
} }
// Verify user has access to organization // Verify user has access to organization
@@ -110,14 +110,14 @@ export const getList = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: list.organizationId, organizationId: list.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "You don't have access to this list", message: "You don't have access to this list",
}) });
} }
return list return list;
}) });

View File

@@ -1,6 +1,6 @@
import { router } from "../trpc" import { router } from "../trpc";
import { createList, updateList, deleteList } from "./mutation" import { createList, updateList, deleteList } from "./mutation";
import { getList, getLists } from "./query" import { getList, getLists } from "./query";
export const listRouter = router({ export const listRouter = router({
create: createList, create: createList,
@@ -8,4 +8,4 @@ export const listRouter = router({
delete: deleteList, delete: deleteList,
get: getList, get: getList,
list: getLists, list: getLists,
}) });

View File

@@ -1,15 +1,15 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { MessageStatus } from "../../prisma/client" import { MessageStatus } from "../../prisma/client";
export const resendMessage = authProcedure export const resendMessage = authProcedure
.input( .input(
z.object({ z.object({
messageId: z.string(), messageId: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -17,13 +17,13 @@ export const resendMessage = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "You do not have access to this organization.", message: "You do not have access to this organization.",
}) });
} }
const message = await prisma.message.findFirst({ const message = await prisma.message.findFirst({
@@ -33,13 +33,13 @@ export const resendMessage = authProcedure
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}, },
}) });
if (!message) { if (!message) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Message not found or you don't have access.", message: "Message not found or you don't have access.",
}) });
} }
const updatedMessage = await prisma.message.update({ const updatedMessage = await prisma.message.update({
@@ -53,7 +53,7 @@ export const resendMessage = authProcedure
error: null, error: null,
messageId: null, messageId: null,
}, },
}) });
return updatedMessage return updatedMessage;
}) });

View File

@@ -1,9 +1,9 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas" import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client" import { Prisma } from "../../prisma/client";
const messageStatusEnum = z.enum([ const messageStatusEnum = z.enum([
"QUEUED", "QUEUED",
@@ -13,7 +13,7 @@ const messageStatusEnum = z.enum([
"CLICKED", "CLICKED",
"FAILED", "FAILED",
"RETRYING", "RETRYING",
]) ]);
export const listMessages = authProcedure export const listMessages = authProcedure
.input( .input(
@@ -24,7 +24,7 @@ export const listMessages = authProcedure
subscriberId: z.string().optional(), subscriberId: z.string().optional(),
status: messageStatusEnum.optional(), status: messageStatusEnum.optional(),
}) })
.merge(paginationSchema) .merge(paginationSchema),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -32,13 +32,13 @@ export const listMessages = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const where: Prisma.MessageWhereInput = { const where: Prisma.MessageWhereInput = {
@@ -78,7 +78,7 @@ export const listMessages = authProcedure
], ],
} }
: {}), : {}),
} };
const [total, messages] = await Promise.all([ const [total, messages] = await Promise.all([
prisma.message.count({ where }), prisma.message.count({ where }),
@@ -103,9 +103,9 @@ export const listMessages = authProcedure
}, },
}, },
}), }),
]) ]);
const totalPages = Math.ceil(total / input.perPage) const totalPages = Math.ceil(total / input.perPage);
return { return {
messages, messages,
@@ -116,14 +116,14 @@ export const listMessages = authProcedure
perPage: input.perPage, perPage: input.perPage,
hasMore: input.page < totalPages, hasMore: input.page < totalPages,
}, },
} };
}) });
export const getMessage = authProcedure export const getMessage = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
}) }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const message = await prisma.message.findUnique({ const message = await prisma.message.findUnique({
@@ -146,14 +146,14 @@ export const getMessage = authProcedure
}, },
}, },
}, },
}) });
if (!message) { if (!message) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Message not found", message: "Message not found",
}) });
} }
return message return message;
}) });

View File

@@ -1,9 +1,9 @@
import { router } from "../trpc" import { router } from "../trpc";
import { listMessages, getMessage } from "./query" import { listMessages, getMessage } from "./query";
import { resendMessage } from "./mutation" import { resendMessage } from "./mutation";
export const messageRouter = router({ export const messageRouter = router({
list: listMessages, list: listMessages,
get: getMessage, get: getMessage,
resend: resendMessage, resend: resendMessage,
}) });

View File

@@ -1,10 +1,10 @@
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { MessageStatus } from "../../prisma/client" import { MessageStatus } from "../../prisma/client";
interface MessageQueryOptions { interface MessageQueryOptions {
campaignId?: string campaignId?: string;
organizationId: string organizationId: string;
status: MessageStatus | MessageStatus[] status: MessageStatus | MessageStatus[];
} }
export async function findMessagesByStatus({ export async function findMessagesByStatus({
@@ -20,12 +20,12 @@ export async function findMessagesByStatus({
}, },
status: Array.isArray(status) ? { in: status } : status, status: Array.isArray(status) ? { in: status } : status,
}, },
}) });
} }
interface CampaignMessagesQueryOptions { interface CampaignMessagesQueryOptions {
campaignId: string campaignId: string;
organizationId: string organizationId: string;
} }
export async function getDeliveredMessages({ export async function getDeliveredMessages({
@@ -36,7 +36,7 @@ export async function getDeliveredMessages({
campaignId, campaignId,
organizationId, organizationId,
status: ["SENT", "CLICKED", "OPENED"], status: ["SENT", "CLICKED", "OPENED"],
}) });
} }
export async function getFailedMessages({ export async function getFailedMessages({
@@ -47,7 +47,7 @@ export async function getFailedMessages({
campaignId, campaignId,
organizationId, organizationId,
status: "FAILED", status: "FAILED",
}) });
} }
export async function getOpenedMessages({ export async function getOpenedMessages({
@@ -58,7 +58,7 @@ export async function getOpenedMessages({
campaignId, campaignId,
organizationId, organizationId,
status: ["OPENED", "CLICKED"], // Clicked implies opened status: ["OPENED", "CLICKED"], // Clicked implies opened
}) });
} }
export async function getClickedMessages({ export async function getClickedMessages({
@@ -69,7 +69,7 @@ export async function getClickedMessages({
campaignId, campaignId,
organizationId, organizationId,
status: "CLICKED", status: "CLICKED",
}) });
} }
export async function getQueuedMessages({ export async function getQueuedMessages({
@@ -80,5 +80,5 @@ export async function getQueuedMessages({
campaignId, campaignId,
organizationId, organizationId,
status: "QUEUED", status: "QUEUED",
}) });
} }

View File

@@ -1,13 +1,13 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import fs from "fs/promises" import fs from "fs/promises";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
const createOrganizationSchema = z.object({ const createOrganizationSchema = z.object({
name: z.string().min(1, "Organization name is required"), name: z.string().min(1, "Organization name is required"),
description: z.string().optional(), description: z.string().optional(),
}) });
export const createOrganization = authProcedure export const createOrganization = authProcedure
.input(createOrganizationSchema) .input(createOrganizationSchema)
@@ -28,7 +28,7 @@ export const createOrganization = authProcedure
name: "Newsletter", name: "Newsletter",
content: await fs.readFile( content: await fs.readFile(
"templates/newsletter.html", "templates/newsletter.html",
"utf-8" "utf-8",
), ),
}, },
], ],
@@ -49,18 +49,18 @@ export const createOrganization = authProcedure
description: true, description: true,
createdAt: true, createdAt: true,
}, },
}) });
return { return {
organization, organization,
} };
}) });
const updateOrganizationSchema = z.object({ const updateOrganizationSchema = z.object({
id: z.string(), id: z.string(),
name: z.string().min(1, "Organization name is required"), name: z.string().min(1, "Organization name is required"),
description: z.string().optional(), description: z.string().optional(),
}) });
export const updateOrganization = authProcedure export const updateOrganization = authProcedure
.input(updateOrganizationSchema) .input(updateOrganizationSchema)
@@ -70,13 +70,13 @@ export const updateOrganization = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.id, organizationId: input.id,
}, },
}) });
if (!userOrg) { if (!userOrg) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "You do not have access to update this organization.", message: "You do not have access to update this organization.",
}) });
} }
const updatedOrganization = await prisma.organization.update({ const updatedOrganization = await prisma.organization.update({
@@ -92,7 +92,7 @@ export const updateOrganization = authProcedure
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
}) });
return { organization: updatedOrganization } return { organization: updatedOrganization };
}) });

View File

@@ -1,13 +1,13 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
export const getOrganizationById = authProcedure export const getOrganizationById = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrg = await prisma.userOrganization.findFirst({ const userOrg = await prisma.userOrganization.findFirst({
@@ -15,13 +15,13 @@ export const getOrganizationById = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.id, organizationId: input.id,
}, },
}) });
if (!userOrg) { if (!userOrg) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "You do not have access to this organization.", message: "You do not have access to this organization.",
}) });
} }
const organization = await prisma.organization.findUnique({ const organization = await prisma.organization.findUnique({
@@ -33,14 +33,14 @@ export const getOrganizationById = authProcedure
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
}, },
}) });
if (!organization) { if (!organization) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Organization not found.", message: "Organization not found.",
}) });
} }
return organization return organization;
}) });

View File

@@ -1,9 +1,9 @@
import { router } from "../trpc" import { router } from "../trpc";
import { createOrganization, updateOrganization } from "./mutation" import { createOrganization, updateOrganization } from "./mutation";
import { getOrganizationById } from "./query" import { getOrganizationById } from "./query";
export const organizationRouter = router({ export const organizationRouter = router({
create: createOrganization, create: createOrganization,
update: updateOrganization, update: updateOrganization,
getById: getOrganizationById, getById: getOrganizationById,
}) });

View File

@@ -1,9 +1,9 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { randomBytes } from "crypto" import { randomBytes } from "crypto";
import { Mailer } from "../lib/Mailer" import { Mailer } from "../lib/Mailer";
const smtpSchema = z.object({ const smtpSchema = z.object({
organizationId: z.string(), organizationId: z.string(),
@@ -15,7 +15,7 @@ const smtpSchema = z.object({
fromName: z.string().optional(), fromName: z.string().optional(),
secure: z.boolean(), secure: z.boolean(),
encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]), encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]),
}) });
export const updateSmtp = authProcedure export const updateSmtp = authProcedure
.input(smtpSchema) .input(smtpSchema)
@@ -25,13 +25,13 @@ export const updateSmtp = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const smtpSettings = await prisma.smtpSettings.findFirst({ const smtpSettings = await prisma.smtpSettings.findFirst({
@@ -45,7 +45,7 @@ export const updateSmtp = authProcedure
}, },
}, },
}, },
}) });
const settings = await prisma.smtpSettings.upsert({ const settings = await prisma.smtpSettings.upsert({
where: { where: {
@@ -72,10 +72,10 @@ export const updateSmtp = authProcedure
secure: input.secure, secure: input.secure,
encryption: input.encryption, encryption: input.encryption,
}, },
}) });
return { settings } return { settings };
}) });
const emailDeliverySchema = z.object({ const emailDeliverySchema = z.object({
organizationId: z.string(), organizationId: z.string(),
@@ -85,7 +85,7 @@ const emailDeliverySchema = z.object({
retryDelay: z.number().min(1, "Retry delay is required"), retryDelay: z.number().min(1, "Retry delay is required"),
concurrency: z.number().min(1, "Concurrency must be at least 1"), concurrency: z.number().min(1, "Concurrency must be at least 1"),
connectionTimeout: z.number().min(1, "Connection timeout must be at least 1"), connectionTimeout: z.number().min(1, "Connection timeout must be at least 1"),
}) });
export const updateEmailDelivery = authProcedure export const updateEmailDelivery = authProcedure
.input(emailDeliverySchema) .input(emailDeliverySchema)
@@ -95,13 +95,13 @@ export const updateEmailDelivery = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const settings = await prisma.emailDeliverySettings.upsert({ const settings = await prisma.emailDeliverySettings.upsert({
@@ -125,10 +125,10 @@ export const updateEmailDelivery = authProcedure
concurrency: input.concurrency, concurrency: input.concurrency,
connectionTimeout: input.connectionTimeout, connectionTimeout: input.connectionTimeout,
}, },
}) });
return { settings } return { settings };
}) });
const generalSettingsSchema = z.object({ const generalSettingsSchema = z.object({
organizationId: z.string(), organizationId: z.string(),
@@ -136,7 +136,7 @@ const generalSettingsSchema = z.object({
defaultFromName: z.string().optional(), defaultFromName: z.string().optional(),
baseURL: z.string().url().optional().or(z.literal("")), baseURL: z.string().url().optional().or(z.literal("")),
cleanupInterval: z.coerce.number().int().min(1).optional(), cleanupInterval: z.coerce.number().int().min(1).optional(),
}) });
export const updateGeneral = authProcedure export const updateGeneral = authProcedure
.input(generalSettingsSchema) .input(generalSettingsSchema)
@@ -146,13 +146,13 @@ export const updateGeneral = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const settings = await prisma.generalSettings.upsert({ const settings = await prisma.generalSettings.upsert({
@@ -172,16 +172,16 @@ export const updateGeneral = authProcedure
baseURL: input.baseURL, baseURL: input.baseURL,
cleanupInterval: input.cleanupInterval, cleanupInterval: input.cleanupInterval,
}, },
}) });
return { settings } return { settings };
}) });
const createApiKeySchema = z.object({ const createApiKeySchema = z.object({
organizationId: z.string(), organizationId: z.string(),
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
expiresAt: z.string().optional(), expiresAt: z.string().optional(),
}) });
export const createApiKey = authProcedure export const createApiKey = authProcedure
.input(createApiKeySchema) .input(createApiKeySchema)
@@ -191,19 +191,19 @@ export const createApiKey = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
let key = `sk_${randomBytes(32).toString("hex")}` let key = `sk_${randomBytes(32).toString("hex")}`;
while (await prisma.apiKey.findUnique({ where: { key } })) { while (await prisma.apiKey.findUnique({ where: { key } })) {
key = `sk_${randomBytes(32).toString("hex")}` key = `sk_${randomBytes(32).toString("hex")}`;
} }
const apiKey = await prisma.apiKey.create({ const apiKey = await prisma.apiKey.create({
@@ -217,15 +217,15 @@ export const createApiKey = authProcedure
id: true, id: true,
key: true, key: true,
}, },
}) });
return apiKey return apiKey;
}) });
const deleteApiKeySchema = z.object({ const deleteApiKeySchema = z.object({
organizationId: z.string(), organizationId: z.string(),
id: z.string(), id: z.string(),
}) });
export const deleteApiKey = authProcedure export const deleteApiKey = authProcedure
.input(deleteApiKeySchema) .input(deleteApiKeySchema)
@@ -235,13 +235,13 @@ export const deleteApiKey = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
await prisma.apiKey.delete({ await prisma.apiKey.delete({
@@ -249,10 +249,10 @@ export const deleteApiKey = authProcedure
id: input.id, id: input.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
return { success: true } return { success: true };
}) });
const createWebhookSchema = z.object({ const createWebhookSchema = z.object({
organizationId: z.string(), organizationId: z.string(),
@@ -261,7 +261,7 @@ const createWebhookSchema = z.object({
events: z.array(z.string()).min(1, "At least one event must be selected"), events: z.array(z.string()).min(1, "At least one event must be selected"),
isActive: z.boolean(), isActive: z.boolean(),
secret: z.string().min(1, "Secret is required"), secret: z.string().min(1, "Secret is required"),
}) });
export const createWebhook = authProcedure export const createWebhook = authProcedure
.input(createWebhookSchema) .input(createWebhookSchema)
@@ -271,13 +271,13 @@ export const createWebhook = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
// const webhook = await prisma.webhook.create({ // const webhook = await prisma.webhook.create({
@@ -292,15 +292,15 @@ export const createWebhook = authProcedure
// }) // })
// TODO: Implement webhook creation // TODO: Implement webhook creation
return { webhook: null } return { webhook: null };
}) });
export const deleteWebhook = authProcedure export const deleteWebhook = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -308,42 +308,42 @@ export const deleteWebhook = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
// TODO: Implement webhook deletion // TODO: Implement webhook deletion
return { success: true } return { success: true };
}) });
export const testSmtp = authProcedure export const testSmtp = authProcedure
.input( .input(
z.object({ z.object({
email: z.string().email(), email: z.string().email(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const settings = await prisma.smtpSettings.findFirst({ const settings = await prisma.smtpSettings.findFirst({
where: { where: {
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!settings) { if (!settings) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: message:
"SMTP settings not found. Please configure your SMTP settings first.", "SMTP settings not found. Please configure your SMTP settings first.",
}) });
} }
const APP_NAME = "LetterSpace" const APP_NAME = "LetterSpace";
const testTemplate = ` const testTemplate = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;"> <div style="font-family: sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
@@ -357,23 +357,23 @@ export const testSmtp = authProcedure
</p> </p>
</div> </div>
</div> </div>
` `;
const mailer = new Mailer(settings) const mailer = new Mailer(settings);
const result = await mailer.sendEmail({ const result = await mailer.sendEmail({
to: input.email, to: input.email,
subject: "SMTP Configuration Test", subject: "SMTP Configuration Test",
html: testTemplate, html: testTemplate,
from: `${settings.fromName} <${settings.fromEmail}>`, from: `${settings.fromName} <${settings.fromEmail}>`,
}) });
if (!result.success) { if (!result.success) {
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to send test email", message: "Failed to send test email",
}) });
} }
return { success: true } return { success: true };
}) });

View File

@@ -1,13 +1,13 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
export const getSmtp = authProcedure export const getSmtp = authProcedure
.input( .input(
z.object({ z.object({
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -15,13 +15,13 @@ export const getSmtp = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const settings = await prisma.smtpSettings.findFirst({ const settings = await prisma.smtpSettings.findFirst({
@@ -35,16 +35,16 @@ export const getSmtp = authProcedure
}, },
}, },
}, },
}) });
return settings return settings;
}) });
export const getGeneral = authProcedure export const getGeneral = authProcedure
.input( .input(
z.object({ z.object({
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -52,29 +52,29 @@ export const getGeneral = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const settings = await prisma.generalSettings.findUnique({ const settings = await prisma.generalSettings.findUnique({
where: { where: {
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
return settings return settings;
}) });
export const listApiKeys = authProcedure export const listApiKeys = authProcedure
.input( .input(
z.object({ z.object({
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -82,13 +82,13 @@ export const listApiKeys = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const apiKeys = await prisma.apiKey.findMany({ const apiKeys = await prisma.apiKey.findMany({
@@ -103,16 +103,16 @@ export const listApiKeys = authProcedure
createdAt: true, createdAt: true,
}, },
orderBy: [{ createdAt: "desc" }, { id: "desc" }], orderBy: [{ createdAt: "desc" }, { id: "desc" }],
}) });
return apiKeys return apiKeys;
}) });
export const listWebhooks = authProcedure export const listWebhooks = authProcedure
.input( .input(
z.object({ z.object({
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -120,17 +120,17 @@ export const listWebhooks = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
// TODO: Implement later // TODO: Implement later
return [] return [];
// const webhooks = await prisma.webhook.findMany({ // const webhooks = await prisma.webhook.findMany({
// where: { // where: {
// organizationId: input.organizationId, // organizationId: input.organizationId,
@@ -141,13 +141,13 @@ export const listWebhooks = authProcedure
// }) // })
// return webhooks // return webhooks
}) });
export const getEmailDelivery = authProcedure export const getEmailDelivery = authProcedure
.input( .input(
z.object({ z.object({
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -155,20 +155,20 @@ export const getEmailDelivery = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const settings = await prisma.emailDeliverySettings.findUnique({ const settings = await prisma.emailDeliverySettings.findUnique({
where: { where: {
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
return settings return settings;
}) });

View File

@@ -1,11 +1,11 @@
import { router } from "../trpc" import { router } from "../trpc";
import { import {
getSmtp, getSmtp,
getGeneral, getGeneral,
listApiKeys, listApiKeys,
listWebhooks, listWebhooks,
getEmailDelivery, getEmailDelivery,
} from "./query" } from "./query";
import { import {
updateSmtp, updateSmtp,
testSmtp, testSmtp,
@@ -15,7 +15,7 @@ import {
createWebhook, createWebhook,
deleteWebhook, deleteWebhook,
updateEmailDelivery, updateEmailDelivery,
} from "./mutation" } from "./mutation";
export const settingsRouter = router({ export const settingsRouter = router({
getSmtp: getSmtp, getSmtp: getSmtp,
@@ -34,4 +34,4 @@ export const settingsRouter = router({
listWebhooks: listWebhooks, listWebhooks: listWebhooks,
getEmailDelivery: getEmailDelivery, getEmailDelivery: getEmailDelivery,
updateEmailDelivery: updateEmailDelivery, updateEmailDelivery: updateEmailDelivery,
}) });

View File

@@ -1,18 +1,18 @@
import dayjs from "dayjs" import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime" import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime) dayjs.extend(relativeTime);
// TODO: move this to a new package named "shared" // TODO: move this to a new package named "shared"
export function displayDate(date: Date) { export function displayDate(date: Date) {
const dateObj = dayjs(date) const dateObj = dayjs(date);
const daysFromNow = dateObj.diff(dayjs(), "day") const daysFromNow = dateObj.diff(dayjs(), "day");
if (daysFromNow > 7) { if (daysFromNow > 7) {
return dateObj.format("DD MMM YYYY") return dateObj.format("DD MMM YYYY");
} }
return dateObj.fromNow() return dateObj.fromNow();
} }

View File

@@ -1,32 +1,32 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { subDays } from "date-fns" import { subDays } from "date-fns";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { resolveProps } from "../utils/pProps" import { resolveProps } from "../utils/pProps";
import { import {
countDistinctRecipients, countDistinctRecipients,
countDistinctRecipientsInTimeRange, countDistinctRecipientsInTimeRange,
} from "../../prisma/client/sql" } from "../../prisma/client/sql";
import { MessageStatus } from "../../prisma/client" import { MessageStatus } from "../../prisma/client";
export const getStats = authProcedure export const getStats = authProcedure
.input( .input(
z.object({ z.object({
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const now = new Date() const now = new Date();
const thirtyDaysAgo = subDays(now, 30) const thirtyDaysAgo = subDays(now, 30);
const sixtyDaysAgo = subDays(now, 60) const sixtyDaysAgo = subDays(now, 60);
const processedMessageStatuses: MessageStatus[] = [ const processedMessageStatuses: MessageStatus[] = [
"SENT", "SENT",
"CLICKED", "CLICKED",
"OPENED", "OPENED",
"FAILED", "FAILED",
] ];
// Check auth // Check auth
const hasAccess = await prisma.userOrganization.findFirst({ const hasAccess = await prisma.userOrganization.findFirst({
@@ -34,13 +34,13 @@ export const getStats = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!hasAccess) { if (!hasAccess) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "You do not have access to this organization", message: "You do not have access to this organization",
}) });
} }
// We need to get this first for calculating the other stats // We need to get this first for calculating the other stats
@@ -78,7 +78,7 @@ export const getStats = authProcedure
status: { in: processedMessageStatuses }, status: { in: processedMessageStatuses },
}, },
}), }),
]) ]);
const promises = { const promises = {
allTimeSubscribers: prisma.subscriber.count({ allTimeSubscribers: prisma.subscriber.count({
@@ -109,9 +109,9 @@ export const getStats = authProcedure
lt: now, lt: now,
}, },
}, },
}) });
return openedMessages / (totalMessagesLast30Days || 1) return openedMessages / (totalMessagesLast30Days || 1);
})(), })(),
openRateLastMonth: (async () => { openRateLastMonth: (async () => {
const openedMessages = await prisma.message.count({ const openedMessages = await prisma.message.count({
@@ -127,9 +127,9 @@ export const getStats = authProcedure
lt: thirtyDaysAgo, lt: thirtyDaysAgo,
}, },
}, },
}) });
return openedMessages / (totalMessagesLastPeriod || 1) return openedMessages / (totalMessagesLastPeriod || 1);
})(), })(),
unsubscribedThisMonth: prisma.listSubscriber.count({ unsubscribedThisMonth: prisma.listSubscriber.count({
where: { where: {
@@ -218,12 +218,12 @@ export const getStats = authProcedure
lt: now, lt: now,
}, },
}, },
}) });
return { return {
delivered: deliveredMessages, delivered: deliveredMessages,
rate: deliveredMessages / (totalMessagesLast30Days || 1), rate: deliveredMessages / (totalMessagesLast30Days || 1),
} };
})(), })(),
deliveryRateLastMonth: (async () => { deliveryRateLastMonth: (async () => {
const deliveredMessages = await prisma.message.count({ const deliveredMessages = await prisma.message.count({
@@ -239,12 +239,12 @@ export const getStats = authProcedure
lt: thirtyDaysAgo, lt: thirtyDaysAgo,
}, },
}, },
}) });
return { return {
delivered: deliveredMessages, delivered: deliveredMessages,
rate: deliveredMessages / (totalMessagesLastPeriod || 1), rate: deliveredMessages / (totalMessagesLastPeriod || 1),
} };
})(), })(),
clickRateThisMonth: (async () => { clickRateThisMonth: (async () => {
const clickedMessages = await prisma.message.count({ const clickedMessages = await prisma.message.count({
@@ -258,12 +258,12 @@ export const getStats = authProcedure
lt: now, lt: now,
}, },
}, },
}) });
return { return {
clicked: clickedMessages, clicked: clickedMessages,
rate: clickedMessages / (totalMessagesLast30Days || 1), rate: clickedMessages / (totalMessagesLast30Days || 1),
} };
})(), })(),
clickRateLastMonth: (async () => { clickRateLastMonth: (async () => {
const clickedMessages = await prisma.message.count({ const clickedMessages = await prisma.message.count({
@@ -277,33 +277,33 @@ export const getStats = authProcedure
lt: thirtyDaysAgo, lt: thirtyDaysAgo,
}, },
}, },
}) });
return { return {
clicked: clickedMessages, clicked: clickedMessages,
rate: clickedMessages / (totalMessagesLastPeriod || 1), rate: clickedMessages / (totalMessagesLastPeriod || 1),
} };
})(), })(),
recipients: prisma.$queryRawTyped( recipients: prisma.$queryRawTyped(
countDistinctRecipients(input.organizationId) countDistinctRecipients(input.organizationId),
), ),
recipientsThisMonth: prisma.$queryRawTyped( recipientsThisMonth: prisma.$queryRawTyped(
countDistinctRecipientsInTimeRange( countDistinctRecipientsInTimeRange(
input.organizationId, input.organizationId,
thirtyDaysAgo, thirtyDaysAgo,
now now,
) ),
), ),
recipientsLastMonth: prisma.$queryRawTyped( recipientsLastMonth: prisma.$queryRawTyped(
countDistinctRecipientsInTimeRange( countDistinctRecipientsInTimeRange(
input.organizationId, input.organizationId,
sixtyDaysAgo, sixtyDaysAgo,
thirtyDaysAgo thirtyDaysAgo,
)
), ),
} ),
};
const result = await resolveProps(promises) const result = await resolveProps(promises);
const data = { const data = {
campaigns: { campaigns: {
@@ -373,7 +373,7 @@ export const getStats = authProcedure
lastMonth: result.unsubscribedLastMonth, lastMonth: result.unsubscribedLastMonth,
comparison: result.unsubscribedThisMonth - result.unsubscribedLastMonth, comparison: result.unsubscribedThisMonth - result.unsubscribedLastMonth,
}, },
} };
return data return data;
}) });

View File

@@ -1,6 +1,6 @@
import { router } from "../trpc" import { router } from "../trpc";
import { getStats } from "./query" import { getStats } from "./query";
export const statsRouter = router({ export const statsRouter = router({
getStats: getStats, getStats: getStats,
}) });

View File

@@ -1,9 +1,9 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure, publicProcedure } from "../trpc" import { authProcedure, publicProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { parse } from "csv-parse" import { parse } from "csv-parse";
import { Readable } from "stream" import { Readable } from "stream";
const createSubscriberSchema = z.object({ const createSubscriberSchema = z.object({
email: z.string().email("Invalid email address"), email: z.string().email("Invalid email address"),
@@ -16,10 +16,10 @@ const createSubscriberSchema = z.object({
z.object({ z.object({
key: z.string().min(1).max(64), key: z.string().min(1).max(64),
value: z.string().min(1).max(256), value: z.string().min(1).max(256),
}) }),
) )
.optional(), .optional(),
}) });
export const createSubscriber = authProcedure export const createSubscriber = authProcedure
.input(createSubscriberSchema) .input(createSubscriberSchema)
@@ -29,13 +29,13 @@ export const createSubscriber = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const existingSubscriber = await prisma.subscriber.findFirst({ const existingSubscriber = await prisma.subscriber.findFirst({
@@ -43,13 +43,13 @@ export const createSubscriber = authProcedure
email: input.email, email: input.email,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (existingSubscriber) { if (existingSubscriber) {
throw new TRPCError({ throw new TRPCError({
code: "CONFLICT", code: "CONFLICT",
message: "Subscriber with this email already exists", message: "Subscriber with this email already exists",
}) });
} }
const subscriber = await prisma.subscriber.create({ const subscriber = await prisma.subscriber.create({
@@ -76,10 +76,10 @@ export const createSubscriber = authProcedure
} }
: undefined, : undefined,
}, },
}) });
return { subscriber } return { subscriber };
}) });
const updateSubscriberSchema = z.object({ const updateSubscriberSchema = z.object({
id: z.string(), id: z.string(),
@@ -93,10 +93,10 @@ const updateSubscriberSchema = z.object({
z.object({ z.object({
key: z.string().min(1), key: z.string().min(1),
value: z.string().min(1), value: z.string().min(1),
}) }),
) )
.optional(), .optional(),
}) });
export const updateSubscriber = authProcedure export const updateSubscriber = authProcedure
.input(updateSubscriberSchema) .input(updateSubscriberSchema)
@@ -106,13 +106,13 @@ export const updateSubscriber = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const subscriber = await prisma.subscriber.findFirst({ const subscriber = await prisma.subscriber.findFirst({
@@ -123,25 +123,25 @@ export const updateSubscriber = authProcedure
include: { include: {
ListSubscribers: true, ListSubscribers: true,
}, },
}) });
if (!subscriber) { if (!subscriber) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Subscriber not found", message: "Subscriber not found",
}) });
} }
// Get current list IDs // Get current list IDs
const currentListIds = subscriber.ListSubscribers.map((ls) => ls.listId) const currentListIds = subscriber.ListSubscribers.map((ls) => ls.listId);
// Find lists to add and remove // Find lists to add and remove
const listsToAdd = input.listIds.filter( const listsToAdd = input.listIds.filter(
(id) => !currentListIds.includes(id) (id) => !currentListIds.includes(id),
) );
const listsToRemove = currentListIds.filter( const listsToRemove = currentListIds.filter(
(id) => !input.listIds.includes(id) (id) => !input.listIds.includes(id),
) );
const updatedSubscriber = await prisma.subscriber.update({ const updatedSubscriber = await prisma.subscriber.update({
where: { id: input.id }, where: { id: input.id },
@@ -176,17 +176,17 @@ export const updateSubscriber = authProcedure
}, },
}, },
}, },
}) });
return { subscriber: updatedSubscriber } return { subscriber: updatedSubscriber };
}) });
export const deleteSubscriber = authProcedure export const deleteSubscriber = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -194,13 +194,13 @@ export const deleteSubscriber = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const subscriber = await prisma.subscriber.findFirst({ const subscriber = await prisma.subscriber.findFirst({
@@ -208,21 +208,21 @@ export const deleteSubscriber = authProcedure
id: input.id, id: input.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!subscriber) { if (!subscriber) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Subscriber not found", message: "Subscriber not found",
}) });
} }
await prisma.subscriber.delete({ await prisma.subscriber.delete({
where: { id: input.id }, where: { id: input.id },
}) });
return { success: true } return { success: true };
}) });
export const importSubscribers = authProcedure export const importSubscribers = authProcedure
.input( .input(
@@ -230,41 +230,41 @@ export const importSubscribers = authProcedure
file: z.instanceof(FormData), file: z.instanceof(FormData),
organizationId: z.string(), organizationId: z.string(),
listId: z.string().optional(), listId: z.string().optional(),
}) }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const file = input.file.get("file") as File const file = input.file.get("file") as File;
if (!file) { if (!file) {
throw new Error("No file provided") throw new Error("No file provided");
} }
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer());
const records: any[] = [] const records: any[] = [];
// Parse CSV // Parse CSV
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const parser = parse({ const parser = parse({
columns: true, columns: true,
skip_empty_lines: true, skip_empty_lines: true,
}) });
parser.on("readable", function () { parser.on("readable", function () {
let record let record;
while ((record = parser.read()) !== null) { while ((record = parser.read()) !== null) {
records.push(record) records.push(record);
} }
}) });
parser.on("error", function (err) { parser.on("error", function (err) {
reject(err) reject(err);
}) });
parser.on("end", function () { parser.on("end", function () {
resolve(undefined) resolve(undefined);
}) });
Readable.from(buffer).pipe(parser) Readable.from(buffer).pipe(parser);
}) });
// Validate and transform records // Validate and transform records
const subscribers = records.map((record) => ({ const subscribers = records.map((record) => ({
@@ -283,7 +283,7 @@ export const importSubscribers = authProcedure
? record.tags.split(",").map((t: string) => t.trim()) ? record.tags.split(",").map((t: string) => t.trim())
: [], : [],
organizationId: input.organizationId, organizationId: input.organizationId,
})) }));
// Import subscribers // Import subscribers
const result = await prisma.$transaction(async (tx) => { const result = await prisma.$transaction(async (tx) => {
@@ -298,7 +298,7 @@ export const importSubscribers = authProcedure
}, },
create: sub, create: sub,
update: sub, update: sub,
}) });
if (input.listId) { if (input.listId) {
await tx.listSubscriber.upsert({ await tx.listSubscriber.upsert({
@@ -313,27 +313,27 @@ export const importSubscribers = authProcedure
subscriberId: subscriber.id, subscriberId: subscriber.id,
}, },
update: {}, update: {},
}) });
} }
return subscriber return subscriber;
}) }),
) );
return imported return imported;
}) });
return { return {
count: result.length, count: result.length,
} };
}) });
export const unsubscribeToggle = authProcedure export const unsubscribeToggle = authProcedure
.input( .input(
z.object({ z.object({
listSubscriberId: z.string(), listSubscriberId: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const org = await prisma.userOrganization.findFirst({ const org = await prisma.userOrganization.findFirst({
@@ -341,13 +341,13 @@ export const unsubscribeToggle = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!org) { if (!org) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const listSubscriber = await prisma.listSubscriber.findFirst({ const listSubscriber = await prisma.listSubscriber.findFirst({
@@ -357,13 +357,13 @@ export const unsubscribeToggle = authProcedure
organizationId: org.organizationId, organizationId: org.organizationId,
}, },
}, },
}) });
if (!listSubscriber) { if (!listSubscriber) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "List subscriber not found", message: "List subscriber not found",
}) });
} }
const updated = await prisma.listSubscriber.update({ const updated = await prisma.listSubscriber.update({
@@ -371,13 +371,13 @@ export const unsubscribeToggle = authProcedure
data: { data: {
unsubscribedAt: listSubscriber.unsubscribedAt ? null : new Date(), unsubscribedAt: listSubscriber.unsubscribedAt ? null : new Date(),
}, },
}) });
return { return {
success: true, success: true,
subbed: !updated.unsubscribedAt, subbed: !updated.unsubscribedAt,
} };
}) });
export const publicUnsubscribe = publicProcedure export const publicUnsubscribe = publicProcedure
.input(z.object({ sid: z.string(), cid: z.string() })) .input(z.object({ sid: z.string(), cid: z.string() }))
@@ -395,12 +395,12 @@ export const publicUnsubscribe = publicProcedure
}, },
unsubscribedAt: null, unsubscribedAt: null,
}, },
}) });
if (!listSubscribers.length) { if (!listSubscribers.length) {
return { return {
success: true, success: true,
} };
} }
await prisma.listSubscriber.updateMany({ await prisma.listSubscriber.updateMany({
@@ -412,35 +412,35 @@ export const publicUnsubscribe = publicProcedure
data: { data: {
unsubscribedAt: new Date(), unsubscribedAt: new Date(),
}, },
}) });
await prisma.campaign await prisma.campaign
.update({ .update({
where: { id: input.cid }, where: { id: input.cid },
data: { unsubscribedCount: { increment: 1 } }, data: { unsubscribedCount: { increment: 1 } },
}) })
.catch(() => {}) .catch(() => {});
return { return {
success: true, success: true,
} };
} catch (error) { } catch (error) {
if (error instanceof TRPCError) { if (error instanceof TRPCError) {
throw error throw error;
} }
throw new TRPCError({ throw new TRPCError({
code: "INTERNAL_SERVER_ERROR", code: "INTERNAL_SERVER_ERROR",
message: "Failed to unsubscribe", message: "Failed to unsubscribe",
}) });
} }
}) });
export const verifyEmail = publicProcedure export const verifyEmail = publicProcedure
.input( .input(
z.object({ z.object({
token: z.string(), token: z.string(),
}) }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const subscriber = await prisma.subscriber.findFirst({ const subscriber = await prisma.subscriber.findFirst({
@@ -450,13 +450,13 @@ export const verifyEmail = publicProcedure
gt: new Date(), gt: new Date(),
}, },
}, },
}) });
if (!subscriber) { if (!subscriber) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Invalid or expired verification token", message: "Invalid or expired verification token",
}) });
} }
await prisma.subscriber.update({ await prisma.subscriber.update({
@@ -466,7 +466,7 @@ export const verifyEmail = publicProcedure
emailVerificationToken: null, emailVerificationToken: null,
emailVerificationTokenExpiresAt: null, emailVerificationTokenExpiresAt: null,
}, },
}) });
return { success: true } return { success: true };
}) });

View File

@@ -1,10 +1,10 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas" import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client" import { Prisma } from "../../prisma/client";
import { resolveProps } from "../utils/pProps" import { resolveProps } from "../utils/pProps";
export const listSubscribers = authProcedure export const listSubscribers = authProcedure
.input(z.object({ organizationId: z.string() }).merge(paginationSchema)) .input(z.object({ organizationId: z.string() }).merge(paginationSchema))
@@ -14,13 +14,13 @@ export const listSubscribers = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const where: Prisma.SubscriberWhereInput = { const where: Prisma.SubscriberWhereInput = {
@@ -33,7 +33,7 @@ export const listSubscribers = authProcedure
], ],
} }
: {}), : {}),
} };
const promises = { const promises = {
subscribersList: prisma.subscriber.findMany({ subscribersList: prisma.subscriber.findMany({
@@ -61,11 +61,11 @@ export const listSubscribers = authProcedure
}, },
}), }),
totalItems: prisma.subscriber.count({ where }), totalItems: prisma.subscriber.count({ where }),
} };
const result = await resolveProps(promises) const result = await resolveProps(promises);
const totalPages = Math.ceil(result.totalItems / input.perPage) const totalPages = Math.ceil(result.totalItems / input.perPage);
return { return {
subscribers: result.subscribersList, subscribers: result.subscribersList,
@@ -76,15 +76,15 @@ export const listSubscribers = authProcedure
perPage: input.perPage, perPage: input.perPage,
hasMore: input.page < totalPages, hasMore: input.page < totalPages,
}, },
} };
}) });
export const getSubscriber = authProcedure export const getSubscriber = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -92,13 +92,13 @@ export const getSubscriber = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const subscriber = await prisma.subscriber.findFirst({ const subscriber = await prisma.subscriber.findFirst({
@@ -119,14 +119,14 @@ export const getSubscriber = authProcedure
Metadata: true, Metadata: true,
}, },
orderBy: [{ createdAt: "desc" }, { id: "desc" }], orderBy: [{ createdAt: "desc" }, { id: "desc" }],
}) });
if (!subscriber) { if (!subscriber) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Subscriber not found", message: "Subscriber not found",
}) });
} }
return subscriber return subscriber;
}) });

View File

@@ -1,4 +1,4 @@
import { router } from "../trpc" import { router } from "../trpc";
import { import {
createSubscriber, createSubscriber,
updateSubscriber, updateSubscriber,
@@ -7,8 +7,8 @@ import {
publicUnsubscribe, publicUnsubscribe,
unsubscribeToggle, unsubscribeToggle,
verifyEmail, verifyEmail,
} from "./mutation" } from "./mutation";
import { getSubscriber, listSubscribers } from "./query" import { getSubscriber, listSubscribers } from "./query";
export const subscriberRouter = router({ export const subscriberRouter = router({
create: createSubscriber, create: createSubscriber,
@@ -20,4 +20,4 @@ export const subscriberRouter = router({
unsubscribe: publicUnsubscribe, unsubscribe: publicUnsubscribe,
unsubscribeToggle, unsubscribeToggle,
verifyEmail, verifyEmail,
}) });

View File

@@ -1,4 +1,4 @@
import swaggerJSDoc from "swagger-jsdoc" import swaggerJSDoc from "swagger-jsdoc";
const swaggerDefinition = { const swaggerDefinition = {
openapi: "3.0.0", openapi: "3.0.0",
@@ -6,13 +6,13 @@ const swaggerDefinition = {
title: "Cat Letter API", title: "Cat Letter API",
version: "1.0.0", version: "1.0.0",
}, },
} };
const options = { const options = {
swaggerDefinition, swaggerDefinition,
apis: ["./src/api/server.ts"], apis: ["./src/api/server.ts"],
} };
const swaggerSpec = swaggerJSDoc(options) const swaggerSpec = swaggerJSDoc(options);
export default swaggerSpec export default swaggerSpec;

View File

@@ -1,22 +1,22 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
const contentSchema = z const contentSchema = z
.string() .string()
.min(1, "HTML content is required") .min(1, "HTML content is required")
.refine( .refine(
(content) => content.includes("{{content}}"), (content) => content.includes("{{content}}"),
"Content must include the {{content}} placeholder" "Content must include the {{content}} placeholder",
) );
const createTemplateSchema = z.object({ const createTemplateSchema = z.object({
name: z.string().min(1, "Template name is required"), name: z.string().min(1, "Template name is required"),
description: z.string().nullable().optional(), description: z.string().nullable().optional(),
content: contentSchema, content: contentSchema,
organizationId: z.string(), organizationId: z.string(),
}) });
export const createTemplate = authProcedure export const createTemplate = authProcedure
.input(createTemplateSchema) .input(createTemplateSchema)
@@ -26,13 +26,13 @@ export const createTemplate = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const template = await prisma.template.create({ const template = await prisma.template.create({
@@ -42,16 +42,16 @@ export const createTemplate = authProcedure
content: input.content, content: input.content,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
return { template } return { template };
}) });
export const updateTemplate = authProcedure export const updateTemplate = authProcedure
.input( .input(
createTemplateSchema.extend({ createTemplateSchema.extend({
id: z.string(), id: z.string(),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -59,13 +59,13 @@ export const updateTemplate = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const template = await prisma.template.findFirst({ const template = await prisma.template.findFirst({
@@ -73,13 +73,13 @@ export const updateTemplate = authProcedure
id: input.id, id: input.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!template) { if (!template) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Template not found", message: "Template not found",
}) });
} }
const updatedTemplate = await prisma.template.update({ const updatedTemplate = await prisma.template.update({
@@ -89,17 +89,17 @@ export const updateTemplate = authProcedure
description: input.description, description: input.description,
content: input.content, content: input.content,
}, },
}) });
return { template: updatedTemplate } return { template: updatedTemplate };
}) });
export const deleteTemplate = authProcedure export const deleteTemplate = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -107,13 +107,13 @@ export const deleteTemplate = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const template = await prisma.template.findFirst({ const template = await prisma.template.findFirst({
@@ -121,18 +121,18 @@ export const deleteTemplate = authProcedure
id: input.id, id: input.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!template) { if (!template) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Template not found", message: "Template not found",
}) });
} }
await prisma.template.delete({ await prisma.template.delete({
where: { id: input.id }, where: { id: input.id },
}) });
return { success: true } return { success: true };
}) });

View File

@@ -1,9 +1,9 @@
import { z } from "zod" import { z } from "zod";
import { authProcedure } from "../trpc" import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas" import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client" import { Prisma } from "../../prisma/client";
export const listTemplates = authProcedure export const listTemplates = authProcedure
.input(z.object({ organizationId: z.string() }).merge(paginationSchema)) .input(z.object({ organizationId: z.string() }).merge(paginationSchema))
@@ -13,13 +13,13 @@ export const listTemplates = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const where: Prisma.TemplateWhereInput = { const where: Prisma.TemplateWhereInput = {
@@ -32,7 +32,7 @@ export const listTemplates = authProcedure
], ],
} }
: {}), : {}),
} };
const [total, templates] = await Promise.all([ const [total, templates] = await Promise.all([
prisma.template.count({ where }), prisma.template.count({ where }),
@@ -42,9 +42,9 @@ export const listTemplates = authProcedure
skip: (input.page - 1) * input.perPage, skip: (input.page - 1) * input.perPage,
take: input.perPage, take: input.perPage,
}), }),
]) ]);
const totalPages = Math.ceil(total / input.perPage) const totalPages = Math.ceil(total / input.perPage);
return { return {
templates, templates,
@@ -55,15 +55,15 @@ export const listTemplates = authProcedure
perPage: input.perPage, perPage: input.perPage,
hasMore: input.page < totalPages, hasMore: input.page < totalPages,
}, },
} };
}) });
export const getTemplate = authProcedure export const getTemplate = authProcedure
.input( .input(
z.object({ z.object({
id: z.string(), id: z.string(),
organizationId: z.string(), organizationId: z.string(),
}) }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({ const userOrganization = await prisma.userOrganization.findFirst({
@@ -71,13 +71,13 @@ export const getTemplate = authProcedure
userId: ctx.user.id, userId: ctx.user.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!userOrganization) { if (!userOrganization) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "Organization not found", message: "Organization not found",
}) });
} }
const template = await prisma.template.findFirst({ const template = await prisma.template.findFirst({
@@ -85,14 +85,14 @@ export const getTemplate = authProcedure
id: input.id, id: input.id,
organizationId: input.organizationId, organizationId: input.organizationId,
}, },
}) });
if (!template) { if (!template) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
message: "Template not found", message: "Template not found",
}) });
} }
return template return template;
}) });

View File

@@ -1,6 +1,6 @@
import { router } from "../trpc" import { router } from "../trpc";
import { createTemplate, updateTemplate, deleteTemplate } from "./mutation" import { createTemplate, updateTemplate, deleteTemplate } from "./mutation";
import { getTemplate, listTemplates } from "./query" import { getTemplate, listTemplates } from "./query";
export const templateRouter = router({ export const templateRouter = router({
create: createTemplate, create: createTemplate,
@@ -8,4 +8,4 @@ export const templateRouter = router({
delete: deleteTemplate, delete: deleteTemplate,
get: getTemplate, get: getTemplate,
list: listTemplates, list: listTemplates,
}) });

View File

@@ -1,81 +1,81 @@
import { initTRPC, TRPCError } from "@trpc/server" import { initTRPC, TRPCError } from "@trpc/server";
import * as trpcExpress from "@trpc/server/adapters/express" import * as trpcExpress from "@trpc/server/adapters/express";
import { verifyToken } from "./utils/auth" import { verifyToken } from "./utils/auth";
import { prisma } from "./utils/prisma" import { prisma } from "./utils/prisma";
import { tokenPayloadSchema } from "./utils/token" import { tokenPayloadSchema } from "./utils/token";
import SuperJSON from "superjson" import SuperJSON from "superjson";
interface User { interface User {
id: string id: string;
} }
interface Context { interface Context {
user?: User user?: User;
} }
export const createContext = async ({ export const createContext = async ({
req, req,
}: trpcExpress.CreateExpressContextOptions): Promise<Context> => { }: trpcExpress.CreateExpressContextOptions): Promise<Context> => {
const authHeader = req.headers.authorization const authHeader = req.headers.authorization;
if (!authHeader) { if (!authHeader) {
return {} return {};
} }
try { try {
const token = authHeader.split(" ")[1] const token = authHeader.split(" ")[1];
if (!token) { if (!token) {
return {} return {};
} }
const decodedRaw = verifyToken(token) const decodedRaw = verifyToken(token);
const result = tokenPayloadSchema.safeParse(decodedRaw) const result = tokenPayloadSchema.safeParse(decodedRaw);
if (!result.success) { if (!result.success) {
return {} return {};
} }
const decoded = result.data const decoded = result.data;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: decoded.id }, where: { id: decoded.id },
select: { id: true, pwdVersion: true }, select: { id: true, pwdVersion: true },
}) });
if (!user) { if (!user) {
return {} return {};
} }
if (user.pwdVersion !== decoded.version) { if (user.pwdVersion !== decoded.version) {
return {} return {};
} }
return { user } return { user };
} catch { } catch {
return {} return {};
} }
} };
const t = initTRPC.context<Context>().create({ const t = initTRPC.context<Context>().create({
transformer: SuperJSON, transformer: SuperJSON,
}) });
export const router = t.router export const router = t.router;
export const publicProcedure = t.procedure export const publicProcedure = t.procedure;
export const isAuthedMiddleware = t.middleware(({ ctx, next }) => { export const isAuthedMiddleware = t.middleware(({ ctx, next }) => {
if (!ctx.user) { if (!ctx.user) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "You must be logged in to access this resource", message: "You must be logged in to access this resource",
}) });
} }
return next({ return next({
ctx: { ctx: {
user: ctx.user, user: ctx.user,
}, },
}) });
}) });
export const authProcedure = t.procedure.use(isAuthedMiddleware) export const authProcedure = t.procedure.use(isAuthedMiddleware);

View File

@@ -1,9 +1,9 @@
import { Organization } from "../prisma/client" import { Organization } from "../prisma/client";
declare global { declare global {
export namespace Express { export namespace Express {
export interface Request { export interface Request {
organization: Organization organization: Organization;
} }
} }
} }

View File

@@ -1,41 +1,41 @@
import { z } from "zod" import { z } from "zod";
import { publicProcedure, authProcedure } from "../trpc" import { publicProcedure, authProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
import { comparePasswords, generateToken, hashPassword } from "../utils/auth" import { comparePasswords, generateToken, hashPassword } from "../utils/auth";
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
const signUpSchema = z.object({ const signUpSchema = z.object({
email: z.string().email().min(1, "Email is required"), email: z.string().email().min(1, "Email is required"),
password: z.string().min(1, "Password is required"), password: z.string().min(1, "Password is required"),
name: z.string().min(1, "Name is required"), name: z.string().min(1, "Name is required"),
}) });
export const signup = publicProcedure export const signup = publicProcedure
.input(signUpSchema) .input(signUpSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { email, password, name } = input const { email, password, name } = input;
if (await prisma.user.count()) { if (await prisma.user.count()) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Bad request", message: "Bad request",
}) });
} }
const existingUser = await prisma.user.findUnique({ const existingUser = await prisma.user.findUnique({
where: { where: {
email, email,
}, },
}) });
if (existingUser) { if (existingUser) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: `User with email ${email} already exists`, message: `User with email ${email} already exists`,
}) });
} }
const hashedPassword = await hashPassword(password) const hashedPassword = await hashPassword(password);
const user = await prisma.user.create({ const user = await prisma.user.create({
data: { data: {
email, email,
@@ -46,24 +46,24 @@ export const signup = publicProcedure
id: true, id: true,
pwdVersion: true, pwdVersion: true,
}, },
}) });
const token = generateToken(user.id, user.pwdVersion) const token = generateToken(user.id, user.pwdVersion);
return { return {
token, token,
} };
}) });
export const login = publicProcedure export const login = publicProcedure
.input( .input(
z.object({ z.object({
email: z.string().email().min(1, "Email is required"), email: z.string().email().min(1, "Email is required"),
password: z.string().min(1, "Password is required"), password: z.string().min(1, "Password is required"),
}) }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const { email, password } = input const { email, password } = input;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email }, where: { email },
@@ -73,45 +73,45 @@ export const login = publicProcedure
pwdVersion: true, pwdVersion: true,
UserOrganizations: true, UserOrganizations: true,
}, },
}) });
if (!user) { if (!user) {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "Invalid credentials", message: "Invalid credentials",
}) });
} }
const isValidPassword = await comparePasswords(password, user.password) const isValidPassword = await comparePasswords(password, user.password);
if (!isValidPassword) { if (!isValidPassword) {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: "Invalid credentials", message: "Invalid credentials",
}) });
} }
const token = generateToken(user.id, user.pwdVersion) const token = generateToken(user.id, user.pwdVersion);
return { return {
token, token,
user, user,
} };
}) });
const updateProfileSchema = z.object({ const updateProfileSchema = z.object({
name: z.string().min(1, "Name is required."), name: z.string().min(1, "Name is required."),
email: z.string().email("Invalid email address.").toLowerCase(), email: z.string().email("Invalid email address.").toLowerCase(),
}) });
export const updateProfile = authProcedure export const updateProfile = authProcedure
.input(updateProfileSchema) .input(updateProfileSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { name, email } = input const { name, email } = input;
const userId = ctx.user.id const userId = ctx.user.id;
const currentUser = await prisma.user.findUnique({ const currentUser = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
}) });
if (currentUser?.email !== email) { if (currentUser?.email !== email) {
const existingUserWithEmail = await prisma.user.findFirst({ const existingUserWithEmail = await prisma.user.findFirst({
@@ -119,12 +119,12 @@ export const updateProfile = authProcedure
email: email, email: email,
id: { not: userId }, id: { not: userId },
}, },
}) });
if (existingUserWithEmail) { if (existingUserWithEmail) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Email address is already in use by another account.", message: "Email address is already in use by another account.",
}) });
} }
} }
@@ -144,44 +144,44 @@ export const updateProfile = authProcedure
}, },
}, },
}, },
}) });
return { user: updatedUser } return { user: updatedUser };
}) });
const changePasswordSchema = z.object({ const changePasswordSchema = z.object({
currentPassword: z.string(), currentPassword: z.string(),
newPassword: z.string().min(8, "New password must be at least 8 characters."), newPassword: z.string().min(8, "New password must be at least 8 characters."),
}) });
export const changePassword = authProcedure export const changePassword = authProcedure
.input(changePasswordSchema) .input(changePasswordSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const userId = ctx.user.id const userId = ctx.user.id;
const { currentPassword, newPassword } = input const { currentPassword, newPassword } = input;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { password: true, pwdVersion: true }, select: { password: true, pwdVersion: true },
}) });
if (!user) { if (!user) {
throw new TRPCError({ code: "NOT_FOUND", message: "User not found." }) throw new TRPCError({ code: "NOT_FOUND", message: "User not found." });
} }
const isValidPassword = await comparePasswords( const isValidPassword = await comparePasswords(
currentPassword, currentPassword,
user.password user.password,
) );
if (!isValidPassword) { if (!isValidPassword) {
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: "Incorrect current password.", message: "Incorrect current password.",
}) });
} }
const hashedNewPassword = await hashPassword(newPassword) const hashedNewPassword = await hashPassword(newPassword);
const newPwdVersion = (user.pwdVersion || 0) + 1 const newPwdVersion = (user.pwdVersion || 0) + 1;
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: userId },
@@ -189,9 +189,9 @@ export const changePassword = authProcedure
password: hashedNewPassword, password: hashedNewPassword,
pwdVersion: newPwdVersion, pwdVersion: newPwdVersion,
}, },
}) });
const newToken = generateToken(userId, newPwdVersion) const newToken = generateToken(userId, newPwdVersion);
return { success: true, token: newToken } return { success: true, token: newToken };
}) });

View File

@@ -1,6 +1,6 @@
import { TRPCError } from "@trpc/server" import { TRPCError } from "@trpc/server";
import { authProcedure, publicProcedure } from "../trpc" import { authProcedure, publicProcedure } from "../trpc";
import { prisma } from "../utils/prisma" import { prisma } from "../utils/prisma";
export const me = authProcedure.query(async ({ ctx }) => { export const me = authProcedure.query(async ({ ctx }) => {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@@ -12,19 +12,19 @@ export const me = authProcedure.query(async ({ ctx }) => {
}, },
}, },
}, },
}) });
if (!user) { if (!user) {
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "UNAUTHORIZED",
message: "User not found", message: "User not found",
}) });
} }
return user return user;
}) });
export const isFirstUser = publicProcedure.query(async () => { export const isFirstUser = publicProcedure.query(async () => {
const user = await prisma.user.count() const user = await prisma.user.count();
return user === 0 return user === 0;
}) });

View File

@@ -1,6 +1,6 @@
import { router } from "../trpc" import { router } from "../trpc";
import { login, signup, updateProfile, changePassword } from "./mutation" import { login, signup, updateProfile, changePassword } from "./mutation";
import { me, isFirstUser } from "./query" import { me, isFirstUser } from "./query";
export const userRouter = router({ export const userRouter = router({
signup, signup,
@@ -9,4 +9,4 @@ export const userRouter = router({
isFirstUser, isFirstUser,
updateProfile, updateProfile,
changePassword, changePassword,
}) });

View File

@@ -1,22 +1,24 @@
import jwt from "jsonwebtoken" import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs" import bcrypt from "bcryptjs";
import { env } from "../constants" import { env } from "../constants";
export async function hashPassword(password: string) { export async function hashPassword(password: string) {
return bcrypt.hash(password, 10) return bcrypt.hash(password, 10);
} }
export async function comparePasswords( export async function comparePasswords(
password: string, password: string,
hashedPassword: string hashedPassword: string,
) { ) {
return bcrypt.compare(password, hashedPassword) return bcrypt.compare(password, hashedPassword);
} }
export function generateToken(userId: string, version: number) { export function generateToken(userId: string, version: number) {
return jwt.sign({ id: userId, version }, env.JWT_SECRET, { expiresIn: "30d" }) return jwt.sign({ id: userId, version }, env.JWT_SECRET, {
expiresIn: "30d",
});
} }
export function verifyToken(token: string) { export function verifyToken(token: string) {
return jwt.verify(token, env.JWT_SECRET) return jwt.verify(token, env.JWT_SECRET);
} }

View File

@@ -1,18 +1,18 @@
const formatLog = (messages: unknown[]) => { const formatLog = (messages: unknown[]) => {
return `[${new Date().toISOString()}] ${messages.join(" ")}` return `[${new Date().toISOString()}] ${messages.join(" ")}`;
} };
export const logger = { export const logger = {
log(...messages: unknown[]) { log(...messages: unknown[]) {
console.log(formatLog(messages)) console.log(formatLog(messages));
}, },
info(...messages: unknown[]) { info(...messages: unknown[]) {
console.log(formatLog(messages)) console.log(formatLog(messages));
}, },
error(...messages: unknown[]) { error(...messages: unknown[]) {
console.error(formatLog(messages)) console.error(formatLog(messages));
}, },
warn(...messages: unknown[]) { warn(...messages: unknown[]) {
console.warn(formatLog(messages)) console.warn(formatLog(messages));
}, },
} };

View File

@@ -1,14 +1,14 @@
export async function resolveProps<T extends Record<string, Promise<any>>>( export async function resolveProps<T extends Record<string, Promise<any>>>(
promises: T promises: T,
): Promise<{ [K in keyof T]: Awaited<T[K]> }> { ): Promise<{ [K in keyof T]: Awaited<T[K]> }> {
const keys = Object.keys(promises) const keys = Object.keys(promises);
const values = await Promise.all(Object.values(promises)) const values = await Promise.all(Object.values(promises));
return keys.reduce( return keys.reduce(
(acc, key, index) => { (acc, key, index) => {
acc[key as keyof T] = values[index] acc[key as keyof T] = values[index];
return acc return acc;
}, },
{} as { [K in keyof T]: Awaited<T[K]> } {} as { [K in keyof T]: Awaited<T[K]> },
) );
} }

View File

@@ -1,122 +1,125 @@
import { replacePlaceholders } from "./placeholder-parser" import { replacePlaceholders } from "./placeholder-parser";
import { describe, it, expect } from "vitest" import { describe, it, expect } from "vitest";
describe("replacePlaceholders", () => { describe("replacePlaceholders", () => {
it("should replace a single placeholder", () => { it("should replace a single placeholder", () => {
const template = "Hello {{subscriber.name}}!" const template = "Hello {{subscriber.name}}!";
const data = { "subscriber.name": "John" } const data = { "subscriber.name": "John" };
expect(replacePlaceholders(template, data)).toBe("Hello John!") expect(replacePlaceholders(template, data)).toBe("Hello John!");
}) });
it("should replace multiple placeholders", () => { it("should replace multiple placeholders", () => {
const template = "Order for {{subscriber.name}} from {{organization.name}}." const template =
"Order for {{subscriber.name}} from {{organization.name}}.";
const data = { const data = {
"subscriber.name": "Alice", "subscriber.name": "Alice",
"organization.name": "Org Inc", "organization.name": "Org Inc",
} };
expect(replacePlaceholders(template, data)).toBe( expect(replacePlaceholders(template, data)).toBe(
"Order for Alice from Org Inc." "Order for Alice from Org Inc.",
) );
}) });
it("should handle templates with no placeholders", () => { it("should handle templates with no placeholders", () => {
const template = "This is a static string." const template = "This is a static string.";
const data = { "subscriber.name": "Bob" } const data = { "subscriber.name": "Bob" };
expect(replacePlaceholders(template, data)).toBe("This is a static string.") expect(replacePlaceholders(template, data)).toBe(
}) "This is a static string.",
);
});
it("should handle empty data", () => { it("should handle empty data", () => {
const template = "Hello {{subscriber.name}}!" const template = "Hello {{subscriber.name}}!";
const data = {} const data = {};
expect(replacePlaceholders(template, data)).toBe( expect(replacePlaceholders(template, data)).toBe(
"Hello {{subscriber.name}}!" "Hello {{subscriber.name}}!",
) );
}) });
it("should handle empty template string", () => { it("should handle empty template string", () => {
const template = "" const template = "";
const data = { "subscriber.name": "Eve" } const data = { "subscriber.name": "Eve" };
expect(replacePlaceholders(template, data)).toBe("") expect(replacePlaceholders(template, data)).toBe("");
}) });
it("should handle placeholders with special characters in keys", () => { it("should handle placeholders with special characters in keys", () => {
const template = "Link: {{unsubscribe_link}}" const template = "Link: {{unsubscribe_link}}";
const data = { unsubscribe_link: "http://example.com/unsubscribe" } const data = { unsubscribe_link: "http://example.com/unsubscribe" };
expect(replacePlaceholders(template, data)).toBe( expect(replacePlaceholders(template, data)).toBe(
"Link: http://example.com/unsubscribe" "Link: http://example.com/unsubscribe",
) );
}) });
it("should replace all occurrences of a placeholder", () => { it("should replace all occurrences of a placeholder", () => {
const template = "Hi {{subscriber.name}}, welcome {{subscriber.name}}." const template = "Hi {{subscriber.name}}, welcome {{subscriber.name}}.";
const data = { "subscriber.name": "Charlie" } const data = { "subscriber.name": "Charlie" };
expect(replacePlaceholders(template, data)).toBe( expect(replacePlaceholders(template, data)).toBe(
"Hi Charlie, welcome Charlie." "Hi Charlie, welcome Charlie.",
) );
}) });
it("should not replace partial matches", () => { it("should not replace partial matches", () => {
const template = "Hello {{subscriber.name}} and {{subscriber.names}}" const template = "Hello {{subscriber.name}} and {{subscriber.names}}";
const data = { "subscriber.name": "David" } const data = { "subscriber.name": "David" };
expect(replacePlaceholders(template, data)).toBe( expect(replacePlaceholders(template, data)).toBe(
"Hello David and {{subscriber.names}}" "Hello David and {{subscriber.names}}",
) );
}) });
it("should correctly replace various types of placeholders", () => { it("should correctly replace various types of placeholders", () => {
const template = const template =
"Email: {{subscriber.email}}, Campaign: {{campaign.name}}, Org: {{organization.name}}, Unsub: {{unsubscribe_link}}, Date: {{current_date}}" "Email: {{subscriber.email}}, Campaign: {{campaign.name}}, Org: {{organization.name}}, Unsub: {{unsubscribe_link}}, Date: {{current_date}}";
const data = { const data = {
"subscriber.email": "test@example.com", "subscriber.email": "test@example.com",
"campaign.name": "Newsletter Q1", "campaign.name": "Newsletter Q1",
"organization.name": "MyCompany", "organization.name": "MyCompany",
unsubscribe_link: "domain.com/unsub", unsubscribe_link: "domain.com/unsub",
current_date: "2024-01-01", current_date: "2024-01-01",
} };
expect(replacePlaceholders(template, data)).toBe( expect(replacePlaceholders(template, data)).toBe(
"Email: test@example.com, Campaign: Newsletter Q1, Org: MyCompany, Unsub: domain.com/unsub, Web: domain.com/web, Date: 2024-01-01" "Email: test@example.com, Campaign: Newsletter Q1, Org: MyCompany, Unsub: domain.com/unsub, Web: domain.com/web, Date: 2024-01-01",
) );
}) });
it("should handle data with undefined values gracefully", () => { it("should handle data with undefined values gracefully", () => {
const template = "Hello {{subscriber.name}} and {{campaign.name}}!" const template = "Hello {{subscriber.name}} and {{campaign.name}}!";
const data = { const data = {
"subscriber.name": "DefinedName", "subscriber.name": "DefinedName",
"campaign.name": undefined, "campaign.name": undefined,
} as { [key: string]: string | undefined } // Added type assertion for clarity } as { [key: string]: string | undefined }; // Added type assertion for clarity
expect(replacePlaceholders(template, data)).toBe( expect(replacePlaceholders(template, data)).toBe(
"Hello DefinedName and {{campaign.name}}!" "Hello DefinedName and {{campaign.name}}!",
) );
}) });
it("should replace placeholders with leading spaces inside braces", () => { it("should replace placeholders with leading spaces inside braces", () => {
const template = "Hello {{ subscriber.name }}!" const template = "Hello {{ subscriber.name }}!";
const data = { "subscriber.name": "SpacedJohn" } const data = { "subscriber.name": "SpacedJohn" };
expect(replacePlaceholders(template, data)).toBe("Hello SpacedJohn!") expect(replacePlaceholders(template, data)).toBe("Hello SpacedJohn!");
}) });
it("should replace placeholders with trailing spaces inside braces", () => { it("should replace placeholders with trailing spaces inside braces", () => {
const template = "Hello {{subscriber.name }}!" const template = "Hello {{subscriber.name }}!";
const data = { "subscriber.name": "SpacedAlice" } const data = { "subscriber.name": "SpacedAlice" };
expect(replacePlaceholders(template, data)).toBe("Hello SpacedAlice!") expect(replacePlaceholders(template, data)).toBe("Hello SpacedAlice!");
}) });
it("should replace placeholders with leading and trailing spaces inside braces", () => { it("should replace placeholders with leading and trailing spaces inside braces", () => {
const template = "Hello {{ subscriber.name }}!" const template = "Hello {{ subscriber.name }}!";
const data = { "subscriber.name": "SpacedBob" } const data = { "subscriber.name": "SpacedBob" };
expect(replacePlaceholders(template, data)).toBe("Hello SpacedBob!") expect(replacePlaceholders(template, data)).toBe("Hello SpacedBob!");
}) });
it("should replace multiple placeholders with various spacing", () => { it("should replace multiple placeholders with various spacing", () => {
const template = const template =
"Hi {{subscriber.name}}, welcome {{ organization.name }}. Date: {{current_date}}." "Hi {{subscriber.name}}, welcome {{ organization.name }}. Date: {{current_date}}.";
const data = { const data = {
"subscriber.name": "SpacedEve", "subscriber.name": "SpacedEve",
"organization.name": "Org Spaced Inc.", "organization.name": "Org Spaced Inc.",
current_date: "2024-02-20", current_date: "2024-02-20",
} };
expect(replacePlaceholders(template, data)).toBe( expect(replacePlaceholders(template, data)).toBe(
"Hi SpacedEve, welcome Org Spaced Inc.. Date: 2024-02-20." "Hi SpacedEve, welcome Org Spaced Inc.. Date: 2024-02-20.",
) );
}) });
}) });

Some files were not shown because too many files have changed in this diff Show More