Implemented e-mail notification trigger.
This commit is contained in:
@@ -49,6 +49,17 @@ async function initCaches() {
|
||||
:class="{ active: pageContext.routeParams.section === 'entities' }"
|
||||
>Entity Health</a>
|
||||
</li>
|
||||
|
||||
<li
|
||||
v-if="pageContext.user.role === 'admin'"
|
||||
class="nav-item"
|
||||
>
|
||||
<a
|
||||
href="/admin/emails"
|
||||
class="nav-link nolink"
|
||||
:class="{ active: pageContext.urlPathname.startsWith('/admin/emails') }"
|
||||
>E-mails</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<button
|
||||
|
||||
307
components/emails/alert.vue
Normal file
307
components/emails/alert.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<script setup>
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Text,
|
||||
} from '@vue-email/components';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
|
||||
defineProps({
|
||||
trigger: {
|
||||
type: Object,
|
||||
default: () => mockTrigger,
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
default: 'http://localhost:5100',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain
|
||||
// <script> block: Vue's compiler forbids defineProps from referencing variables declared inside
|
||||
// <script setup> itself, since that block gets hoisted out of the setup() function.
|
||||
const mockTrigger = {
|
||||
userId: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
userEmail: 'debug@alerts.traxxx.me',
|
||||
alerts: [
|
||||
{
|
||||
sceneId: 684061,
|
||||
alert: {
|
||||
id: 565,
|
||||
userId: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
userEmail: 'debug@alerts.traxxx.me',
|
||||
notify: true,
|
||||
email: true,
|
||||
all: true,
|
||||
allActors: true,
|
||||
allEntities: true,
|
||||
allTags: true,
|
||||
allMatches: true,
|
||||
actors: [],
|
||||
tags: [],
|
||||
entities: [
|
||||
{
|
||||
id: 522,
|
||||
name: 'Jules Jordan',
|
||||
},
|
||||
],
|
||||
matches: [],
|
||||
stashIds: [],
|
||||
},
|
||||
scene: {
|
||||
id: 684061,
|
||||
title: 'Thick & Bootylicious: Vanessa Blake Takes A BBC Up Her Ass',
|
||||
slug: 'thick-bootylicious-vanessa-blake-takes-a-bbc-up',
|
||||
date: '2026-02-17T00:00:00.000Z',
|
||||
description: 'Vanessa Blake’s curves are impossible to ignore—especially her thick, round, bootylicious ass. The scene opens with the camera following Vanessa through the house as she struts confidently in a slow, teasing booty walk, giving viewers a full view of her luscious curves. She pauses at the stairs, turning back to flaunt her massive ass before letting her hands wander over her body. Feeling the heat build, Vanessa begins to pleasure herself, showcasing her insatiable appetite for big black cock. Moving to the couch, she grabs a dildo and slowly works it into her ass, preparing herself for what’s coming next. Sitting back and watching the show is Lexington Steele, enjoying every second as she stretches and teases in anticipation. Lexington finally steps in, and Vanessa eagerly drops to her knees, wrapping her lips around his massive BBC and giving him a deep, hungry blowjob. The action quickly escalates to the couch, where they fuck in multiple intense positions, Vanessa’s thick body bouncing with every thrust. When she’s fully ready, Vanessa has Lex slide his BBC deep into her ass, and the two go at it hard in a variety of explosive anal positions. The scene builds to a powerful climax as Lexington pulls out and finishes with a messy facial, leaving Vanessa satisfied and dripping after an unforgettable session.',
|
||||
actors: [
|
||||
{
|
||||
id: 919,
|
||||
name: 'Vanessa Blake',
|
||||
},
|
||||
{
|
||||
id: 1374,
|
||||
name: 'Lexington Steele',
|
||||
},
|
||||
],
|
||||
actorIds: [
|
||||
919,
|
||||
1374,
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: 'MILF',
|
||||
slug: 'milf',
|
||||
},
|
||||
{
|
||||
name: 'facial',
|
||||
slug: 'facial',
|
||||
},
|
||||
],
|
||||
tagIds: [
|
||||
4,
|
||||
12,
|
||||
19,
|
||||
30,
|
||||
29,
|
||||
37,
|
||||
41,
|
||||
68,
|
||||
82,
|
||||
100,
|
||||
112,
|
||||
119,
|
||||
null,
|
||||
130,
|
||||
],
|
||||
entityId: 522,
|
||||
parentEntityId: 47,
|
||||
// posterUrl: 'http://localhost:5100/media/posters/lazy/cd/b5/99375c7998a20acffef2217a8a21c3546924312cec65.jpeg',
|
||||
},
|
||||
},
|
||||
{
|
||||
sceneId: 684060,
|
||||
alert: {
|
||||
id: 565,
|
||||
userId: 1,
|
||||
username: 'DebuggingLibrarian',
|
||||
userEmail: 'debug@alerts.traxxx.me',
|
||||
notify: true,
|
||||
email: true,
|
||||
all: true,
|
||||
allActors: true,
|
||||
allEntities: true,
|
||||
allTags: true,
|
||||
allMatches: true,
|
||||
actors: [],
|
||||
tags: [],
|
||||
entities: [
|
||||
{
|
||||
id: 522,
|
||||
name: 'Jules Jordan',
|
||||
},
|
||||
],
|
||||
matches: [],
|
||||
stashIds: [],
|
||||
},
|
||||
scene: {
|
||||
id: 684060,
|
||||
title: 'Elizabeth Skylar’s Big ASSets Drive The Milfomaniac Manuel Ferrara Wild',
|
||||
slug: 'elizabeth-skylar-s-big-assets-drive-the-manuel',
|
||||
date: '2026-02-21T00:00:00.000Z',
|
||||
description: 'Elizabeth Skylar is ready to indulge her cravings, and there’s only one man she wants—the Milfomaniac, Manuel Ferrara. The scene opens with Elizabeth stepping out onto the balcony in sexy lingerie, confidently showing off her busty curves as the camera captures every seductive angle. She teases the viewer with slow, deliberate poses before heading inside to the kitchen, where she perches on a stool with her ass on full display. Manuel joins her, grabbing and admiring her curves, letting the camera in on just how irresistible she is. The action moves to the living room, where Elizabeth stretches out on the couch and begins to pleasure herself, putting on a sultry show for Manuel. Unable to resist, she drops to her knees, her tits spilling free as she takes him deep into her mouth with an eager, hungry intensity. From there, the two tear into each other on the couch, fucking in multiple positions as Elizabeth moans for more. The scene builds to a explosive finish, ending with Manuel delivering a memorable facial that leaves Elizabeth completely satisfied.',
|
||||
actors: [
|
||||
{
|
||||
id: 44001,
|
||||
name: 'Elizabeth Skylar',
|
||||
},
|
||||
{
|
||||
id: 512,
|
||||
name: 'Manuel Ferrara',
|
||||
},
|
||||
],
|
||||
actorIds: [
|
||||
512,
|
||||
44001,
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
name: 'anal',
|
||||
slug: 'anal',
|
||||
},
|
||||
{
|
||||
name: 'ass to mouth',
|
||||
tag: 'atm',
|
||||
},
|
||||
],
|
||||
tagIds: [
|
||||
4,
|
||||
30,
|
||||
29,
|
||||
31,
|
||||
37,
|
||||
41,
|
||||
68,
|
||||
82,
|
||||
119,
|
||||
125,
|
||||
],
|
||||
entityId: 522,
|
||||
parentEntityId: 47,
|
||||
posterUrl: 'http://localhost:5100/media/posters/lazy/3c/84/f53e772e4710ca71fb74bdb6896af3d180725d17710b.jpeg',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Html>
|
||||
<Body style="font-family: sans-serif;">
|
||||
<Preview>{{ trigger.alerts.length }} new scene{{ trigger.alerts.length === 1 ? '' : 's' }} matching your alerts on traxxx</Preview>
|
||||
|
||||
<Container>
|
||||
<Img
|
||||
:src="`${address}/img/logo.png`"
|
||||
width="150"
|
||||
alt="traxxx"
|
||||
style="margin: 15px auto 25px auto;"
|
||||
/>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="color: #f65596; margin: 0 auto 0 auto;"
|
||||
>Hi, {{ trigger.username }}</Heading>
|
||||
|
||||
<Text style="font-size: 16px; margin: 20px 0;">Scenes for which you set e-mail alerts have been added or released:</Text>
|
||||
|
||||
<div
|
||||
v-for="(alert, index) in trigger.alerts"
|
||||
:key="alert.alert.id"
|
||||
style="margin-bottom: 15px; font-size: 14px;"
|
||||
:style="{
|
||||
'padding-bottom': index < trigger.alerts.length - 1 ? '15px' : 0,
|
||||
'border-bottom': index < trigger.alerts.length - 1 ? 'solid 1px #ddd' : 'none',
|
||||
}"
|
||||
>
|
||||
<Row style="margin-bottom: 8px;">
|
||||
<Column valign="top">
|
||||
{{ format(alert.scene.date, 'yyyy-MM-dd') }} |
|
||||
<span v-if="alert.alert.sceneIds?.length > 0">Scene is released</span>
|
||||
|
||||
<span v-else>
|
||||
New
|
||||
|
||||
<template
|
||||
v-if="alert.alert.tags.length > 0"
|
||||
>{{ alert.alert.tags.map((tag) => tag.name).join(', ') }}</template>
|
||||
|
||||
scene
|
||||
|
||||
<template
|
||||
v-if="alert.alert.actors.length > 0"
|
||||
>with {{ alert.alert.actors.map((actor) => actor.name).join(', ') }} </template>
|
||||
|
||||
<template
|
||||
v-if="alert.alert.entities.length > 0"
|
||||
>for {{ alert.alert.entities[0].name }} </template>
|
||||
|
||||
<template
|
||||
v-if="alert.alert.matches.length > 0"
|
||||
>matching {{ alert.alert.matches.map((match) => match.expression).join(', ') }}</template>
|
||||
</span>
|
||||
</Column>
|
||||
|
||||
<Column
|
||||
width="50"
|
||||
valign="top"
|
||||
style="text-align: right;"
|
||||
>
|
||||
<span style="font-size: 14px; color: #aaa;">#{{ alert.alert.id }}</span>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<div>
|
||||
<Row>
|
||||
<Column
|
||||
width="175"
|
||||
valign="top"
|
||||
>
|
||||
<Link :href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`">
|
||||
<Img
|
||||
v-if="alert.scene.posterUrl"
|
||||
:src="alert.scene.posterUrl"
|
||||
width="160"
|
||||
:alt="alert.scene.title"
|
||||
style="margin-top: 2px; border-radius: 8px;"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else
|
||||
style="width: 144px; height: 74px; background: #eee; color: #aaa; text-align: center; border-radius: 8px; padding: 8px;"
|
||||
>No thumb</div>
|
||||
</Link>
|
||||
</Column>
|
||||
|
||||
<Column valign="top">
|
||||
<div style="margin-bottom: 5px;">
|
||||
<Link
|
||||
:href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`"
|
||||
style="font-weight: bold; line-height: 1.5; color: inherit;"
|
||||
>{{ alert.scene.title }}</Link>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="font-size: 14px;">{{ alert.scene.actors.slice(0, 3).map((actor) => actor.name).join(', ') }}</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 8px;">
|
||||
<span style="font-size: 14px; color: #666;">{{ alert.scene.tags.slice(0, 5).map((tag) => tag.name).join(', ') }}</span>
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Heading
|
||||
as="h3"
|
||||
style="margin-top: 30px;"
|
||||
>Enjoy!</Heading>
|
||||
|
||||
<span style="font-size: 14px; color: #aaa;">Delivered by the Automated Notification and Alert Link</span>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
</template>
|
||||
@@ -33,6 +33,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
web: {
|
||||
address: 'https://traxxx.me',
|
||||
host: '0.0.0.0',
|
||||
port: 5000,
|
||||
sfwHost: '0.0.0.0',
|
||||
@@ -203,6 +204,8 @@ module.exports = {
|
||||
},
|
||||
email: {
|
||||
// Resend
|
||||
enabled: false,
|
||||
apiKey: null,
|
||||
from: 'noreply@alerts.traxxx.me',
|
||||
},
|
||||
};
|
||||
|
||||
3727
package-lock.json
generated
3727
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,9 @@
|
||||
"@resvg/resvg-js": "^2.6.2",
|
||||
"@toycode/markdown-it-class": "^1.2.4",
|
||||
"@vitejs/plugin-vue": "^6.0.7",
|
||||
"@vue-email/compiler": "^0.8.14",
|
||||
"@vue-email/components": "^0.0.21",
|
||||
"@vue-email/render": "^0.0.9",
|
||||
"@vue/compiler-sfc": "^3.5.39",
|
||||
"@vue/server-renderer": "^3.5.39",
|
||||
"@vueuse/components": "^14.3.0",
|
||||
|
||||
38
pages/admin/emails/+Page.vue
Normal file
38
pages/admin/emails/+Page.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import Admin from '#/components/admin/admin.vue';
|
||||
|
||||
const { pageProps } = inject('pageContext');
|
||||
|
||||
const { templates } = pageProps;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Admin class="page">
|
||||
<ul
|
||||
v-if="templates.length > 0"
|
||||
class="menu"
|
||||
>
|
||||
<li v-for="template in templates" :key="template">
|
||||
<a
|
||||
:href="`/admin/emails/${template}`"
|
||||
class="link"
|
||||
>{{ template }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else>No e-mail templates found in components/emails.</p>
|
||||
</Admin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu {
|
||||
margin: 0;
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
padding: .25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
18
pages/admin/emails/+onBeforeRender.js
Normal file
18
pages/admin/emails/+onBeforeRender.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { render } from 'vike/abort';
|
||||
|
||||
import { listEmailTemplates } from '#/src/email-templates.js';
|
||||
|
||||
export default function onBeforeRender(pageContext) {
|
||||
if (!pageContext.user || pageContext.user.role === 'user') {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: 'E-mail Previews',
|
||||
pageProps: {
|
||||
templates: listEmailTemplates(),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
70
pages/admin/emails/@template/+Page.vue
Normal file
70
pages/admin/emails/@template/+Page.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<script setup>
|
||||
import { inject } from 'vue';
|
||||
|
||||
import Admin from '#/components/admin/admin.vue';
|
||||
|
||||
const { pageProps } = inject('pageContext');
|
||||
|
||||
const {
|
||||
template,
|
||||
html,
|
||||
text,
|
||||
error,
|
||||
} = pageProps;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Admin class="page">
|
||||
<div class="header">
|
||||
<h2 class="heading">{{ template }}</h2>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">Failed to render template: {{ error }}</p>
|
||||
|
||||
<template v-else>
|
||||
<iframe
|
||||
:srcdoc="html"
|
||||
class="frame"
|
||||
title="E-mail preview"
|
||||
/>
|
||||
|
||||
<details v-if="text" class="text">
|
||||
<summary>Plain-text version</summary>
|
||||
<pre>{{ text }}</pre>
|
||||
</details>
|
||||
</template>
|
||||
</Admin>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--warn);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.frame {
|
||||
width: 100%;
|
||||
min-height: 60rem;
|
||||
border: solid 1px var(--shadow-weak-30);
|
||||
border-radius: .5rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-top: 1rem;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
padding: 1rem;
|
||||
background: var(--background-dark-20);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
pages/admin/emails/@template/+onBeforeRender.js
Normal file
38
pages/admin/emails/@template/+onBeforeRender.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { render } from 'vike/abort';
|
||||
|
||||
import { emailTemplates, listEmailTemplates } from '#/src/email-templates.js';
|
||||
|
||||
export default async function onBeforeRender(pageContext) {
|
||||
if (!pageContext.user || pageContext.user.role === 'user') {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
const { template } = pageContext.routeParams;
|
||||
|
||||
if (!listEmailTemplates().includes(template)) {
|
||||
throw render(404);
|
||||
}
|
||||
|
||||
let html = null;
|
||||
let text = null;
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
({ html, text } = await emailTemplates.render(`${template}.vue`));
|
||||
}
|
||||
catch (renderError) {
|
||||
error = renderError.message;
|
||||
}
|
||||
|
||||
return {
|
||||
pageContext: {
|
||||
title: `E-mail Preview: ${template}`,
|
||||
pageProps: {
|
||||
template,
|
||||
html,
|
||||
text,
|
||||
error,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
18
pages/admin/emails/@template/+route.js
Normal file
18
pages/admin/emails/@template/+route.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { match } from 'path-to-regexp';
|
||||
|
||||
const path = '/admin/emails/:template';
|
||||
const urlMatch = match(path, { decode: decodeURIComponent });
|
||||
|
||||
export default (pageContext) => {
|
||||
const matched = urlMatch(pageContext.urlPathname);
|
||||
|
||||
if (matched) {
|
||||
return {
|
||||
routeParams: {
|
||||
template: matched.params.template,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
BIN
public/img/logo.png
Normal file
BIN
public/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
149
src/alerts.js
149
src/alerts.js
@@ -1,9 +1,12 @@
|
||||
import config from 'config';
|
||||
import { CronJob } from 'cron';
|
||||
import escapeRegexp from 'escape-string-regexp';
|
||||
import markdownIt from 'markdown-it';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
import promiseProps from '../utils/promise-props.js';
|
||||
import { getIdsBySlug } from './cache.js';
|
||||
import { emailTemplates } from './email-templates.js';
|
||||
import { HttpError } from './errors.js';
|
||||
import { knexOwner as knex } from './knex.js';
|
||||
import initLogger from './logger.js';
|
||||
@@ -11,6 +14,25 @@ import { indexApi } from './manticore.js';
|
||||
import { fetchScenesById } from './scenes.js';
|
||||
|
||||
const logger = initLogger();
|
||||
const resend = new Resend(config.email.apiKey);
|
||||
|
||||
function getImagePath(media) {
|
||||
if (!media) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (media.isS3) {
|
||||
return new URL(media.lazy, config.media.s3Path).href;
|
||||
}
|
||||
|
||||
return new URL(`${config.media.mediaPath}/${media.lazy}`, config.web.address).href;
|
||||
}
|
||||
|
||||
const md = markdownIt({
|
||||
html: true,
|
||||
});
|
||||
|
||||
md.disable('code');
|
||||
|
||||
function curateAlert(alert, context = {}) {
|
||||
return {
|
||||
@@ -400,12 +422,68 @@ function groupByKey(data, byKey, itemKey) {
|
||||
acc[item[byKey]] = [];
|
||||
}
|
||||
|
||||
acc[item[byKey]].push(item[itemKey]);
|
||||
acc[item[byKey]].push(itemKey ? item[itemKey] : item);
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async function sendEmailAlerts(triggers, scenes) {
|
||||
if (!config.email.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene]));
|
||||
|
||||
const triggersByUserId = Object.values(triggers // prevent multiple emails for the same scene
|
||||
.filter((trigger) => trigger.alert.email && trigger.alert.userEmail)
|
||||
.reduce((acc, trigger) => {
|
||||
if (!acc[trigger.alert.userId]) {
|
||||
acc[trigger.alert.userId] = {
|
||||
userId: trigger.alert.userId,
|
||||
username: trigger.alert.username,
|
||||
userEmail: trigger.alert.userEmail,
|
||||
alerts: [],
|
||||
};
|
||||
}
|
||||
|
||||
acc[trigger.alert.userId].alerts.push({
|
||||
...trigger,
|
||||
scene: scenesBySceneId[trigger.sceneId] || null,
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {}));
|
||||
|
||||
const emails = await Promise.all(triggersByUserId.map(async (trigger) => {
|
||||
const { html, text } = await emailTemplates.render('alert.vue', {
|
||||
props: {
|
||||
trigger,
|
||||
address: config.web.address,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
from: config.email.from,
|
||||
to: [trigger.userEmail],
|
||||
subject: `Notification for ${trigger.alerts.length} scenes on traxxx`,
|
||||
html,
|
||||
text,
|
||||
};
|
||||
}));
|
||||
|
||||
const { data, error } = await resend.batch.send(emails);
|
||||
|
||||
if (error) {
|
||||
logger.error(`Failed to send ${emails.length} alert e-mails: ${error}`);
|
||||
}
|
||||
else {
|
||||
logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`);
|
||||
}
|
||||
}
|
||||
|
||||
const genderOrder = ['female', 'transsexual', 'male'];
|
||||
|
||||
export async function notify(sceneIds) {
|
||||
const scenes = await fetchScenesById(sceneIds);
|
||||
|
||||
@@ -419,21 +497,36 @@ export async function notify(sceneIds) {
|
||||
alertsMatches,
|
||||
alertsStashes,
|
||||
] = await Promise.all([
|
||||
knex('releases_actors').whereIn('release_id', sceneIds),
|
||||
knex('releases_tags').whereIn('release_id', sceneIds),
|
||||
knex('alerts'),
|
||||
knex('alerts_actors'),
|
||||
knex('alerts_tags'),
|
||||
knex('alerts_entities'),
|
||||
knex('releases_actors')
|
||||
.select('releases_actors.*', 'actors.name as actor_name', 'actors.gender as actor_gender')
|
||||
.whereIn('release_id', sceneIds)
|
||||
.leftJoin('actors', 'actors.id', 'releases_actors.actor_id'),
|
||||
knex('releases_tags')
|
||||
.select('releases_tags.*', 'tags.name as tag_name', 'tags.slug as tag_slug')
|
||||
.leftJoin('tags', 'tags.id', 'releases_tags.tag_id')
|
||||
.whereIn('release_id', sceneIds)
|
||||
.orderBy('tags.priority', 'desc'),
|
||||
knex('alerts')
|
||||
.select('alerts.*', 'users.username', 'users.email as user_email')
|
||||
.leftJoin('users', 'users.id', 'alerts.user_id'),
|
||||
knex('alerts_actors')
|
||||
.select('alerts_actors.*', 'actors.name as actor_name', 'actors.slug as actor_slug')
|
||||
.leftJoin('actors', 'actors.id', 'alerts_actors.actor_id'),
|
||||
knex('alerts_tags')
|
||||
.select('alerts_tags.*', 'tags.name as tag_name')
|
||||
.leftJoin('tags', 'tags.id', 'alerts_tags.tag_id'),
|
||||
knex('alerts_entities')
|
||||
.select('alerts_entities.*', 'entities.name as entity_name')
|
||||
.leftJoin('entities', 'entities.id', 'alerts_entities.entity_id'),
|
||||
knex('alerts_matches'),
|
||||
knex('alerts_stashes'),
|
||||
]);
|
||||
|
||||
const actorIdsByReleaseId = groupByKey(releasesActors, 'release_id', 'actor_id');
|
||||
const tagIdsByReleaseId = groupByKey(releasesTags, 'release_id', 'tag_id');
|
||||
const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', 'actor_id');
|
||||
const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', 'tag_id');
|
||||
const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', 'entity_id');
|
||||
const actorsByReleaseId = groupByKey(releasesActors, 'release_id', null);
|
||||
const tagsByReleaseId = groupByKey(releasesTags, 'release_id', null);
|
||||
const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', null);
|
||||
const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', null);
|
||||
const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', null);
|
||||
const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id');
|
||||
|
||||
const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => {
|
||||
@@ -452,6 +545,8 @@ export async function notify(sceneIds) {
|
||||
const alerts = rawAlerts.map((alert) => ({
|
||||
id: alert.id,
|
||||
userId: alert.user_id,
|
||||
username: alert.username,
|
||||
userEmail: alert.user_email,
|
||||
notify: alert.notify,
|
||||
email: alert.email,
|
||||
all: alert.all,
|
||||
@@ -459,23 +554,31 @@ export async function notify(sceneIds) {
|
||||
allEntities: alert.all_entities,
|
||||
allTags: alert.all_tags,
|
||||
allMatches: alert.all_matches,
|
||||
actors: alertsActorsByAlertId[alert.id] || [],
|
||||
tags: alertsTagsByAlertId[alert.id] || [],
|
||||
entities: alertsEntitiesByAlertId[alert.id] || [],
|
||||
actors: alertsActorsByAlertId[alert.id]
|
||||
?.map((actor) => ({ id: actor.actor_id, name: actor.actor_name, gender: actor.actor_gender }))
|
||||
.toSorted((actorA, actorB) => genderOrder.indexOf(actorA.gender) - genderOrder.indexOf(actorB.gender)) || [],
|
||||
tags: alertsTagsByAlertId[alert.id]?.map((tag) => ({ id: tag.tag_id, name: tag.tag_name })) || [],
|
||||
entities: alertsEntitiesByAlertId[alert.id]?.map((entity) => ({ id: entity.entity_id, name: entity.entity_name })) || [],
|
||||
matches: alertsMatchesByAlertId[alert.id] || [],
|
||||
stashes: alertsStashesByAlertId[alert.id] || [],
|
||||
stashIds: alertsStashesByAlertId[alert.id] || [],
|
||||
}));
|
||||
|
||||
const curatedScenes = scenes
|
||||
.filter((scene) => scene.isNew)
|
||||
.toSorted((sceneA, sceneB) => sceneB.date - sceneA.date)
|
||||
.map((scene) => ({
|
||||
id: scene.id,
|
||||
title: scene.title,
|
||||
slug: scene.slug,
|
||||
date: scene.date,
|
||||
description: scene.description,
|
||||
actorIds: actorIdsByReleaseId[scene.id] || [],
|
||||
tagIds: tagIdsByReleaseId[scene.id] || [],
|
||||
actors: (actorsByReleaseId[scene.id] || []).filter(Boolean).map((actor) => ({ id: actor.actor_id, name: actor.actor_name, slug: actor.slug })),
|
||||
actorIds: (actorsByReleaseId[scene.id] || []).map((actor) => actor.actor_id),
|
||||
tagIds: (tagsByReleaseId[scene.id] || []).map((tag) => tag.id),
|
||||
tags: (tagsByReleaseId[scene.id] || []).filter(Boolean).map((tag) => ({ slug: tag.tag_slug, name: tag.tag_name })),
|
||||
entityId: scene.channel?.id || scene.network?.id || null,
|
||||
parentEntityId: scene.network?.id,
|
||||
posterUrl: getImagePath(scene.poster),
|
||||
}));
|
||||
|
||||
const triggers = alerts.flatMap((alert) => {
|
||||
@@ -489,16 +592,16 @@ export async function notify(sceneIds) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (alert.actors.length > 0 && !alert.actors[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
|
||||
if (alert.actors.length > 0 && !alert.actors[alert.allActors ? 'every' : 'some']((actor) => scene.actorIds.includes(actor.id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (alert.tags.length > 0 && !alert.tags[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
|
||||
if (alert.tags.length > 0 && !alert.tags[alert.allTags ? 'every' : 'some']((tag) => scene.tagIds.includes(tag.id))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// multiple entities can only be matched in OR mode
|
||||
if (alert.entities.length > 0 && !alert.entities.some((alertEntityId) => alertEntityId === scene.entityId || alertEntityId === scene.parentEntityId)) {
|
||||
if (alert.entities.length > 0 && !alert.entities.some((alertEntity) => alertEntity.id === scene.entityId || alertEntity.id === scene.parentEntityId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -544,7 +647,7 @@ export async function notify(sceneIds) {
|
||||
scene_id: trigger.sceneId,
|
||||
}));
|
||||
|
||||
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashes.map((stashId) => ({
|
||||
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashIds.map((stashId) => ({
|
||||
stashId,
|
||||
sceneId: trigger.sceneId,
|
||||
userId: trigger.alert.userId,
|
||||
@@ -593,6 +696,8 @@ export async function notify(sceneIds) {
|
||||
}
|
||||
}
|
||||
|
||||
await sendEmailAlerts(triggers, curatedScenes);
|
||||
|
||||
return triggers;
|
||||
}
|
||||
|
||||
|
||||
15
src/email-templates.js
Normal file
15
src/email-templates.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { config as configureEmailTemplates } from '@vue-email/compiler';
|
||||
|
||||
export const emailsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), '../components/emails');
|
||||
|
||||
export const emailTemplates = configureEmailTemplates(emailsDir, { verbose: false });
|
||||
|
||||
export function listEmailTemplates() {
|
||||
return readdirSync(emailsDir)
|
||||
.filter((file) => file.endsWith('.vue'))
|
||||
.map((file) => file.replace(/\.vue$/, ''));
|
||||
}
|
||||
Reference in New Issue
Block a user