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

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

View File

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

View File

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

View File

@@ -4,30 +4,30 @@
@source "../../../content/**/*";
@theme static {
--container-8xl: 90rem;
--font-sans: 'Public Sans', sans-serif;
--container-8xl: 90rem;
--font-sans: "Public Sans", sans-serif;
--color-green-50: #EFFDF5;
--color-green-100: #D9FBE8;
--color-green-200: #B3F5D1;
--color-green-300: #75EDAE;
--color-green-400: #00DC82;
--color-green-500: #00C16A;
--color-green-600: #00A155;
--color-green-700: #007F45;
--color-green-800: #016538;
--color-green-900: #0A5331;
--color-green-950: #052E16;
--color-green-50: #effdf5;
--color-green-100: #d9fbe8;
--color-green-200: #b3f5d1;
--color-green-300: #75edae;
--color-green-400: #00dc82;
--color-green-500: #00c16a;
--color-green-600: #00a155;
--color-green-700: #007f45;
--color-green-800: #016538;
--color-green-900: #0a5331;
--color-green-950: #052e16;
}
:root {
--ui-container: var(--container-8xl);
--ui-container: var(--container-8xl);
}
h2 > a > span + span {
@apply size-6 align-text-top;
@apply size-6 align-text-top;
}
h3 > a > span + span {
@apply size-5 align-text-top;
@apply size-5 align-text-top;
}

View File

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

View File

@@ -1,9 +1,9 @@
<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>
<template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
<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>
<template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,40 @@
{
"name": "nuxt-ui-template-docs",
"private": true,
"type": "module",
"scripts": {
"generate": "nuxi generate",
"build": "nuxt build",
"dev": "nuxt dev",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.68",
"@iconify-json/simple-icons": "^1.2.54",
"@iconify-json/vscode-icons": "^1.2.30",
"@nuxt/content": "^3.7.1",
"@nuxt/image": "^1.11.0",
"@nuxt/ui": "^4.0.1",
"@nuxtjs/mdc": "^0.17.4",
"@tailwindcss/typography": "^0.5.19",
"@vite-pwa/nuxt": "^1.0.4",
"better-sqlite3": "^12.4.1",
"nuxt": "^4.1.2",
"nuxt-llms": "0.1.3",
"nuxt-og-image": "^5.1.11",
"tailwindcss": "^4.1.14",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@nuxt/eslint": "^1.9.0",
"eslint": "^9.37.0",
"typescript": "^5.9.3",
"vue-tsc": "^3.1.0"
},
"resolutions": {
"unimport": "4.1.1"
}
"name": "nuxt-ui-template-docs",
"private": true,
"type": "module",
"scripts": {
"generate": "nuxi generate",
"build": "nuxt build",
"dev": "nuxt dev",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.68",
"@iconify-json/simple-icons": "^1.2.54",
"@iconify-json/vscode-icons": "^1.2.30",
"@nuxt/content": "^3.7.1",
"@nuxt/image": "^1.11.0",
"@nuxt/ui": "^4.0.1",
"@nuxtjs/mdc": "^0.17.4",
"@tailwindcss/typography": "^0.5.19",
"@vite-pwa/nuxt": "^1.0.4",
"better-sqlite3": "^12.4.1",
"nuxt": "^4.1.2",
"nuxt-llms": "0.1.3",
"nuxt-og-image": "^5.1.11",
"tailwindcss": "^4.1.14",
"unist-util-visit": "^5.0.0"
},
"devDependencies": {
"@nuxt/eslint": "^1.9.0",
"eslint": "^9.37.0",
"typescript": "^5.9.3",
"vue-tsc": "^3.1.0"
},
"resolutions": {
"unimport": "4.1.1"
}
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

View File

@@ -1,15 +1,15 @@
{
"eslint.useFlatConfig": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.workingDirectories": [
{
"mode": "auto"
}
],
"typescript.tsdk": "node_modules/typescript/lib"
"eslint.useFlatConfig": true,
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
"eslint.workingDirectories": [
{
"mode": "auto"
}
],
"typescript.tsdk": "node_modules/typescript/lib"
}

View File

@@ -1,25 +1,25 @@
import globals from "globals"
import pluginJs from "@eslint/js"
import * as tseslint from "typescript-eslint"
import globals from "globals";
import pluginJs from "@eslint/js";
import * as tseslint from "typescript-eslint";
export default tseslint.config({
files: ["src/**/*.{js,mjs,cjs,ts}"],
extends: [pluginJs.configs.recommended],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parser: tseslint.parser,
parserOptions: {
project: true,
},
},
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { caughtErrors: "none" }],
"no-unused-vars": "off",
},
})
files: ["src/**/*.{js,mjs,cjs,ts}"],
extends: [pluginJs.configs.recommended],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
parser: tseslint.parser,
parserOptions: {
project: true,
},
},
plugins: {
"@typescript-eslint": tseslint.plugin,
},
rules: {
"@typescript-eslint/no-unused-vars": ["warn", { caughtErrors: "none" }],
"no-unused-vars": "off",
},
});

View File

@@ -1,71 +1,71 @@
{
"name": "backend",
"version": "0.1.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"start": "bun run src/index.ts",
"dev": "watchexec -r -e ts bun run src/index.ts",
"build": "rm -rf dist && tsc -b tsconfig.build.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"generate": "prisma generate",
"generate:sql": "prisma generate --sql && pnpm exec prettier --write prisma/client",
"test": "dotenv -e .env.test -- vitest"
},
"exports": {
".": "./src/index.ts",
"./shared": "./src/shared.ts"
},
"prisma": {
"seed": "bun prisma/seed.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.7.0",
"@trpc/server": "11.0.0-rc.730",
"bcryptjs": "^3.0.0",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"node-cron": "^3.0.3",
"nodemailer": "^6.10.0",
"p-map": "^7.0.3",
"superjson": "^2.2.2",
"uuid": "^11.0.5",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@faker-js/faker": "^9.5.0",
"@repo/eslint-config": "workspace:*",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.3",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.8",
"@types/node": "^22.12.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/supertest": "^6.0.2",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.7",
"@types/uuid": "^10.0.0",
"@vitest/ui": "3.0.5",
"dotenv-cli": "^8.0.0",
"eslint": "^9.19.0",
"globals": "^15.14.0",
"prisma": "^6.7.0",
"supertest": "^7.0.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"typescript": "^5.8.3",
"typescript-eslint": "^8.22.0",
"vitest": "^3.0.5"
}
"name": "backend",
"version": "0.1.0",
"description": "",
"main": "src/index.ts",
"scripts": {
"start": "bun run src/index.ts",
"dev": "watchexec -r -e ts bun run src/index.ts",
"build": "rm -rf dist && tsc -b tsconfig.build.json",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"generate": "prisma generate",
"generate:sql": "prisma generate --sql && pnpm exec prettier --write prisma/client",
"test": "dotenv -e .env.test -- vitest"
},
"exports": {
".": "./src/index.ts",
"./shared": "./src/shared.ts"
},
"prisma": {
"seed": "bun prisma/seed.ts"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@prisma/client": "^6.7.0",
"@trpc/server": "11.0.0-rc.730",
"bcryptjs": "^3.0.0",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"node-cron": "^3.0.3",
"nodemailer": "^6.10.0",
"p-map": "^7.0.3",
"superjson": "^2.2.2",
"uuid": "^11.0.5",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@faker-js/faker": "^9.5.0",
"@repo/eslint-config": "workspace:*",
"@types/cors": "^2.8.17",
"@types/dotenv": "^8.2.3",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.8",
"@types/node": "^22.12.0",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^6.4.17",
"@types/supertest": "^6.0.2",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.7",
"@types/uuid": "^10.0.0",
"@vitest/ui": "3.0.5",
"dotenv-cli": "^8.0.0",
"eslint": "^9.19.0",
"globals": "^15.14.0",
"prisma": "^6.7.0",
"supertest": "^7.0.0",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
"typescript": "^5.8.3",
"typescript-eslint": "^8.22.0",
"vitest": "^3.0.5"
}
}

View File

@@ -1,19 +1,19 @@
export interface $DbEnums {}
export namespace $DbEnums {
type CampaignStatus =
| "DRAFT"
| "SCHEDULED"
| "SENDING"
| "COMPLETED"
| "CANCELLED";
type MessageStatus =
| "QUEUED"
| "PENDING"
| "SENT"
| "OPENED"
| "CLICKED"
| "FAILED"
| "RETRYING";
type SmtpEncryption = "STARTTLS" | "SSL_TLS" | "NONE";
type CampaignStatus =
| "DRAFT"
| "SCHEDULED"
| "SENDING"
| "COMPLETED"
| "CANCELLED";
type MessageStatus =
| "QUEUED"
| "PENDING"
| "SENT"
| "OPENED"
| "CLICKED"
| "FAILED"
| "RETRYING";
type SmtpEncryption = "STARTTLS" | "SSL_TLS" | "NONE";
}

View File

@@ -4,19 +4,19 @@ import * as $runtime from "../runtime/library";
* @param text
*/
export const countDbSize: (
text: string,
text: string,
) => $runtime.TypedSql<countDbSize.Parameters, countDbSize.Result>;
export namespace countDbSize {
export type Parameters = [text: string];
export type Result = {
organization_id: string;
organization_name: string;
campaign_count: bigint | null;
template_count: bigint | null;
message_count: bigint | null;
subscriber_count: bigint | null;
list_count: bigint | null;
total_size_mb: $runtime.Decimal | null;
};
export type Parameters = [text: string];
export type Result = {
organization_id: string;
organization_name: string;
campaign_count: bigint | null;
template_count: bigint | null;
message_count: bigint | null;
subscriber_count: bigint | null;
list_count: bigint | null;
total_size_mb: $runtime.Decimal | null;
};
}

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/edge.js");
exports.countDbSize = /*#__PURE__*/ $mkFactory(
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/edge.js";
export const countDbSize = /*#__PURE__*/ $mkFactory(
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
);

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/library");
exports.countDbSize = /*#__PURE__*/ $mkFactory(
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/library";
export const countDbSize = /*#__PURE__*/ $mkFactory(
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
'WITH organization_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(c.content)), 0) as campaign_content_size,\nCOALESCE(SUM(LENGTH(c.subject)), 0) as campaign_subject_size,\nCOALESCE(SUM(LENGTH(c.title)), 0) as campaign_title_size,\nCOALESCE(SUM(LENGTH(c.description)), 0) as campaign_description_size,\nCOUNT(*) as campaign_count\nFROM "Campaign" c\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\ntemplate_storage AS (\nSELECT\nt."organizationId",\nCOALESCE(SUM(LENGTH(t.content)), 0) as template_content_size,\nCOALESCE(SUM(LENGTH(t.name)), 0) as template_name_size,\nCOALESCE(SUM(LENGTH(t.description)), 0) as template_description_size,\nCOUNT(*) as template_count\nFROM "Template" t\nWHERE t."organizationId" = $1\nGROUP BY t."organizationId"\n),\nmessage_storage AS (\nSELECT\nc."organizationId",\nCOALESCE(SUM(LENGTH(m.content)), 0) as message_content_size,\nCOALESCE(SUM(LENGTH(m.error)), 0) as message_error_size,\nCOUNT(*) as message_count\nFROM "Message" m\nJOIN "Campaign" c ON c.id = m."campaignId"\nWHERE c."organizationId" = $1\nGROUP BY c."organizationId"\n),\nsubscriber_storage AS (\nSELECT\ns."organizationId",\nCOALESCE(SUM(LENGTH(s.email)), 0) as subscriber_email_size,\nCOALESCE(SUM(LENGTH(s.name)), 0) as subscriber_name_size,\nCOUNT(*) as subscriber_count\nFROM "Subscriber" s\nWHERE s."organizationId" = $1\nGROUP BY s."organizationId"\n),\nlist_storage AS (\nSELECT\nl."organizationId",\nCOALESCE(SUM(LENGTH(l.name)), 0) as list_name_size,\nCOALESCE(SUM(LENGTH(l.description)), 0) as list_description_size,\nCOUNT(*) as list_count\nFROM "List" l\nWHERE l."organizationId" = $1\nGROUP BY l."organizationId"\n)\n\nSELECT\no.id as organization_id,\no.name as organization_name,\nCOALESCE(os.campaign_count, 0) as campaign_count,\nCOALESCE(ts.template_count, 0) as template_count,\nCOALESCE(ms.message_count, 0) as message_count,\nCOALESCE(ss.subscriber_count, 0) as subscriber_count,\nCOALESCE(ls.list_count, 0) as list_count,\n(\nCOALESCE(os.campaign_content_size, 0) +\nCOALESCE(os.campaign_subject_size, 0) +\nCOALESCE(os.campaign_title_size, 0) +\nCOALESCE(os.campaign_description_size, 0) +\nCOALESCE(ts.template_content_size, 0) +\nCOALESCE(ts.template_name_size, 0) +\nCOALESCE(ts.template_description_size, 0) +\nCOALESCE(ms.message_content_size, 0) +\nCOALESCE(ms.message_error_size, 0) +\nCOALESCE(ss.subscriber_email_size, 0) +\nCOALESCE(ss.subscriber_name_size, 0) +\nCOALESCE(ls.list_name_size, 0) +\nCOALESCE(ls.list_description_size, 0)\n) / 1024.0 / 1024.0 as total_size_mb\nFROM "Organization" o\nLEFT JOIN organization_storage os ON o.id = os."organizationId"\nLEFT JOIN template_storage ts ON o.id = ts."organizationId"\nLEFT JOIN message_storage ms ON o.id = ms."organizationId"\nLEFT JOIN subscriber_storage ss ON o.id = ss."organizationId"\nLEFT JOIN list_storage ls ON o.id = ls."organizationId"\nWHERE o.id = $1;',
);

View File

@@ -4,15 +4,15 @@ import * as $runtime from "../runtime/library";
* @param text
*/
export const countDistinctRecipients: (
text: string,
text: string,
) => $runtime.TypedSql<
countDistinctRecipients.Parameters,
countDistinctRecipients.Result
countDistinctRecipients.Parameters,
countDistinctRecipients.Result
>;
export namespace countDistinctRecipients {
export type Parameters = [text: string];
export type Result = {
count: bigint | null;
};
export type Parameters = [text: string];
export type Result = {
count: bigint | null;
};
}

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/edge.js");
exports.countDistinctRecipients = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/edge.js";
export const countDistinctRecipients = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
);

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/library");
exports.countDistinctRecipients = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/library";
export const countDistinctRecipients = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1;',
);

View File

@@ -6,17 +6,17 @@ import * as $runtime from "../runtime/library";
* @param timestamp
*/
export const countDistinctRecipientsInTimeRange: (
text: string,
timestamp: Date,
timestamp: Date,
text: string,
timestamp: Date,
timestamp: Date,
) => $runtime.TypedSql<
countDistinctRecipientsInTimeRange.Parameters,
countDistinctRecipientsInTimeRange.Result
countDistinctRecipientsInTimeRange.Parameters,
countDistinctRecipientsInTimeRange.Result
>;
export namespace countDistinctRecipientsInTimeRange {
export type Parameters = [text: string, timestamp: Date, timestamp: Date];
export type Result = {
count: bigint | null;
};
export type Parameters = [text: string, timestamp: Date, timestamp: Date];
export type Result = {
count: bigint | null;
};
}

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/edge.js");
exports.countDistinctRecipientsInTimeRange = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/edge.js";
export const countDistinctRecipientsInTimeRange = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
);

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/library");
exports.countDistinctRecipientsInTimeRange = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/library";
export const countDistinctRecipientsInTimeRange = /*#__PURE__*/ $mkFactory(
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
'SELECT COUNT(DISTINCT "subscriberId")\nFROM "Message" m\nJOIN "Campaign" c ON m."campaignId" = c.id\nWHERE c."organizationId" = $1\nAND m."createdAt" >= $2\nAND m."createdAt" <= $3;',
);

View File

@@ -3,8 +3,8 @@
"use strict";
exports.countDbSize = require("./countDbSize.edge.js").countDbSize;
exports.countDistinctRecipients =
require("./countDistinctRecipients.edge.js").countDistinctRecipients;
require("./countDistinctRecipients.edge.js").countDistinctRecipients;
exports.countDistinctRecipientsInTimeRange =
require("./countDistinctRecipientsInTimeRange.edge.js").countDistinctRecipientsInTimeRange;
require("./countDistinctRecipientsInTimeRange.edge.js").countDistinctRecipientsInTimeRange;
exports.subscriberGrowthQuery =
require("./subscriberGrowthQuery.edge.js").subscriberGrowthQuery;
require("./subscriberGrowthQuery.edge.js").subscriberGrowthQuery;

View File

@@ -3,8 +3,8 @@
"use strict";
exports.countDbSize = require("./countDbSize.js").countDbSize;
exports.countDistinctRecipients =
require("./countDistinctRecipients.js").countDistinctRecipients;
require("./countDistinctRecipients.js").countDistinctRecipients;
exports.countDistinctRecipientsInTimeRange =
require("./countDistinctRecipientsInTimeRange.js").countDistinctRecipientsInTimeRange;
require("./countDistinctRecipientsInTimeRange.js").countDistinctRecipientsInTimeRange;
exports.subscriberGrowthQuery =
require("./subscriberGrowthQuery.js").subscriberGrowthQuery;
require("./subscriberGrowthQuery.js").subscriberGrowthQuery;

View File

@@ -6,18 +6,18 @@ import * as $runtime from "../runtime/library";
* @param timestamp
*/
export const subscriberGrowthQuery: (
text: string,
timestamp: Date,
timestamp: Date,
text: string,
timestamp: Date,
timestamp: Date,
) => $runtime.TypedSql<
subscriberGrowthQuery.Parameters,
subscriberGrowthQuery.Result
subscriberGrowthQuery.Parameters,
subscriberGrowthQuery.Result
>;
export namespace subscriberGrowthQuery {
export type Parameters = [text: string, timestamp: Date, timestamp: Date];
export type Result = {
date: Date | null;
count: bigint | null;
};
export type Parameters = [text: string, timestamp: Date, timestamp: Date];
export type Result = {
date: Date | null;
count: bigint | null;
};
}

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/edge.js");
exports.subscriberGrowthQuery = /*#__PURE__*/ $mkFactory(
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/edge.js";
export const subscriberGrowthQuery = /*#__PURE__*/ $mkFactory(
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
);

View File

@@ -3,5 +3,5 @@
"use strict";
const { makeTypedQueryFactory: $mkFactory } = require("../runtime/library");
exports.subscriberGrowthQuery = /*#__PURE__*/ $mkFactory(
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
);

View File

@@ -2,5 +2,5 @@
/* eslint-disable */
import { makeTypedQueryFactory as $mkFactory } from "../runtime/library";
export const subscriberGrowthQuery = /*#__PURE__*/ $mkFactory(
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
'SELECT\nDATE_TRUNC(\'day\', "createdAt") as date,\nCOUNT(*) as count\nFROM "public"."Subscriber"\nWHERE "organizationId" = $1\nAND "createdAt" >= $2\nAND "createdAt" <= $3\nGROUP BY DATE_TRUNC(\'day\', "createdAt")\nORDER BY date ASC',
);

View File

@@ -1,99 +1,99 @@
import { hashPassword } from "../src/utils/auth"
import { prisma } from "../src/utils/prisma"
import { SmtpEncryption } from "./client"
import dayjs from "dayjs"
import { hashPassword } from "../src/utils/auth";
import { prisma } from "../src/utils/prisma";
import { SmtpEncryption } from "./client";
import dayjs from "dayjs";
async function seed() {
if (!(await prisma.organization.findFirst())) {
await prisma.organization.create({
data: {
name: "Test Organization",
description: "Test Description",
GeneralSettings: {
create: {},
},
EmailDeliverySettings: {
create: {
rateLimit: 100,
},
},
SmtpSettings: {
create: {
host: "smtp.test.com",
port: 587,
username: "test",
password: "test",
encryption: SmtpEncryption.STARTTLS,
},
},
},
})
}
if (!(await prisma.organization.findFirst())) {
await prisma.organization.create({
data: {
name: "Test Organization",
description: "Test Description",
GeneralSettings: {
create: {},
},
EmailDeliverySettings: {
create: {
rateLimit: 100,
},
},
SmtpSettings: {
create: {
host: "smtp.test.com",
port: 587,
username: "test",
password: "test",
encryption: SmtpEncryption.STARTTLS,
},
},
},
});
}
const orgId = (
await prisma.organization.findFirst({
orderBy: {
createdAt: "asc",
},
})
)?.id
const orgId = (
await prisma.organization.findFirst({
orderBy: {
createdAt: "asc",
},
})
)?.id;
if (!orgId) {
throw new Error("not reachable")
}
if (!orgId) {
throw new Error("not reachable");
}
if (!(await prisma.user.findFirst())) {
await prisma.user.create({
data: {
name: "Admin",
email: "admin@example.com",
password: await hashPassword("password123"),
UserOrganizations: {
create: {
organizationId: orgId,
},
},
},
})
}
if (!(await prisma.user.findFirst())) {
await prisma.user.create({
data: {
name: "Admin",
email: "admin@example.com",
password: await hashPassword("password123"),
UserOrganizations: {
create: {
organizationId: orgId,
},
},
},
});
}
// Create 5000 subscribers
const subscribers = Array.from({ length: 5000 }, (_, i) => ({
name: `Subscriber ${i + 1}`,
email: `subscriber${i + 1}@example.com`,
organizationId: orgId,
createdAt: dayjs().subtract(12, "days").toDate(),
}))
await prisma.subscriber.createMany({
data: subscribers,
skipDuplicates: true,
})
// Then 10 more for each day for 10 days
const now = new Date()
for (let d = 0; d < 10; d++) {
const day = dayjs(now)
.subtract(d + 1, "day")
.toDate()
// Create 5000 subscribers
const subscribers = Array.from({ length: 5000 }, (_, i) => ({
name: `Subscriber ${i + 1}`,
email: `subscriber${i + 1}@example.com`,
organizationId: orgId,
createdAt: dayjs().subtract(12, "days").toDate(),
}));
await prisma.subscriber.createMany({
data: subscribers,
skipDuplicates: true,
});
// Then 10 more for each day for 10 days
const now = new Date();
for (let d = 0; d < 10; d++) {
const day = dayjs(now)
.subtract(d + 1, "day")
.toDate();
const dailySubs = Array.from({ length: 10 }, (_, i) => ({
name: `DailySub ${d + 1}-${i + 1}`,
email: `dailysub${d + 1}-${i + 1}@example.com`,
organizationId: orgId,
createdAt: day,
updatedAt: day,
}))
await prisma.subscriber.createMany({
data: dailySubs,
skipDuplicates: true,
})
}
const dailySubs = Array.from({ length: 10 }, (_, i) => ({
name: `DailySub ${d + 1}-${i + 1}`,
email: `dailysub${d + 1}-${i + 1}@example.com`,
organizationId: orgId,
createdAt: day,
updatedAt: day,
}));
await prisma.subscriber.createMany({
data: dailySubs,
skipDuplicates: true,
});
}
}
seed()
.then(async () => {
await prisma.$disconnect()
})
.catch(async (e) => {
console.error(e)
await prisma.$disconnect()
})
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
});

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,249 +1,249 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { paginationSchema } from "../utils/schemas"
import { Prisma } from "../../prisma/client"
import { resolveProps } from "../utils/pProps"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client";
import { resolveProps } from "../utils/pProps";
export const listCampaigns = authProcedure
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const where: Prisma.CampaignWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ title: { contains: input.search, mode: "insensitive" } },
{ description: { contains: input.search, mode: "insensitive" } },
{ subject: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
}
const where: Prisma.CampaignWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ title: { contains: input.search, mode: "insensitive" } },
{ description: { contains: input.search, mode: "insensitive" } },
{ subject: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
};
const [total, campaigns] = await prisma.$transaction([
prisma.campaign.count({ where }),
prisma.campaign.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
include: {
Template: {
select: {
id: true,
name: true,
},
},
CampaignLists: {
include: {
List: {
select: {
id: true,
name: true,
},
},
},
},
_count: {
select: {
Messages: true,
},
},
},
}),
])
const [total, campaigns] = await prisma.$transaction([
prisma.campaign.count({ where }),
prisma.campaign.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
include: {
Template: {
select: {
id: true,
name: true,
},
},
CampaignLists: {
include: {
List: {
select: {
id: true,
name: true,
},
},
},
},
_count: {
select: {
Messages: true,
},
},
},
}),
]);
const totalPages = Math.ceil(total / input.perPage)
const totalPages = Math.ceil(total / input.perPage);
return {
campaigns,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
}
})
return {
campaigns,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
};
});
export const getCampaign = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
id: z.string(),
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const campaign = await prisma.campaign.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
include: {
Template: true,
CampaignLists: {
include: {
List: true,
},
},
},
})
const campaign = await prisma.campaign.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
include: {
Template: true,
CampaignLists: {
include: {
List: true,
},
},
},
});
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
})
}
if (!campaign) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Campaign not found",
});
}
const listSubscribers = await prisma.listSubscriber.findMany({
where: {
listId: {
in: campaign.CampaignLists.map((cl) => cl.listId),
},
unsubscribedAt: null,
},
select: {
id: true,
},
distinct: ["subscriberId"],
})
const listSubscribers = await prisma.listSubscriber.findMany({
where: {
listId: {
in: campaign.CampaignLists.map((cl) => cl.listId),
},
unsubscribedAt: null,
},
select: {
id: true,
},
distinct: ["subscriberId"],
});
// Add the count to each list for backward compatibility
const campaignWithCounts = {
...campaign,
CampaignLists: await Promise.all(
campaign.CampaignLists.map(async (cl) => {
const count = await prisma.listSubscriber.count({
where: {
listId: cl.listId,
unsubscribedAt: null,
},
})
// Add the count to each list for backward compatibility
const campaignWithCounts = {
...campaign,
CampaignLists: await Promise.all(
campaign.CampaignLists.map(async (cl) => {
const count = await prisma.listSubscriber.count({
where: {
listId: cl.listId,
unsubscribedAt: null,
},
});
return {
...cl,
List: {
...cl.List,
_count: {
ListSubscribers: count,
},
},
}
})
),
// Add the unique subscriber count directly to the campaign object
uniqueRecipientCount: listSubscribers.length,
}
return {
...cl,
List: {
...cl.List,
_count: {
ListSubscribers: count,
},
},
};
}),
),
// Add the unique subscriber count directly to the campaign object
uniqueRecipientCount: listSubscribers.length,
};
const promises = {
totalMessages: prisma.message.count({
where: {
campaignId: campaign.id,
},
}),
queuedMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "QUEUED",
},
}),
pendingMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "PENDING",
},
}),
sentMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["SENT", "OPENED", "CLICKED"],
},
},
}),
failedMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "FAILED",
},
}),
processed: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
not: "QUEUED",
},
},
}),
clicked: prisma.message.count({
where: {
campaignId: campaign.id,
status: "CLICKED",
},
}),
opened: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["OPENED", "CLICKED"],
},
},
}),
}
const promises = {
totalMessages: prisma.message.count({
where: {
campaignId: campaign.id,
},
}),
queuedMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "QUEUED",
},
}),
pendingMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "PENDING",
},
}),
sentMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["SENT", "OPENED", "CLICKED"],
},
},
}),
failedMessages: prisma.message.count({
where: {
campaignId: campaign.id,
status: "FAILED",
},
}),
processed: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
not: "QUEUED",
},
},
}),
clicked: prisma.message.count({
where: {
campaignId: campaign.id,
status: "CLICKED",
},
}),
opened: prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["OPENED", "CLICKED"],
},
},
}),
};
const result = await resolveProps(promises)
const result = await resolveProps(promises);
return {
campaign: campaignWithCounts,
stats: {
totalMessages: result.totalMessages,
queuedMessages: result.queuedMessages,
pendingMessages: result.pendingMessages,
sentMessages: result.sentMessages,
failedMessages: result.failedMessages,
processed: result.processed,
clicked: result.clicked,
opened: result.opened,
clickRate:
result.sentMessages > 0
? (result.clicked / result.sentMessages) * 100
: 0,
openRate:
result.sentMessages > 0
? (result.opened / result.sentMessages) * 100
: 0,
},
}
})
return {
campaign: campaignWithCounts,
stats: {
totalMessages: result.totalMessages,
queuedMessages: result.queuedMessages,
pendingMessages: result.pendingMessages,
sentMessages: result.sentMessages,
failedMessages: result.failedMessages,
processed: result.processed,
clicked: result.clicked,
opened: result.opened,
clickRate:
result.sentMessages > 0
? (result.clicked / result.sentMessages) * 100
: 0,
openRate:
result.sentMessages > 0
? (result.opened / result.sentMessages) * 100
: 0,
},
};
});

View File

@@ -1,23 +1,23 @@
import { router } from "../trpc"
import { router } from "../trpc";
import {
createCampaign,
updateCampaign,
deleteCampaign,
startCampaign,
cancelCampaign,
sendTestEmail,
duplicateCampaign,
} from "./mutation"
import { getCampaign, listCampaigns } from "./query"
createCampaign,
updateCampaign,
deleteCampaign,
startCampaign,
cancelCampaign,
sendTestEmail,
duplicateCampaign,
} from "./mutation";
import { getCampaign, listCampaigns } from "./query";
export const campaignRouter = router({
create: createCampaign,
update: updateCampaign,
delete: deleteCampaign,
get: getCampaign,
list: listCampaigns,
start: startCampaign,
cancel: cancelCampaign,
sendTestEmail,
duplicate: duplicateCampaign,
})
create: createCampaign,
update: updateCampaign,
delete: deleteCampaign,
get: getCampaign,
list: listCampaigns,
start: startCampaign,
cancel: cancelCampaign,
sendTestEmail,
duplicate: duplicateCampaign,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,190 +1,190 @@
import pMap from "p-map"
import { Mailer } from "../lib/Mailer"
import { logger } from "../utils/logger"
import { prisma } from "../utils/prisma"
import pMap from "p-map";
import { Mailer } from "../lib/Mailer";
import { logger } from "../utils/logger";
import { prisma } from "../utils/prisma";
import { cronJob } from "./cron.utils"
import { subSeconds } from "date-fns"
import { cronJob } from "./cron.utils";
import { subSeconds } from "date-fns";
export const sendMessagesCron = cronJob("sendMessages", async () => {
const organizations = await prisma.organization.findMany()
const organizations = await prisma.organization.findMany();
for (const organization of organizations) {
const [smtpSettings, emailSettings, generalSettings] = await Promise.all([
prisma.smtpSettings.findFirst({
where: { organizationId: organization.id },
}),
prisma.emailDeliverySettings.findFirst({
where: { organizationId: organization.id },
}),
prisma.generalSettings.findFirst({
where: { organizationId: organization.id },
}),
])
for (const organization of organizations) {
const [smtpSettings, emailSettings, generalSettings] = await Promise.all([
prisma.smtpSettings.findFirst({
where: { organizationId: organization.id },
}),
prisma.emailDeliverySettings.findFirst({
where: { organizationId: organization.id },
}),
prisma.generalSettings.findFirst({
where: { organizationId: organization.id },
}),
]);
if (!smtpSettings || !emailSettings) {
logger.warn(
`Required settings not found for org ${organization.id}, skipping`
)
continue
}
if (!smtpSettings || !emailSettings) {
logger.warn(
`Required settings not found for org ${organization.id}, skipping`,
);
continue;
}
const windowStart = subSeconds(new Date(), emailSettings.rateWindow)
const sentInWindow = await prisma.message.count({
where: {
status: {
in: ["PENDING", "SENT", "OPENED", "CLICKED"],
},
sentAt: {
gte: windowStart,
},
Campaign: {
organizationId: organization.id,
},
},
})
const windowStart = subSeconds(new Date(), emailSettings.rateWindow);
const sentInWindow = await prisma.message.count({
where: {
status: {
in: ["PENDING", "SENT", "OPENED", "CLICKED"],
},
sentAt: {
gte: windowStart,
},
Campaign: {
organizationId: organization.id,
},
},
});
const availableSlots = Math.max(0, emailSettings.rateLimit - sentInWindow)
const availableSlots = Math.max(0, emailSettings.rateLimit - sentInWindow);
if (availableSlots === 0) {
continue
}
if (availableSlots === 0) {
continue;
}
// Message status is now independent of campaign status.
// This allows retrying individual messages even for completed campaigns.
// We only filter by QUEUED and RETRYING message statuses.
const messages = await prisma.message.findMany({
where: {
Campaign: {
organizationId: organization.id,
},
OR: [
{ status: "QUEUED" },
{
status: "RETRYING",
lastTriedAt: {
lte: subSeconds(new Date(), emailSettings.retryDelay),
},
},
],
},
include: {
Subscriber: {
select: {
email: true,
},
},
Campaign: {
select: {
subject: true,
},
},
},
take: availableSlots,
})
// Message status is now independent of campaign status.
// This allows retrying individual messages even for completed campaigns.
// We only filter by QUEUED and RETRYING message statuses.
const messages = await prisma.message.findMany({
where: {
Campaign: {
organizationId: organization.id,
},
OR: [
{ status: "QUEUED" },
{
status: "RETRYING",
lastTriedAt: {
lte: subSeconds(new Date(), emailSettings.retryDelay),
},
},
],
},
include: {
Subscriber: {
select: {
email: true,
},
},
Campaign: {
select: {
subject: true,
},
},
},
take: availableSlots,
});
const noMoreRetryingMessages = await prisma.message.count({
where: {
status: "RETRYING",
Campaign: {
organizationId: organization.id,
},
},
})
const noMoreRetryingMessages = await prisma.message.count({
where: {
status: "RETRYING",
Campaign: {
organizationId: organization.id,
},
},
});
if (!messages.length && noMoreRetryingMessages === 0) {
await prisma.campaign.updateMany({
where: {
status: "SENDING",
organizationId: organization.id,
Messages: {
every: {
status: {
in: ["SENT", "FAILED", "OPENED", "CLICKED", "CANCELLED"],
},
},
},
},
data: {
status: "COMPLETED",
completedAt: new Date(),
},
})
continue
}
if (!messages.length && noMoreRetryingMessages === 0) {
await prisma.campaign.updateMany({
where: {
status: "SENDING",
organizationId: organization.id,
Messages: {
every: {
status: {
in: ["SENT", "FAILED", "OPENED", "CLICKED", "CANCELLED"],
},
},
},
},
data: {
status: "COMPLETED",
completedAt: new Date(),
},
});
continue;
}
logger.info(`Found ${messages.length} messages to send`)
logger.info(`Found ${messages.length} messages to send`);
const mailer = new Mailer({
...smtpSettings,
timeout: emailSettings.connectionTimeout,
})
const mailer = new Mailer({
...smtpSettings,
timeout: emailSettings.connectionTimeout,
});
const fromName =
smtpSettings.fromName ?? generalSettings?.defaultFromName ?? ""
const fromEmail =
smtpSettings.fromEmail ?? generalSettings?.defaultFromEmail ?? ""
const fromName =
smtpSettings.fromName ?? generalSettings?.defaultFromName ?? "";
const fromEmail =
smtpSettings.fromEmail ?? generalSettings?.defaultFromEmail ?? "";
if (!fromName || !fromEmail) {
logger.warn("No from name or email found, message will not be sent")
continue
}
if (!fromName || !fromEmail) {
logger.warn("No from name or email found, message will not be sent");
continue;
}
await pMap(
messages,
async (message) => {
if (!message.Campaign.subject) {
logger.warn("No subject found for campaign")
return
}
await pMap(
messages,
async (message) => {
if (!message.Campaign.subject) {
logger.warn("No subject found for campaign");
return;
}
await prisma.message.update({
where: { id: message.id },
data: { status: "PENDING" },
})
await prisma.message.update({
where: { id: message.id },
data: { status: "PENDING" },
});
try {
const result = await mailer.sendEmail({
to: message.Subscriber.email,
subject: message.Campaign.subject,
html: message.content,
from: `${fromName} <${fromEmail}>`,
})
try {
const result = await mailer.sendEmail({
to: message.Subscriber.email,
subject: message.Campaign.subject,
html: message.content,
from: `${fromName} <${fromEmail}>`,
});
await prisma.message.update({
where: { id: message.id },
data: {
messageId: result.messageId,
status: result.success
? "SENT"
: message.tries >= emailSettings.maxRetries
? "FAILED"
: "RETRYING",
sentAt: result.success ? new Date() : undefined,
tries: {
increment: 1,
},
lastTriedAt: new Date(),
},
})
} catch (error) {
await prisma.message.update({
where: { id: message.id },
data: {
status:
message.tries >= emailSettings.maxRetries
? "FAILED"
: "RETRYING",
error: String(error),
tries: {
increment: 1,
},
lastTriedAt: new Date(),
},
})
}
},
{ concurrency: emailSettings.concurrency }
)
}
})
await prisma.message.update({
where: { id: message.id },
data: {
messageId: result.messageId,
status: result.success
? "SENT"
: message.tries >= emailSettings.maxRetries
? "FAILED"
: "RETRYING",
sentAt: result.success ? new Date() : undefined,
tries: {
increment: 1,
},
lastTriedAt: new Date(),
},
});
} catch (error) {
await prisma.message.update({
where: { id: message.id },
data: {
status:
message.tries >= emailSettings.maxRetries
? "FAILED"
: "RETRYING",
error: String(error),
tries: {
increment: 1,
},
lastTriedAt: new Date(),
},
});
}
},
{ concurrency: emailSettings.concurrency },
);
}
});

View File

@@ -1,145 +1,145 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { MessageStatus } from "../../prisma/client"
import { countDbSize, subscriberGrowthQuery } from "../../prisma/client/sql"
import pMap from "p-map"
import { subMonths } from "date-fns"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { MessageStatus } from "../../prisma/client";
import { countDbSize, subscriberGrowthQuery } from "../../prisma/client/sql";
import pMap from "p-map";
import { subMonths } from "date-fns";
export const getDashboardStats = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const from = subMonths(new Date(), 6)
const to = new Date()
const from = subMonths(new Date(), 6);
const to = new Date();
const dateFilter = {
...(from && to
? {
createdAt: {
gte: from,
lte: to,
},
}
: {}),
}
const dateFilter = {
...(from && to
? {
createdAt: {
gte: from,
lte: to,
},
}
: {}),
};
const [messageStats, recentCampaigns, subscriberGrowth, [dbSize]] =
await Promise.all([
// Message delivery stats
prisma.message.groupBy({
by: ["status"],
where: {
Campaign: {
organizationId: input.organizationId,
},
...dateFilter,
},
_count: true,
}),
const [messageStats, recentCampaigns, subscriberGrowth, [dbSize]] =
await Promise.all([
// Message delivery stats
prisma.message.groupBy({
by: ["status"],
where: {
Campaign: {
organizationId: input.organizationId,
},
...dateFilter,
},
_count: true,
}),
// Recent campaigns with stats
prisma.campaign.findMany({
where: {
organizationId: input.organizationId,
status: "COMPLETED",
...dateFilter,
},
include: {
_count: {
select: {
Messages: true,
},
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: 5,
}),
// Recent campaigns with stats
prisma.campaign.findMany({
where: {
organizationId: input.organizationId,
status: "COMPLETED",
...dateFilter,
},
include: {
_count: {
select: {
Messages: true,
},
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: 5,
}),
// Subscriber growth over time
prisma.$queryRawTyped(
subscriberGrowthQuery(input.organizationId, from, to)
),
// Subscriber growth over time
prisma.$queryRawTyped(
subscriberGrowthQuery(input.organizationId, from, to),
),
prisma.$queryRawTyped(countDbSize(input.organizationId)),
])
prisma.$queryRawTyped(countDbSize(input.organizationId)),
]);
// Process message stats
const messageStatsByStatus = messageStats.reduce(
(acc, stat) => {
acc[stat.status as MessageStatus] = stat._count
return acc
},
{} as Record<MessageStatus, number>
)
// Process message stats
const messageStatsByStatus = messageStats.reduce(
(acc, stat) => {
acc[stat.status as MessageStatus] = stat._count;
return acc;
},
{} as Record<MessageStatus, number>,
);
// Process recent campaigns
const processedCampaigns = await pMap(recentCampaigns, async (campaign) => {
const [deliveredCount, totalCount] = await Promise.all([
prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["SENT", "OPENED", "CLICKED"],
},
},
}),
prisma.message.count({
where: { campaignId: campaign.id },
}),
])
// Process recent campaigns
const processedCampaigns = await pMap(recentCampaigns, async (campaign) => {
const [deliveredCount, totalCount] = await Promise.all([
prisma.message.count({
where: {
campaignId: campaign.id,
status: {
in: ["SENT", "OPENED", "CLICKED"],
},
},
}),
prisma.message.count({
where: { campaignId: campaign.id },
}),
]);
return {
id: campaign.id,
title: campaign.title,
status: campaign.status,
completedAt: campaign.completedAt,
deliveryRate: totalCount > 0 ? (deliveredCount / totalCount) * 100 : 0,
totalMessages: totalCount,
sentMessages: deliveredCount,
createdAt: campaign.createdAt,
}
})
return {
id: campaign.id,
title: campaign.title,
status: campaign.status,
completedAt: campaign.completedAt,
deliveryRate: totalCount > 0 ? (deliveredCount / totalCount) * 100 : 0,
totalMessages: totalCount,
sentMessages: deliveredCount,
createdAt: campaign.createdAt,
};
});
const subscriberGrowthCumulative: { date: Date; count: number }[] = []
const subscriberGrowthCumulative: { date: Date; count: number }[] = [];
for (let i = 0; i < subscriberGrowth.length; i++) {
const point = subscriberGrowth[i]
for (let i = 0; i < subscriberGrowth.length; i++) {
const point = subscriberGrowth[i];
if (!point?.date) {
continue
}
if (!point?.date) {
continue;
}
const prev = subscriberGrowthCumulative[i - 1]?.count || 0
const prev = subscriberGrowthCumulative[i - 1]?.count || 0;
subscriberGrowthCumulative.push({
date: point.date,
count: Number(point.count) + Number(prev),
})
}
subscriberGrowthCumulative.push({
date: point.date,
count: Number(point.count) + Number(prev),
});
}
return {
messageStats: messageStatsByStatus,
recentCampaigns: processedCampaigns,
subscriberGrowth: subscriberGrowthCumulative,
dbSize,
}
})
return {
messageStats: messageStatsByStatus,
recentCampaigns: processedCampaigns,
subscriberGrowth: subscriberGrowthCumulative,
dbSize,
};
});

View File

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

View File

@@ -1,44 +1,44 @@
export type * from "./app"
export type * from "../prisma/client"
export type * from "./types"
export type * from "./app";
export type * from "../prisma/client";
export type * from "./types";
import { app } from "./app"
import { initializeCronJobs } from "./cron/cron"
import { prisma } from "./utils/prisma"
import { app } from "./app";
import { initializeCronJobs } from "./cron/cron";
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 () => {
console.log("Connected to database")
console.log("Connected to database");
// For backwards compatibility, set all messages that have campaign status === "CANCELLED" to "CANCELLED"
await prisma.message.updateMany({
where: {
Campaign: {
status: "CANCELLED",
},
status: {
in: ["QUEUED", "PENDING", "RETRYING"],
},
},
data: {
status: "CANCELLED",
},
})
// For backwards compatibility, set all messages that have campaign status === "CANCELLED" to "CANCELLED"
await prisma.message.updateMany({
where: {
Campaign: {
status: "CANCELLED",
},
status: {
in: ["QUEUED", "PENDING", "RETRYING"],
},
},
data: {
status: "CANCELLED",
},
});
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`)
})
})
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
});
// Handle graceful shutdown
const shutdown = () => {
console.log("Shutting down cron jobs...")
cronController.stop()
process.exit(0)
}
console.log("Shutting down cron jobs...");
cronController.stop();
process.exit(0);
};
process.on("SIGINT", shutdown)
process.on("SIGTERM", shutdown)
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

View File

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

View File

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

View File

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

View File

@@ -1,123 +1,123 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { paginationSchema } from "../utils/schemas"
import { Prisma } from "../../prisma/client"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client";
export const getLists = authProcedure
.input(
z
.object({
organizationId: z.string(),
})
.merge(paginationSchema)
)
.query(async ({ ctx, input }) => {
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z
.object({
organizationId: z.string(),
})
.merge(paginationSchema),
)
.query(async ({ ctx, input }) => {
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
const where: Prisma.ListWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" } },
{ description: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
}
const where: Prisma.ListWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" } },
{ description: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
};
const [total, lists] = await Promise.all([
prisma.list.count({ where }),
prisma.list.findMany({
where,
include: {
_count: {
select: {
ListSubscribers: {
where: {
unsubscribedAt: null,
},
},
},
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
])
const [total, lists] = await Promise.all([
prisma.list.count({ where }),
prisma.list.findMany({
where,
include: {
_count: {
select: {
ListSubscribers: {
where: {
unsubscribedAt: null,
},
},
},
},
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
]);
const totalPages = Math.ceil(total / input.perPage)
const totalPages = Math.ceil(total / input.perPage);
return {
lists,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
}
})
return {
lists,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
};
});
export const getList = authProcedure
.input(
z.object({
id: z.string(),
})
)
.query(async ({ ctx, input }) => {
const list = await prisma.list.findUnique({
where: {
id: input.id,
},
include: {
Organization: true,
ListSubscribers: {
include: {
Subscriber: true,
},
},
},
})
.input(
z.object({
id: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const list = await prisma.list.findUnique({
where: {
id: input.id,
},
include: {
Organization: true,
ListSubscribers: {
include: {
Subscriber: true,
},
},
},
});
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
})
}
if (!list) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List not found",
});
}
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: list.organizationId,
},
})
// Verify user has access to organization
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: list.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this list",
});
}
return list
})
return list;
});

View File

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

View File

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

View File

@@ -1,159 +1,159 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { paginationSchema } from "../utils/schemas"
import { Prisma } from "../../prisma/client"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client";
const messageStatusEnum = z.enum([
"QUEUED",
"PENDING",
"SENT",
"OPENED",
"CLICKED",
"FAILED",
"RETRYING",
])
"QUEUED",
"PENDING",
"SENT",
"OPENED",
"CLICKED",
"FAILED",
"RETRYING",
]);
export const listMessages = authProcedure
.input(
z
.object({
organizationId: z.string(),
campaignId: z.string().optional(),
subscriberId: z.string().optional(),
status: messageStatusEnum.optional(),
})
.merge(paginationSchema)
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z
.object({
organizationId: z.string(),
campaignId: z.string().optional(),
subscriberId: z.string().optional(),
status: messageStatusEnum.optional(),
})
.merge(paginationSchema),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const where: Prisma.MessageWhereInput = {
Campaign: {
organizationId: input.organizationId,
},
...(input.campaignId ? { campaignId: input.campaignId } : {}),
...(input.subscriberId ? { subscriberId: input.subscriberId } : {}),
...(input.status ? { status: input.status } : {}),
...(input.search
? {
OR: [
{
Subscriber: {
name: {
contains: input.search,
mode: "insensitive",
},
},
},
{
Subscriber: {
email: {
contains: input.search,
mode: "insensitive",
},
},
},
{
Campaign: {
title: {
contains: input.search,
mode: "insensitive",
},
},
},
],
}
: {}),
}
const where: Prisma.MessageWhereInput = {
Campaign: {
organizationId: input.organizationId,
},
...(input.campaignId ? { campaignId: input.campaignId } : {}),
...(input.subscriberId ? { subscriberId: input.subscriberId } : {}),
...(input.status ? { status: input.status } : {}),
...(input.search
? {
OR: [
{
Subscriber: {
name: {
contains: input.search,
mode: "insensitive",
},
},
},
{
Subscriber: {
email: {
contains: input.search,
mode: "insensitive",
},
},
},
{
Campaign: {
title: {
contains: input.search,
mode: "insensitive",
},
},
},
],
}
: {}),
};
const [total, messages] = await Promise.all([
prisma.message.count({ where }),
prisma.message.findMany({
where,
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
include: {
Campaign: {
select: {
id: true,
title: true,
},
},
Subscriber: {
select: {
id: true,
email: true,
name: true,
},
},
},
}),
])
const [total, messages] = await Promise.all([
prisma.message.count({ where }),
prisma.message.findMany({
where,
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
include: {
Campaign: {
select: {
id: true,
title: true,
},
},
Subscriber: {
select: {
id: true,
email: true,
name: true,
},
},
},
}),
]);
const totalPages = Math.ceil(total / input.perPage)
const totalPages = Math.ceil(total / input.perPage);
return {
messages,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
}
})
return {
messages,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
};
});
export const getMessage = authProcedure
.input(
z.object({
id: z.string(),
})
)
.query(async ({ input }) => {
const message = await prisma.message.findUnique({
where: {
id: input.id,
},
include: {
Campaign: {
select: {
id: true,
title: true,
content: true,
},
},
Subscriber: {
select: {
id: true,
email: true,
name: true,
},
},
},
})
.input(
z.object({
id: z.string(),
}),
)
.query(async ({ input }) => {
const message = await prisma.message.findUnique({
where: {
id: input.id,
},
include: {
Campaign: {
select: {
id: true,
title: true,
content: true,
},
},
Subscriber: {
select: {
id: true,
email: true,
name: true,
},
},
},
});
if (!message) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Message not found",
})
}
if (!message) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Message not found",
});
}
return message
})
return message;
});

View File

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

View File

@@ -1,84 +1,84 @@
import { prisma } from "../utils/prisma"
import { MessageStatus } from "../../prisma/client"
import { prisma } from "../utils/prisma";
import { MessageStatus } from "../../prisma/client";
interface MessageQueryOptions {
campaignId?: string
organizationId: string
status: MessageStatus | MessageStatus[]
campaignId?: string;
organizationId: string;
status: MessageStatus | MessageStatus[];
}
export async function findMessagesByStatus({
campaignId,
organizationId,
status,
campaignId,
organizationId,
status,
}: MessageQueryOptions) {
return prisma.message.findMany({
where: {
...(campaignId && { campaignId }),
Campaign: {
organizationId,
},
status: Array.isArray(status) ? { in: status } : status,
},
})
return prisma.message.findMany({
where: {
...(campaignId && { campaignId }),
Campaign: {
organizationId,
},
status: Array.isArray(status) ? { in: status } : status,
},
});
}
interface CampaignMessagesQueryOptions {
campaignId: string
organizationId: string
campaignId: string;
organizationId: string;
}
export async function getDeliveredMessages({
campaignId,
organizationId,
campaignId,
organizationId,
}: CampaignMessagesQueryOptions) {
return findMessagesByStatus({
campaignId,
organizationId,
status: ["SENT", "CLICKED", "OPENED"],
})
return findMessagesByStatus({
campaignId,
organizationId,
status: ["SENT", "CLICKED", "OPENED"],
});
}
export async function getFailedMessages({
campaignId,
organizationId,
campaignId,
organizationId,
}: CampaignMessagesQueryOptions) {
return findMessagesByStatus({
campaignId,
organizationId,
status: "FAILED",
})
return findMessagesByStatus({
campaignId,
organizationId,
status: "FAILED",
});
}
export async function getOpenedMessages({
campaignId,
organizationId,
campaignId,
organizationId,
}: CampaignMessagesQueryOptions) {
return findMessagesByStatus({
campaignId,
organizationId,
status: ["OPENED", "CLICKED"], // Clicked implies opened
})
return findMessagesByStatus({
campaignId,
organizationId,
status: ["OPENED", "CLICKED"], // Clicked implies opened
});
}
export async function getClickedMessages({
campaignId,
organizationId,
campaignId,
organizationId,
}: CampaignMessagesQueryOptions) {
return findMessagesByStatus({
campaignId,
organizationId,
status: "CLICKED",
})
return findMessagesByStatus({
campaignId,
organizationId,
status: "CLICKED",
});
}
export async function getQueuedMessages({
campaignId,
organizationId,
campaignId,
organizationId,
}: CampaignMessagesQueryOptions) {
return findMessagesByStatus({
campaignId,
organizationId,
status: "QUEUED",
})
return findMessagesByStatus({
campaignId,
organizationId,
status: "QUEUED",
});
}

View File

@@ -1,98 +1,98 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import fs from "fs/promises"
import { TRPCError } from "@trpc/server"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import fs from "fs/promises";
import { TRPCError } from "@trpc/server";
const createOrganizationSchema = z.object({
name: z.string().min(1, "Organization name is required"),
description: z.string().optional(),
})
name: z.string().min(1, "Organization name is required"),
description: z.string().optional(),
});
export const createOrganization = authProcedure
.input(createOrganizationSchema)
.mutation(async ({ ctx, input }) => {
const organization = await prisma.organization.create({
data: {
name: input.name,
description: input.description,
UserOrganizations: {
create: {
userId: ctx.user.id,
},
},
Templates: {
createMany: {
data: [
{
name: "Newsletter",
content: await fs.readFile(
"templates/newsletter.html",
"utf-8"
),
},
],
},
},
EmailDeliverySettings: {
// Default settings
create: {},
},
GeneralSettings: {
// Default settings
create: {},
},
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
},
})
.input(createOrganizationSchema)
.mutation(async ({ ctx, input }) => {
const organization = await prisma.organization.create({
data: {
name: input.name,
description: input.description,
UserOrganizations: {
create: {
userId: ctx.user.id,
},
},
Templates: {
createMany: {
data: [
{
name: "Newsletter",
content: await fs.readFile(
"templates/newsletter.html",
"utf-8",
),
},
],
},
},
EmailDeliverySettings: {
// Default settings
create: {},
},
GeneralSettings: {
// Default settings
create: {},
},
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
},
});
return {
organization,
}
})
return {
organization,
};
});
const updateOrganizationSchema = z.object({
id: z.string(),
name: z.string().min(1, "Organization name is required"),
description: z.string().optional(),
})
id: z.string(),
name: z.string().min(1, "Organization name is required"),
description: z.string().optional(),
});
export const updateOrganization = authProcedure
.input(updateOrganizationSchema)
.mutation(async ({ ctx, input }) => {
const userOrg = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.id,
},
})
.input(updateOrganizationSchema)
.mutation(async ({ ctx, input }) => {
const userOrg = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.id,
},
});
if (!userOrg) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to update this organization.",
})
}
if (!userOrg) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to update this organization.",
});
}
const updatedOrganization = await prisma.organization.update({
where: { id: input.id },
data: {
name: input.name,
description: input.description,
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
},
})
const updatedOrganization = await prisma.organization.update({
where: { id: input.id },
data: {
name: input.name,
description: input.description,
},
select: {
id: true,
name: true,
description: true,
createdAt: true,
updatedAt: true,
},
});
return { organization: updatedOrganization }
})
return { organization: updatedOrganization };
});

View File

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

View File

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

View File

@@ -1,351 +1,351 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { randomBytes } from "crypto"
import { Mailer } from "../lib/Mailer"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { randomBytes } from "crypto";
import { Mailer } from "../lib/Mailer";
const smtpSchema = z.object({
organizationId: z.string(),
host: z.string().min(1, "SMTP host is required"),
port: z.number().min(1, "Port is required"),
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
fromEmail: z.string().email("Invalid email address").optional(),
fromName: z.string().optional(),
secure: z.boolean(),
encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]),
})
organizationId: z.string(),
host: z.string().min(1, "SMTP host is required"),
port: z.number().min(1, "Port is required"),
username: z.string().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
fromEmail: z.string().email("Invalid email address").optional(),
fromName: z.string().optional(),
secure: z.boolean(),
encryption: z.enum(["STARTTLS", "SSL_TLS", "NONE"]),
});
export const updateSmtp = authProcedure
.input(smtpSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(smtpSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const smtpSettings = await prisma.smtpSettings.findFirst({
where: {
Organization: {
id: input.organizationId,
UserOrganizations: {
some: {
userId: ctx.user.id,
},
},
},
},
})
const smtpSettings = await prisma.smtpSettings.findFirst({
where: {
Organization: {
id: input.organizationId,
UserOrganizations: {
some: {
userId: ctx.user.id,
},
},
},
},
});
const settings = await prisma.smtpSettings.upsert({
where: {
id: smtpSettings ? smtpSettings.id : "create-happens",
},
create: {
host: input.host,
port: input.port,
username: input.username,
password: input.password,
fromEmail: input.fromEmail,
fromName: input.fromName,
secure: input.secure,
encryption: input.encryption,
organizationId: input.organizationId,
},
update: {
host: input.host,
port: input.port,
username: input.username,
password: input.password,
fromEmail: input.fromEmail,
fromName: input.fromName,
secure: input.secure,
encryption: input.encryption,
},
})
const settings = await prisma.smtpSettings.upsert({
where: {
id: smtpSettings ? smtpSettings.id : "create-happens",
},
create: {
host: input.host,
port: input.port,
username: input.username,
password: input.password,
fromEmail: input.fromEmail,
fromName: input.fromName,
secure: input.secure,
encryption: input.encryption,
organizationId: input.organizationId,
},
update: {
host: input.host,
port: input.port,
username: input.username,
password: input.password,
fromEmail: input.fromEmail,
fromName: input.fromName,
secure: input.secure,
encryption: input.encryption,
},
});
return { settings }
})
return { settings };
});
const emailDeliverySchema = z.object({
organizationId: z.string(),
rateLimit: z.number().min(1, "Rate limit is required"),
rateWindow: z.number().min(1, "Rate window is required"),
maxRetries: z.number().min(0, "Max retries must be 0 or greater"),
retryDelay: z.number().min(1, "Retry delay is required"),
concurrency: z.number().min(1, "Concurrency must be at least 1"),
connectionTimeout: z.number().min(1, "Connection timeout must be at least 1"),
})
organizationId: z.string(),
rateLimit: z.number().min(1, "Rate limit is required"),
rateWindow: z.number().min(1, "Rate window is required"),
maxRetries: z.number().min(0, "Max retries must be 0 or greater"),
retryDelay: z.number().min(1, "Retry delay is required"),
concurrency: z.number().min(1, "Concurrency must be at least 1"),
connectionTimeout: z.number().min(1, "Connection timeout must be at least 1"),
});
export const updateEmailDelivery = authProcedure
.input(emailDeliverySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(emailDeliverySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const settings = await prisma.emailDeliverySettings.upsert({
where: {
organizationId: input.organizationId,
},
create: {
rateLimit: input.rateLimit,
rateWindow: input.rateWindow,
maxRetries: input.maxRetries,
retryDelay: input.retryDelay,
concurrency: input.concurrency,
connectionTimeout: input.connectionTimeout,
organizationId: input.organizationId,
},
update: {
rateLimit: input.rateLimit,
rateWindow: input.rateWindow,
maxRetries: input.maxRetries,
retryDelay: input.retryDelay,
concurrency: input.concurrency,
connectionTimeout: input.connectionTimeout,
},
})
const settings = await prisma.emailDeliverySettings.upsert({
where: {
organizationId: input.organizationId,
},
create: {
rateLimit: input.rateLimit,
rateWindow: input.rateWindow,
maxRetries: input.maxRetries,
retryDelay: input.retryDelay,
concurrency: input.concurrency,
connectionTimeout: input.connectionTimeout,
organizationId: input.organizationId,
},
update: {
rateLimit: input.rateLimit,
rateWindow: input.rateWindow,
maxRetries: input.maxRetries,
retryDelay: input.retryDelay,
concurrency: input.concurrency,
connectionTimeout: input.connectionTimeout,
},
});
return { settings }
})
return { settings };
});
const generalSettingsSchema = z.object({
organizationId: z.string(),
defaultFromEmail: z.string().email().optional().or(z.literal("")),
defaultFromName: z.string().optional(),
baseURL: z.string().url().optional().or(z.literal("")),
cleanupInterval: z.coerce.number().int().min(1).optional(),
})
organizationId: z.string(),
defaultFromEmail: z.string().email().optional().or(z.literal("")),
defaultFromName: z.string().optional(),
baseURL: z.string().url().optional().or(z.literal("")),
cleanupInterval: z.coerce.number().int().min(1).optional(),
});
export const updateGeneral = authProcedure
.input(generalSettingsSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(generalSettingsSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const settings = await prisma.generalSettings.upsert({
where: {
organizationId: input.organizationId,
},
create: {
defaultFromEmail: input.defaultFromEmail,
defaultFromName: input.defaultFromName,
baseURL: input.baseURL,
cleanupInterval: input.cleanupInterval,
organizationId: input.organizationId,
},
update: {
defaultFromEmail: input.defaultFromEmail,
defaultFromName: input.defaultFromName,
baseURL: input.baseURL,
cleanupInterval: input.cleanupInterval,
},
})
const settings = await prisma.generalSettings.upsert({
where: {
organizationId: input.organizationId,
},
create: {
defaultFromEmail: input.defaultFromEmail,
defaultFromName: input.defaultFromName,
baseURL: input.baseURL,
cleanupInterval: input.cleanupInterval,
organizationId: input.organizationId,
},
update: {
defaultFromEmail: input.defaultFromEmail,
defaultFromName: input.defaultFromName,
baseURL: input.baseURL,
cleanupInterval: input.cleanupInterval,
},
});
return { settings }
})
return { settings };
});
const createApiKeySchema = z.object({
organizationId: z.string(),
name: z.string().min(1, "Name is required"),
expiresAt: z.string().optional(),
})
organizationId: z.string(),
name: z.string().min(1, "Name is required"),
expiresAt: z.string().optional(),
});
export const createApiKey = authProcedure
.input(createApiKeySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(createApiKeySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
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 } })) {
key = `sk_${randomBytes(32).toString("hex")}`
}
while (await prisma.apiKey.findUnique({ where: { key } })) {
key = `sk_${randomBytes(32).toString("hex")}`;
}
const apiKey = await prisma.apiKey.create({
data: {
name: input.name,
key: key,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
organizationId: input.organizationId,
},
select: {
id: true,
key: true,
},
})
const apiKey = await prisma.apiKey.create({
data: {
name: input.name,
key: key,
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
organizationId: input.organizationId,
},
select: {
id: true,
key: true,
},
});
return apiKey
})
return apiKey;
});
const deleteApiKeySchema = z.object({
organizationId: z.string(),
id: z.string(),
})
organizationId: z.string(),
id: z.string(),
});
export const deleteApiKey = authProcedure
.input(deleteApiKeySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(deleteApiKeySchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
await prisma.apiKey.delete({
where: {
id: input.id,
organizationId: input.organizationId,
},
})
await prisma.apiKey.delete({
where: {
id: input.id,
organizationId: input.organizationId,
},
});
return { success: true }
})
return { success: true };
});
const createWebhookSchema = z.object({
organizationId: z.string(),
name: z.string().min(1, "Name is required"),
url: z.string().url("Must be a valid URL"),
events: z.array(z.string()).min(1, "At least one event must be selected"),
isActive: z.boolean(),
secret: z.string().min(1, "Secret is required"),
})
organizationId: z.string(),
name: z.string().min(1, "Name is required"),
url: z.string().url("Must be a valid URL"),
events: z.array(z.string()).min(1, "At least one event must be selected"),
isActive: z.boolean(),
secret: z.string().min(1, "Secret is required"),
});
export const createWebhook = authProcedure
.input(createWebhookSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(createWebhookSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
// const webhook = await prisma.webhook.create({
// data: {
// name: input.name,
// url: input.url,
// events: input.events,
// isActive: input.isActive,
// secret: input.secret,
// organizationId: input.organizationId,
// },
// })
// const webhook = await prisma.webhook.create({
// data: {
// name: input.name,
// url: input.url,
// events: input.events,
// isActive: input.isActive,
// secret: input.secret,
// organizationId: input.organizationId,
// },
// })
// TODO: Implement webhook creation
return { webhook: null }
})
// TODO: Implement webhook creation
return { webhook: null };
});
export const deleteWebhook = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
id: z.string(),
organizationId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
// TODO: Implement webhook deletion
return { success: true }
})
// TODO: Implement webhook deletion
return { success: true };
});
export const testSmtp = authProcedure
.input(
z.object({
email: z.string().email(),
organizationId: z.string(),
})
)
.mutation(async ({ input }) => {
const settings = await prisma.smtpSettings.findFirst({
where: {
organizationId: input.organizationId,
},
})
.input(
z.object({
email: z.string().email(),
organizationId: z.string(),
}),
)
.mutation(async ({ input }) => {
const settings = await prisma.smtpSettings.findFirst({
where: {
organizationId: input.organizationId,
},
});
if (!settings) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"SMTP settings not found. Please configure your SMTP settings first.",
})
}
if (!settings) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"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;">
<h1 style="color: #1a1a1a; margin-bottom: 20px;">SMTP Test Email</h1>
<p style="color: #4a4a4a; line-height: 1.5;">
@@ -357,23 +357,23 @@ export const testSmtp = authProcedure
</p>
</div>
</div>
`
`;
const mailer = new Mailer(settings)
const mailer = new Mailer(settings);
const result = await mailer.sendEmail({
to: input.email,
subject: "SMTP Configuration Test",
html: testTemplate,
from: `${settings.fromName} <${settings.fromEmail}>`,
})
const result = await mailer.sendEmail({
to: input.email,
subject: "SMTP Configuration Test",
html: testTemplate,
from: `${settings.fromName} <${settings.fromEmail}>`,
});
if (!result.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send test email",
})
}
if (!result.success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to send test email",
});
}
return { success: true }
})
return { success: true };
});

View File

@@ -1,174 +1,174 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
export const getSmtp = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const settings = await prisma.smtpSettings.findFirst({
where: {
Organization: {
id: input.organizationId,
UserOrganizations: {
some: {
userId: ctx.user.id,
},
},
},
},
})
const settings = await prisma.smtpSettings.findFirst({
where: {
Organization: {
id: input.organizationId,
UserOrganizations: {
some: {
userId: ctx.user.id,
},
},
},
},
});
return settings
})
return settings;
});
export const getGeneral = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const settings = await prisma.generalSettings.findUnique({
where: {
organizationId: input.organizationId,
},
})
const settings = await prisma.generalSettings.findUnique({
where: {
organizationId: input.organizationId,
},
});
return settings
})
return settings;
});
export const listApiKeys = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const apiKeys = await prisma.apiKey.findMany({
where: {
organizationId: input.organizationId,
},
select: {
id: true,
name: true,
lastUsed: true,
expiresAt: true,
createdAt: true,
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
})
const apiKeys = await prisma.apiKey.findMany({
where: {
organizationId: input.organizationId,
},
select: {
id: true,
name: true,
lastUsed: true,
expiresAt: true,
createdAt: true,
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
return apiKeys
})
return apiKeys;
});
export const listWebhooks = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
// TODO: Implement later
return []
// const webhooks = await prisma.webhook.findMany({
// where: {
// organizationId: input.organizationId,
// },
// orderBy: {
// createdAt: "desc",
// },
// })
// TODO: Implement later
return [];
// const webhooks = await prisma.webhook.findMany({
// where: {
// organizationId: input.organizationId,
// },
// orderBy: {
// createdAt: "desc",
// },
// })
// return webhooks
})
// return webhooks
});
export const getEmailDelivery = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const settings = await prisma.emailDeliverySettings.findUnique({
where: {
organizationId: input.organizationId,
},
})
const settings = await prisma.emailDeliverySettings.findUnique({
where: {
organizationId: input.organizationId,
},
});
return settings
})
return settings;
});

View File

@@ -1,37 +1,37 @@
import { router } from "../trpc"
import { router } from "../trpc";
import {
getSmtp,
getGeneral,
listApiKeys,
listWebhooks,
getEmailDelivery,
} from "./query"
getSmtp,
getGeneral,
listApiKeys,
listWebhooks,
getEmailDelivery,
} from "./query";
import {
updateSmtp,
testSmtp,
updateGeneral,
createApiKey,
deleteApiKey,
createWebhook,
deleteWebhook,
updateEmailDelivery,
} from "./mutation"
updateSmtp,
testSmtp,
updateGeneral,
createApiKey,
deleteApiKey,
createWebhook,
deleteWebhook,
updateEmailDelivery,
} from "./mutation";
export const settingsRouter = router({
getSmtp: getSmtp,
updateSmtp: updateSmtp,
testSmtp: testSmtp,
getGeneral: getGeneral,
updateGeneral: updateGeneral,
getSmtp: getSmtp,
updateSmtp: updateSmtp,
testSmtp: testSmtp,
getGeneral: getGeneral,
updateGeneral: updateGeneral,
// API Keys
createApiKey: createApiKey,
deleteApiKey: deleteApiKey,
listApiKeys: listApiKeys,
// API Keys
createApiKey: createApiKey,
deleteApiKey: deleteApiKey,
listApiKeys: listApiKeys,
createWebhook: createWebhook,
deleteWebhook: deleteWebhook,
listWebhooks: listWebhooks,
getEmailDelivery: getEmailDelivery,
updateEmailDelivery: updateEmailDelivery,
})
createWebhook: createWebhook,
deleteWebhook: deleteWebhook,
listWebhooks: listWebhooks,
getEmailDelivery: getEmailDelivery,
updateEmailDelivery: updateEmailDelivery,
});

View File

@@ -1,18 +1,18 @@
import dayjs from "dayjs"
import relativeTime from "dayjs/plugin/relativeTime"
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(relativeTime)
dayjs.extend(relativeTime);
// TODO: move this to a new package named "shared"
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) {
return dateObj.format("DD MMM YYYY")
}
if (daysFromNow > 7) {
return dateObj.format("DD MMM YYYY");
}
return dateObj.fromNow()
return dateObj.fromNow();
}

View File

@@ -1,379 +1,379 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { subDays } from "date-fns"
import { TRPCError } from "@trpc/server"
import { resolveProps } from "../utils/pProps"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { subDays } from "date-fns";
import { TRPCError } from "@trpc/server";
import { resolveProps } from "../utils/pProps";
import {
countDistinctRecipients,
countDistinctRecipientsInTimeRange,
} from "../../prisma/client/sql"
import { MessageStatus } from "../../prisma/client"
countDistinctRecipients,
countDistinctRecipientsInTimeRange,
} from "../../prisma/client/sql";
import { MessageStatus } from "../../prisma/client";
export const getStats = authProcedure
.input(
z.object({
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const now = new Date()
const thirtyDaysAgo = subDays(now, 30)
const sixtyDaysAgo = subDays(now, 60)
.input(
z.object({
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const now = new Date();
const thirtyDaysAgo = subDays(now, 30);
const sixtyDaysAgo = subDays(now, 60);
const processedMessageStatuses: MessageStatus[] = [
"SENT",
"CLICKED",
"OPENED",
"FAILED",
]
const processedMessageStatuses: MessageStatus[] = [
"SENT",
"CLICKED",
"OPENED",
"FAILED",
];
// Check auth
const hasAccess = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
// Check auth
const hasAccess = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!hasAccess) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to this organization",
})
}
if (!hasAccess) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You do not have access to this organization",
});
}
// We need to get this first for calculating the other stats
const [totalMessages, totalMessagesLast30Days, totalMessagesLastPeriod] =
await prisma.$transaction([
prisma.message.count({
where: {
Campaign: {
organizationId: input.organizationId,
},
status: { in: processedMessageStatuses },
},
}),
prisma.message.count({
where: {
Campaign: {
organizationId: input.organizationId,
},
createdAt: {
gte: thirtyDaysAgo,
lt: now,
},
status: { in: processedMessageStatuses },
},
}),
prisma.message.count({
where: {
Campaign: {
organizationId: input.organizationId,
},
createdAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
status: { in: processedMessageStatuses },
},
}),
])
// We need to get this first for calculating the other stats
const [totalMessages, totalMessagesLast30Days, totalMessagesLastPeriod] =
await prisma.$transaction([
prisma.message.count({
where: {
Campaign: {
organizationId: input.organizationId,
},
status: { in: processedMessageStatuses },
},
}),
prisma.message.count({
where: {
Campaign: {
organizationId: input.organizationId,
},
createdAt: {
gte: thirtyDaysAgo,
lt: now,
},
status: { in: processedMessageStatuses },
},
}),
prisma.message.count({
where: {
Campaign: {
organizationId: input.organizationId,
},
createdAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
status: { in: processedMessageStatuses },
},
}),
]);
const promises = {
allTimeSubscribers: prisma.subscriber.count({
where: {
organizationId: input.organizationId,
},
}),
newSubscribersThisMonth: prisma.subscriber.count({
where: {
organizationId: input.organizationId,
createdAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
}),
openRateThisMonth: (async () => {
const openedMessages = await prisma.message.count({
where: {
status: {
in: ["CLICKED", "OPENED"],
},
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
})
const promises = {
allTimeSubscribers: prisma.subscriber.count({
where: {
organizationId: input.organizationId,
},
}),
newSubscribersThisMonth: prisma.subscriber.count({
where: {
organizationId: input.organizationId,
createdAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
}),
openRateThisMonth: (async () => {
const openedMessages = await prisma.message.count({
where: {
status: {
in: ["CLICKED", "OPENED"],
},
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
});
return openedMessages / (totalMessagesLast30Days || 1)
})(),
openRateLastMonth: (async () => {
const openedMessages = await prisma.message.count({
where: {
status: {
in: ["CLICKED", "OPENED"],
},
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
})
return openedMessages / (totalMessagesLast30Days || 1);
})(),
openRateLastMonth: (async () => {
const openedMessages = await prisma.message.count({
where: {
status: {
in: ["CLICKED", "OPENED"],
},
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
});
return openedMessages / (totalMessagesLastPeriod || 1)
})(),
unsubscribedThisMonth: prisma.listSubscriber.count({
where: {
List: {
organizationId: input.organizationId,
},
unsubscribedAt: {
not: null,
gte: thirtyDaysAgo,
lt: now,
},
},
}),
unsubscribedLastMonth: prisma.listSubscriber.count({
where: {
List: {
organizationId: input.organizationId,
},
unsubscribedAt: {
not: null,
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
}),
totalCampaigns: prisma.campaign.count({
where: {
organizationId: input.organizationId,
},
}),
totalCampaignsThisMonth: prisma.campaign.count({
where: {
organizationId: input.organizationId,
createdAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
}),
totalCampaignsLastMonth: prisma.campaign.count({
where: {
organizationId: input.organizationId,
createdAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
}),
completedCampaigns: prisma.campaign.count({
where: {
organizationId: input.organizationId,
status: "COMPLETED",
},
}),
completedCampaignsThisMonth: prisma.campaign.count({
where: {
organizationId: input.organizationId,
status: "COMPLETED",
createdAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
}),
completedCampaignsLastMonth: prisma.campaign.count({
where: {
organizationId: input.organizationId,
status: "COMPLETED",
createdAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
}),
deliveryRateThisMonth: (async () => {
const deliveredMessages = await prisma.message.count({
where: {
status: {
in: ["SENT", "CLICKED", "OPENED"],
},
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
})
return openedMessages / (totalMessagesLastPeriod || 1);
})(),
unsubscribedThisMonth: prisma.listSubscriber.count({
where: {
List: {
organizationId: input.organizationId,
},
unsubscribedAt: {
not: null,
gte: thirtyDaysAgo,
lt: now,
},
},
}),
unsubscribedLastMonth: prisma.listSubscriber.count({
where: {
List: {
organizationId: input.organizationId,
},
unsubscribedAt: {
not: null,
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
}),
totalCampaigns: prisma.campaign.count({
where: {
organizationId: input.organizationId,
},
}),
totalCampaignsThisMonth: prisma.campaign.count({
where: {
organizationId: input.organizationId,
createdAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
}),
totalCampaignsLastMonth: prisma.campaign.count({
where: {
organizationId: input.organizationId,
createdAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
}),
completedCampaigns: prisma.campaign.count({
where: {
organizationId: input.organizationId,
status: "COMPLETED",
},
}),
completedCampaignsThisMonth: prisma.campaign.count({
where: {
organizationId: input.organizationId,
status: "COMPLETED",
createdAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
}),
completedCampaignsLastMonth: prisma.campaign.count({
where: {
organizationId: input.organizationId,
status: "COMPLETED",
createdAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
}),
deliveryRateThisMonth: (async () => {
const deliveredMessages = await prisma.message.count({
where: {
status: {
in: ["SENT", "CLICKED", "OPENED"],
},
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
});
return {
delivered: deliveredMessages,
rate: deliveredMessages / (totalMessagesLast30Days || 1),
}
})(),
deliveryRateLastMonth: (async () => {
const deliveredMessages = await prisma.message.count({
where: {
status: {
in: ["SENT", "CLICKED", "OPENED"],
},
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
})
return {
delivered: deliveredMessages,
rate: deliveredMessages / (totalMessagesLast30Days || 1),
};
})(),
deliveryRateLastMonth: (async () => {
const deliveredMessages = await prisma.message.count({
where: {
status: {
in: ["SENT", "CLICKED", "OPENED"],
},
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
});
return {
delivered: deliveredMessages,
rate: deliveredMessages / (totalMessagesLastPeriod || 1),
}
})(),
clickRateThisMonth: (async () => {
const clickedMessages = await prisma.message.count({
where: {
status: "CLICKED",
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
})
return {
delivered: deliveredMessages,
rate: deliveredMessages / (totalMessagesLastPeriod || 1),
};
})(),
clickRateThisMonth: (async () => {
const clickedMessages = await prisma.message.count({
where: {
status: "CLICKED",
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: thirtyDaysAgo,
lt: now,
},
},
});
return {
clicked: clickedMessages,
rate: clickedMessages / (totalMessagesLast30Days || 1),
}
})(),
clickRateLastMonth: (async () => {
const clickedMessages = await prisma.message.count({
where: {
status: "CLICKED",
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
})
return {
clicked: clickedMessages,
rate: clickedMessages / (totalMessagesLast30Days || 1),
};
})(),
clickRateLastMonth: (async () => {
const clickedMessages = await prisma.message.count({
where: {
status: "CLICKED",
Campaign: {
organizationId: input.organizationId,
},
sentAt: {
gte: sixtyDaysAgo,
lt: thirtyDaysAgo,
},
},
});
return {
clicked: clickedMessages,
rate: clickedMessages / (totalMessagesLastPeriod || 1),
}
})(),
recipients: prisma.$queryRawTyped(
countDistinctRecipients(input.organizationId)
),
recipientsThisMonth: prisma.$queryRawTyped(
countDistinctRecipientsInTimeRange(
input.organizationId,
thirtyDaysAgo,
now
)
),
recipientsLastMonth: prisma.$queryRawTyped(
countDistinctRecipientsInTimeRange(
input.organizationId,
sixtyDaysAgo,
thirtyDaysAgo
)
),
}
return {
clicked: clickedMessages,
rate: clickedMessages / (totalMessagesLastPeriod || 1),
};
})(),
recipients: prisma.$queryRawTyped(
countDistinctRecipients(input.organizationId),
),
recipientsThisMonth: prisma.$queryRawTyped(
countDistinctRecipientsInTimeRange(
input.organizationId,
thirtyDaysAgo,
now,
),
),
recipientsLastMonth: prisma.$queryRawTyped(
countDistinctRecipientsInTimeRange(
input.organizationId,
sixtyDaysAgo,
thirtyDaysAgo,
),
),
};
const result = await resolveProps(promises)
const result = await resolveProps(promises);
const data = {
campaigns: {
total: result.totalCampaigns,
thisMonth: result.totalCampaignsThisMonth,
lastMonth: result.totalCampaignsLastMonth,
comparison: result.totalCampaigns - result.totalCampaignsLastMonth,
},
completedCampaigns: {
total: result.completedCampaigns,
thisMonth: result.completedCampaignsThisMonth,
lastMonth: result.completedCampaignsLastMonth,
comparison:
result.completedCampaigns - result.completedCampaignsLastMonth,
},
openRate: {
thisMonth: result.openRateThisMonth * 100,
lastMonth: result.openRateLastMonth * 100,
comparison: (result.openRateThisMonth - result.openRateLastMonth) * 100,
},
clickRate: {
thisMonth: {
clicked: result.clickRateThisMonth.clicked,
rate: result.clickRateThisMonth.rate * 100,
},
lastMonth: {
clicked: result.clickRateLastMonth.clicked,
rate: result.clickRateLastMonth.rate * 100,
},
comparison:
(result.clickRateThisMonth.rate - result.clickRateLastMonth.rate) *
100,
},
messages: {
total: totalMessages,
last30Days: totalMessagesLast30Days,
lastPeriod: totalMessagesLastPeriod,
},
recipients: {
allTime: result.recipients[0]?.count || 0,
thisMonth: result.recipientsThisMonth[0]?.count || 0,
lastMonth: result.recipientsLastMonth[0]?.count || 0,
comparison:
Number(result.recipientsThisMonth[0]?.count) -
Number(result.recipientsLastMonth[0]?.count),
},
deliveryRate: {
thisMonth: {
delivered: result.deliveryRateThisMonth.delivered,
rate: result.deliveryRateThisMonth.rate * 100,
},
lastMonth: {
delivered: result.deliveryRateLastMonth.delivered,
rate: result.deliveryRateLastMonth.rate * 100,
},
comparison:
(result.deliveryRateThisMonth.rate -
result.deliveryRateLastMonth.rate) *
100,
},
subscribers: {
allTime: result.allTimeSubscribers,
newThisMonth: result.newSubscribersThisMonth,
},
unsubscribed: {
thisMonth: result.unsubscribedThisMonth,
lastMonth: result.unsubscribedLastMonth,
comparison: result.unsubscribedThisMonth - result.unsubscribedLastMonth,
},
}
const data = {
campaigns: {
total: result.totalCampaigns,
thisMonth: result.totalCampaignsThisMonth,
lastMonth: result.totalCampaignsLastMonth,
comparison: result.totalCampaigns - result.totalCampaignsLastMonth,
},
completedCampaigns: {
total: result.completedCampaigns,
thisMonth: result.completedCampaignsThisMonth,
lastMonth: result.completedCampaignsLastMonth,
comparison:
result.completedCampaigns - result.completedCampaignsLastMonth,
},
openRate: {
thisMonth: result.openRateThisMonth * 100,
lastMonth: result.openRateLastMonth * 100,
comparison: (result.openRateThisMonth - result.openRateLastMonth) * 100,
},
clickRate: {
thisMonth: {
clicked: result.clickRateThisMonth.clicked,
rate: result.clickRateThisMonth.rate * 100,
},
lastMonth: {
clicked: result.clickRateLastMonth.clicked,
rate: result.clickRateLastMonth.rate * 100,
},
comparison:
(result.clickRateThisMonth.rate - result.clickRateLastMonth.rate) *
100,
},
messages: {
total: totalMessages,
last30Days: totalMessagesLast30Days,
lastPeriod: totalMessagesLastPeriod,
},
recipients: {
allTime: result.recipients[0]?.count || 0,
thisMonth: result.recipientsThisMonth[0]?.count || 0,
lastMonth: result.recipientsLastMonth[0]?.count || 0,
comparison:
Number(result.recipientsThisMonth[0]?.count) -
Number(result.recipientsLastMonth[0]?.count),
},
deliveryRate: {
thisMonth: {
delivered: result.deliveryRateThisMonth.delivered,
rate: result.deliveryRateThisMonth.rate * 100,
},
lastMonth: {
delivered: result.deliveryRateLastMonth.delivered,
rate: result.deliveryRateLastMonth.rate * 100,
},
comparison:
(result.deliveryRateThisMonth.rate -
result.deliveryRateLastMonth.rate) *
100,
},
subscribers: {
allTime: result.allTimeSubscribers,
newThisMonth: result.newSubscribersThisMonth,
},
unsubscribed: {
thisMonth: result.unsubscribedThisMonth,
lastMonth: result.unsubscribedLastMonth,
comparison: result.unsubscribedThisMonth - result.unsubscribedLastMonth,
},
};
return data
})
return data;
});

View File

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

View File

@@ -1,472 +1,472 @@
import { z } from "zod"
import { authProcedure, publicProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { parse } from "csv-parse"
import { Readable } from "stream"
import { z } from "zod";
import { authProcedure, publicProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { parse } from "csv-parse";
import { Readable } from "stream";
const createSubscriberSchema = z.object({
email: z.string().email("Invalid email address"),
name: z.string().optional(),
organizationId: z.string(),
listIds: z.array(z.string()),
emailVerified: z.boolean().optional(),
metadata: z
.array(
z.object({
key: z.string().min(1).max(64),
value: z.string().min(1).max(256),
})
)
.optional(),
})
email: z.string().email("Invalid email address"),
name: z.string().optional(),
organizationId: z.string(),
listIds: z.array(z.string()),
emailVerified: z.boolean().optional(),
metadata: z
.array(
z.object({
key: z.string().min(1).max(64),
value: z.string().min(1).max(256),
}),
)
.optional(),
});
export const createSubscriber = authProcedure
.input(createSubscriberSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(createSubscriberSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const existingSubscriber = await prisma.subscriber.findFirst({
where: {
email: input.email,
organizationId: input.organizationId,
},
})
const existingSubscriber = await prisma.subscriber.findFirst({
where: {
email: input.email,
organizationId: input.organizationId,
},
});
if (existingSubscriber) {
throw new TRPCError({
code: "CONFLICT",
message: "Subscriber with this email already exists",
})
}
if (existingSubscriber) {
throw new TRPCError({
code: "CONFLICT",
message: "Subscriber with this email already exists",
});
}
const subscriber = await prisma.subscriber.create({
data: {
email: input.email,
name: input.name,
organizationId: input.organizationId,
emailVerified: input.emailVerified,
ListSubscribers: {
create: input.listIds.map((listId) => ({
List: {
connect: {
id: listId,
},
},
})),
},
Metadata: input.metadata
? {
create: input.metadata.map((meta) => ({
key: meta.key,
value: meta.value,
})),
}
: undefined,
},
})
const subscriber = await prisma.subscriber.create({
data: {
email: input.email,
name: input.name,
organizationId: input.organizationId,
emailVerified: input.emailVerified,
ListSubscribers: {
create: input.listIds.map((listId) => ({
List: {
connect: {
id: listId,
},
},
})),
},
Metadata: input.metadata
? {
create: input.metadata.map((meta) => ({
key: meta.key,
value: meta.value,
})),
}
: undefined,
},
});
return { subscriber }
})
return { subscriber };
});
const updateSubscriberSchema = z.object({
id: z.string(),
email: z.string().email("Invalid email address"),
name: z.string().optional(),
organizationId: z.string(),
listIds: z.array(z.string()),
emailVerified: z.boolean().optional(),
metadata: z
.array(
z.object({
key: z.string().min(1),
value: z.string().min(1),
})
)
.optional(),
})
id: z.string(),
email: z.string().email("Invalid email address"),
name: z.string().optional(),
organizationId: z.string(),
listIds: z.array(z.string()),
emailVerified: z.boolean().optional(),
metadata: z
.array(
z.object({
key: z.string().min(1),
value: z.string().min(1),
}),
)
.optional(),
});
export const updateSubscriber = authProcedure
.input(updateSubscriberSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(updateSubscriberSchema)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const subscriber = await prisma.subscriber.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
include: {
ListSubscribers: true,
},
})
const subscriber = await prisma.subscriber.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
include: {
ListSubscribers: true,
},
});
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Subscriber not found",
})
}
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Subscriber not found",
});
}
// Get current list IDs
const currentListIds = subscriber.ListSubscribers.map((ls) => ls.listId)
// Get current list IDs
const currentListIds = subscriber.ListSubscribers.map((ls) => ls.listId);
// Find lists to add and remove
const listsToAdd = input.listIds.filter(
(id) => !currentListIds.includes(id)
)
const listsToRemove = currentListIds.filter(
(id) => !input.listIds.includes(id)
)
// Find lists to add and remove
const listsToAdd = input.listIds.filter(
(id) => !currentListIds.includes(id),
);
const listsToRemove = currentListIds.filter(
(id) => !input.listIds.includes(id),
);
const updatedSubscriber = await prisma.subscriber.update({
where: { id: input.id },
data: {
email: input.email,
name: input.name,
emailVerified: input.emailVerified,
ListSubscribers: {
deleteMany: {
listId: {
in: listsToRemove,
},
},
create: listsToAdd.map((listId) => ({
listId,
})),
},
Metadata: input.metadata
? {
deleteMany: {},
create: input.metadata.map((meta) => ({
key: meta.key,
value: meta.value,
})),
}
: { deleteMany: {} },
},
include: {
ListSubscribers: {
include: {
List: true,
},
},
},
})
const updatedSubscriber = await prisma.subscriber.update({
where: { id: input.id },
data: {
email: input.email,
name: input.name,
emailVerified: input.emailVerified,
ListSubscribers: {
deleteMany: {
listId: {
in: listsToRemove,
},
},
create: listsToAdd.map((listId) => ({
listId,
})),
},
Metadata: input.metadata
? {
deleteMany: {},
create: input.metadata.map((meta) => ({
key: meta.key,
value: meta.value,
})),
}
: { deleteMany: {} },
},
include: {
ListSubscribers: {
include: {
List: true,
},
},
},
});
return { subscriber: updatedSubscriber }
})
return { subscriber: updatedSubscriber };
});
export const deleteSubscriber = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
id: z.string(),
organizationId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const subscriber = await prisma.subscriber.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
})
const subscriber = await prisma.subscriber.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
});
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Subscriber not found",
})
}
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Subscriber not found",
});
}
await prisma.subscriber.delete({
where: { id: input.id },
})
await prisma.subscriber.delete({
where: { id: input.id },
});
return { success: true }
})
return { success: true };
});
export const importSubscribers = authProcedure
.input(
z.object({
file: z.instanceof(FormData),
organizationId: z.string(),
listId: z.string().optional(),
})
)
.mutation(async ({ input }) => {
const file = input.file.get("file") as File
if (!file) {
throw new Error("No file provided")
}
.input(
z.object({
file: z.instanceof(FormData),
organizationId: z.string(),
listId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
const file = input.file.get("file") as File;
if (!file) {
throw new Error("No file provided");
}
const buffer = Buffer.from(await file.arrayBuffer())
const records: any[] = []
const buffer = Buffer.from(await file.arrayBuffer());
const records: any[] = [];
// Parse CSV
await new Promise((resolve, reject) => {
const parser = parse({
columns: true,
skip_empty_lines: true,
})
// Parse CSV
await new Promise((resolve, reject) => {
const parser = parse({
columns: true,
skip_empty_lines: true,
});
parser.on("readable", function () {
let record
while ((record = parser.read()) !== null) {
records.push(record)
}
})
parser.on("readable", function () {
let record;
while ((record = parser.read()) !== null) {
records.push(record);
}
});
parser.on("error", function (err) {
reject(err)
})
parser.on("error", function (err) {
reject(err);
});
parser.on("end", function () {
resolve(undefined)
})
parser.on("end", function () {
resolve(undefined);
});
Readable.from(buffer).pipe(parser)
})
Readable.from(buffer).pipe(parser);
});
// Validate and transform records
const subscribers = records.map((record) => ({
email: record.email,
firstName: record.first_name || null,
lastName: record.last_name || null,
phone: record.phone || null,
company: record.company || null,
jobTitle: record.job_title || null,
city: record.city || null,
country: record.country || null,
subscribedAt: record.subscribed_at
? new Date(record.subscribed_at)
: new Date(),
tags: record.tags
? record.tags.split(",").map((t: string) => t.trim())
: [],
organizationId: input.organizationId,
}))
// Validate and transform records
const subscribers = records.map((record) => ({
email: record.email,
firstName: record.first_name || null,
lastName: record.last_name || null,
phone: record.phone || null,
company: record.company || null,
jobTitle: record.job_title || null,
city: record.city || null,
country: record.country || null,
subscribedAt: record.subscribed_at
? new Date(record.subscribed_at)
: new Date(),
tags: record.tags
? record.tags.split(",").map((t: string) => t.trim())
: [],
organizationId: input.organizationId,
}));
// Import subscribers
const result = await prisma.$transaction(async (tx) => {
const imported = await Promise.all(
subscribers.map(async (sub) => {
const subscriber = await tx.subscriber.upsert({
where: {
organizationId_email: {
organizationId: input.organizationId,
email: sub.email,
},
},
create: sub,
update: sub,
})
// Import subscribers
const result = await prisma.$transaction(async (tx) => {
const imported = await Promise.all(
subscribers.map(async (sub) => {
const subscriber = await tx.subscriber.upsert({
where: {
organizationId_email: {
organizationId: input.organizationId,
email: sub.email,
},
},
create: sub,
update: sub,
});
if (input.listId) {
await tx.listSubscriber.upsert({
where: {
listId_subscriberId: {
listId: input.listId,
subscriberId: subscriber.id,
},
},
create: {
listId: input.listId,
subscriberId: subscriber.id,
},
update: {},
})
}
if (input.listId) {
await tx.listSubscriber.upsert({
where: {
listId_subscriberId: {
listId: input.listId,
subscriberId: subscriber.id,
},
},
create: {
listId: input.listId,
subscriberId: subscriber.id,
},
update: {},
});
}
return subscriber
})
)
return subscriber;
}),
);
return imported
})
return imported;
});
return {
count: result.length,
}
})
return {
count: result.length,
};
});
export const unsubscribeToggle = authProcedure
.input(
z.object({
listSubscriberId: z.string(),
organizationId: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
const org = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
listSubscriberId: z.string(),
organizationId: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const org = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!org) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!org) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const listSubscriber = await prisma.listSubscriber.findFirst({
where: {
id: input.listSubscriberId,
Subscriber: {
organizationId: org.organizationId,
},
},
})
const listSubscriber = await prisma.listSubscriber.findFirst({
where: {
id: input.listSubscriberId,
Subscriber: {
organizationId: org.organizationId,
},
},
});
if (!listSubscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List subscriber not found",
})
}
if (!listSubscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "List subscriber not found",
});
}
const updated = await prisma.listSubscriber.update({
where: { id: input.listSubscriberId },
data: {
unsubscribedAt: listSubscriber.unsubscribedAt ? null : new Date(),
},
})
const updated = await prisma.listSubscriber.update({
where: { id: input.listSubscriberId },
data: {
unsubscribedAt: listSubscriber.unsubscribedAt ? null : new Date(),
},
});
return {
success: true,
subbed: !updated.unsubscribedAt,
}
})
return {
success: true,
subbed: !updated.unsubscribedAt,
};
});
export const publicUnsubscribe = publicProcedure
.input(z.object({ sid: z.string(), cid: z.string() }))
.mutation(async ({ input }) => {
try {
const listSubscribers = await prisma.listSubscriber.findMany({
where: {
subscriberId: input.sid,
List: {
CampaignLists: {
some: {
campaignId: input.cid,
},
},
},
unsubscribedAt: null,
},
})
.input(z.object({ sid: z.string(), cid: z.string() }))
.mutation(async ({ input }) => {
try {
const listSubscribers = await prisma.listSubscriber.findMany({
where: {
subscriberId: input.sid,
List: {
CampaignLists: {
some: {
campaignId: input.cid,
},
},
},
unsubscribedAt: null,
},
});
if (!listSubscribers.length) {
return {
success: true,
}
}
if (!listSubscribers.length) {
return {
success: true,
};
}
await prisma.listSubscriber.updateMany({
where: {
id: {
in: listSubscribers.map((ls) => ls.id),
},
},
data: {
unsubscribedAt: new Date(),
},
})
await prisma.listSubscriber.updateMany({
where: {
id: {
in: listSubscribers.map((ls) => ls.id),
},
},
data: {
unsubscribedAt: new Date(),
},
});
await prisma.campaign
.update({
where: { id: input.cid },
data: { unsubscribedCount: { increment: 1 } },
})
.catch(() => {})
await prisma.campaign
.update({
where: { id: input.cid },
data: { unsubscribedCount: { increment: 1 } },
})
.catch(() => {});
return {
success: true,
}
} catch (error) {
if (error instanceof TRPCError) {
throw error
}
return {
success: true,
};
} catch (error) {
if (error instanceof TRPCError) {
throw error;
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to unsubscribe",
})
}
})
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to unsubscribe",
});
}
});
export const verifyEmail = publicProcedure
.input(
z.object({
token: z.string(),
})
)
.mutation(async ({ input }) => {
const subscriber = await prisma.subscriber.findFirst({
where: {
emailVerificationToken: input.token,
emailVerificationTokenExpiresAt: {
gt: new Date(),
},
},
})
.input(
z.object({
token: z.string(),
}),
)
.mutation(async ({ input }) => {
const subscriber = await prisma.subscriber.findFirst({
where: {
emailVerificationToken: input.token,
emailVerificationTokenExpiresAt: {
gt: new Date(),
},
},
});
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invalid or expired verification token",
})
}
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invalid or expired verification token",
});
}
await prisma.subscriber.update({
where: { id: subscriber.id },
data: {
emailVerified: true,
emailVerificationToken: null,
emailVerificationTokenExpiresAt: null,
},
})
await prisma.subscriber.update({
where: { id: subscriber.id },
data: {
emailVerified: true,
emailVerificationToken: null,
emailVerificationTokenExpiresAt: null,
},
});
return { success: true }
})
return { success: true };
});

View File

@@ -1,132 +1,132 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { paginationSchema } from "../utils/schemas"
import { Prisma } from "../../prisma/client"
import { resolveProps } from "../utils/pProps"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client";
import { resolveProps } from "../utils/pProps";
export const listSubscribers = authProcedure
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const where: Prisma.SubscriberWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" } },
{ email: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
}
const where: Prisma.SubscriberWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" } },
{ email: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
};
const promises = {
subscribersList: prisma.subscriber.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
include: {
Metadata: true,
ListSubscribers: {
select: {
id: true,
unsubscribedAt: true,
listId: true,
createdAt: true,
updatedAt: true,
List: {
select: {
id: true,
name: true,
},
},
},
},
},
}),
totalItems: prisma.subscriber.count({ where }),
}
const promises = {
subscribersList: prisma.subscriber.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
include: {
Metadata: true,
ListSubscribers: {
select: {
id: true,
unsubscribedAt: true,
listId: true,
createdAt: true,
updatedAt: true,
List: {
select: {
id: true,
name: true,
},
},
},
},
},
}),
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 {
subscribers: result.subscribersList,
pagination: {
total: result.totalItems,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
}
})
return {
subscribers: result.subscribersList,
pagination: {
total: result.totalItems,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
};
});
export const getSubscriber = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
id: z.string(),
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const subscriber = await prisma.subscriber.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
include: {
ListSubscribers: {
include: {
List: true,
},
},
Messages: {
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: 10,
},
Metadata: true,
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
})
const subscriber = await prisma.subscriber.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
include: {
ListSubscribers: {
include: {
List: true,
},
},
Messages: {
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
take: 10,
},
Metadata: true,
},
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
});
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Subscriber not found",
})
}
if (!subscriber) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Subscriber not found",
});
}
return subscriber
})
return subscriber;
});

View File

@@ -1,23 +1,23 @@
import { router } from "../trpc"
import { router } from "../trpc";
import {
createSubscriber,
updateSubscriber,
deleteSubscriber,
importSubscribers,
publicUnsubscribe,
unsubscribeToggle,
verifyEmail,
} from "./mutation"
import { getSubscriber, listSubscribers } from "./query"
createSubscriber,
updateSubscriber,
deleteSubscriber,
importSubscribers,
publicUnsubscribe,
unsubscribeToggle,
verifyEmail,
} from "./mutation";
import { getSubscriber, listSubscribers } from "./query";
export const subscriberRouter = router({
create: createSubscriber,
update: updateSubscriber,
delete: deleteSubscriber,
get: getSubscriber,
list: listSubscribers,
import: importSubscribers,
unsubscribe: publicUnsubscribe,
unsubscribeToggle,
verifyEmail,
})
create: createSubscriber,
update: updateSubscriber,
delete: deleteSubscriber,
get: getSubscriber,
list: listSubscribers,
import: importSubscribers,
unsubscribe: publicUnsubscribe,
unsubscribeToggle,
verifyEmail,
});

View File

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

View File

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

View File

@@ -1,98 +1,98 @@
import { z } from "zod"
import { authProcedure } from "../trpc"
import { prisma } from "../utils/prisma"
import { TRPCError } from "@trpc/server"
import { paginationSchema } from "../utils/schemas"
import { Prisma } from "../../prisma/client"
import { z } from "zod";
import { authProcedure } from "../trpc";
import { prisma } from "../utils/prisma";
import { TRPCError } from "@trpc/server";
import { paginationSchema } from "../utils/schemas";
import { Prisma } from "../../prisma/client";
export const listTemplates = authProcedure
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(z.object({ organizationId: z.string() }).merge(paginationSchema))
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const where: Prisma.TemplateWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" } },
{ description: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
}
const where: Prisma.TemplateWhereInput = {
organizationId: input.organizationId,
...(input.search
? {
OR: [
{ name: { contains: input.search, mode: "insensitive" } },
{ description: { contains: input.search, mode: "insensitive" } },
],
}
: {}),
};
const [total, templates] = await Promise.all([
prisma.template.count({ where }),
prisma.template.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
])
const [total, templates] = await Promise.all([
prisma.template.count({ where }),
prisma.template.findMany({
where,
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
skip: (input.page - 1) * input.perPage,
take: input.perPage,
}),
]);
const totalPages = Math.ceil(total / input.perPage)
const totalPages = Math.ceil(total / input.perPage);
return {
templates,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
}
})
return {
templates,
pagination: {
total,
totalPages,
page: input.page,
perPage: input.perPage,
hasMore: input.page < totalPages,
},
};
});
export const getTemplate = authProcedure
.input(
z.object({
id: z.string(),
organizationId: z.string(),
})
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
})
.input(
z.object({
id: z.string(),
organizationId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const userOrganization = await prisma.userOrganization.findFirst({
where: {
userId: ctx.user.id,
organizationId: input.organizationId,
},
});
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
})
}
if (!userOrganization) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Organization not found",
});
}
const template = await prisma.template.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
})
const template = await prisma.template.findFirst({
where: {
id: input.id,
organizationId: input.organizationId,
},
});
if (!template) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
})
}
if (!template) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Template not found",
});
}
return template
})
return template;
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,24 @@
import jwt from "jsonwebtoken"
import bcrypt from "bcryptjs"
import { env } from "../constants"
import jwt from "jsonwebtoken";
import bcrypt from "bcryptjs";
import { env } from "../constants";
export async function hashPassword(password: string) {
return bcrypt.hash(password, 10)
return bcrypt.hash(password, 10);
}
export async function comparePasswords(
password: string,
hashedPassword: string
password: string,
hashedPassword: string,
) {
return bcrypt.compare(password, hashedPassword)
return bcrypt.compare(password, hashedPassword);
}
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) {
return jwt.verify(token, env.JWT_SECRET)
return jwt.verify(token, env.JWT_SECRET);
}

View File

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

View File

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

View File

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

View File

@@ -1,47 +1,47 @@
interface SubscriberPlaceholderData {
email: string
name?: string
email: string;
name?: string;
}
interface CampaignPlaceholderData {
name: string
subject?: string
name: string;
subject?: string;
}
interface OrganizationPlaceholderData {
name: string
name: string;
}
export interface PlaceholderData {
subscriber: SubscriberPlaceholderData
campaign: CampaignPlaceholderData
organization: OrganizationPlaceholderData
unsubscribe_link: string
current_date?: string
subscriber: SubscriberPlaceholderData;
campaign: CampaignPlaceholderData;
organization: OrganizationPlaceholderData;
unsubscribe_link: string;
current_date?: string;
}
export type PlaceholderDataKey =
| `subscriber.${keyof SubscriberPlaceholderData}`
| `campaign.${keyof CampaignPlaceholderData}`
| `organization.${keyof OrganizationPlaceholderData}`
| `unsubscribe_link`
| `current_date`
| `subscriber.metadata.${string}`
| `subscriber.${keyof SubscriberPlaceholderData}`
| `campaign.${keyof CampaignPlaceholderData}`
| `organization.${keyof OrganizationPlaceholderData}`
| `unsubscribe_link`
| `current_date`
| `subscriber.metadata.${string}`;
export function replacePlaceholders(
template: string,
data: Partial<Record<PlaceholderDataKey, string>>
template: string,
data: Partial<Record<PlaceholderDataKey, string>>,
): string {
let result = template
for (const key in data) {
const placeholderRegex = new RegExp(
`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*}}`,
"g"
)
const value = data[key as PlaceholderDataKey]
if (value !== undefined) {
result = result.replace(placeholderRegex, value)
}
}
return result
let result = template;
for (const key in data) {
const placeholderRegex = new RegExp(
`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*}}`,
"g",
);
const value = data[key as PlaceholderDataKey];
if (value !== undefined) {
result = result.replace(placeholderRegex, value);
}
}
return result;
}

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