chore: format
This commit is contained in:
1
Projects/kompose/.gitignore
vendored
1
Projects/kompose/.gitignore
vendored
@@ -2,3 +2,4 @@
|
||||
|
||||
.DS_Store
|
||||
*.log*
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
const { footer } = useAppConfig()
|
||||
const { footer } = useAppConfig();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
-->
|
||||
|
||||
<script setup>
|
||||
import AppIcon from './AppIcon.vue'
|
||||
import AppIcon from "./AppIcon.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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%" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
26
Projects/kompose/news/.vscode/settings.json
vendored
26
Projects/kompose/news/.vscode/settings.json
vendored
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;',
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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=";
|
||||
|
||||
@@ -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()),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 }});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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]> },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user