Integrated e-mail alerts, added dialog option for release alerts.

This commit is contained in:
2026-07-21 04:31:12 +02:00
parent 6444cf21eb
commit 827566d57d
11 changed files with 487 additions and 308 deletions

View File

@@ -1,4 +1,5 @@
<script setup> <script setup>
import { format } from 'date-fns';
import { inject, ref, useId } from 'vue'; import { inject, ref, useId } from 'vue';
import Dialog from '#/components/dialog/dialog.vue'; import Dialog from '#/components/dialog/dialog.vue';
@@ -20,6 +21,10 @@ const props = defineProps({
type: Array, type: Array,
default: () => [], default: () => [],
}, },
presetScenes: {
type: Array,
default: () => [],
},
}); });
const emit = defineEmits(['alert', 'close']); const emit = defineEmits(['alert', 'close']);
@@ -45,6 +50,8 @@ const tagResults = ref([]);
const tagInput = ref(null); const tagInput = ref(null);
const tagQuery = ref(''); const tagQuery = ref('');
const scenes = ref(props.presetScenes);
const matches = ref([]); const matches = ref([]);
const matchProperty = ref('title'); const matchProperty = ref('title');
const matchExpression = ref(''); const matchExpression = ref('');
@@ -61,8 +68,8 @@ const addEntityDropdownId = useId();
const addMatchDropdownId = useId(); const addMatchDropdownId = useId();
const addStashDropdownId = useId(); const addStashDropdownId = useId();
const notify = ref(true); const notify = ref(JSON.parse(localStorage.getItem('alert_notify')) ?? true);
const email = ref(false); const email = ref(JSON.parse(localStorage.getItem('alert_email')) ?? false);
const stashes = ref([]); const stashes = ref([]);
@@ -72,6 +79,7 @@ async function createAlert() {
...tags.value.map((tag) => tag.name), ...tags.value.map((tag) => tag.name),
...entities.value.map((entity) => entity.name), ...entities.value.map((entity) => entity.name),
...matches.value.map((match) => match.expression), ...matches.value.map((match) => match.expression),
...scenes.value.map((scene) => scene.title),
].filter(Boolean).join(', '); ].filter(Boolean).join(', ');
const newAlert = await post('/alerts', { const newAlert = await post('/alerts', {
@@ -81,6 +89,7 @@ async function createAlert() {
allMatches: matchAnd.value, allMatches: matchAnd.value,
actors: actors.value.map((actor) => actor.id), actors: actors.value.map((actor) => actor.id),
tagIds: tags.value.map((tag) => tag.id), tagIds: tags.value.map((tag) => tag.id),
sceneIds: scenes.value.map((scene) => scene.id),
matches: matches.value, matches: matches.value,
entityIds: entities.value.map((entity) => entity.id), entityIds: entities.value.map((entity) => entity.id),
notify: notify.value, notify: notify.value,
@@ -162,6 +171,10 @@ function selectStash(selectedStash) {
stashes.value.push(selectedStash); stashes.value.push(selectedStash);
} }
} }
function rememberThen(domain, checked) {
localStorage.setItem(`alert_${domain}`, checked);
}
</script> </script>
<template> <template>
@@ -173,7 +186,33 @@ function selectStash(selectedStash) {
class="dialog-body" class="dialog-body"
@submit.prevent="createAlert" @submit.prevent="createAlert"
> >
<div class="dialog-section"> <div
v-if="scenes.length > 0"
class="dialog-section"
>
<div class="section-header">
<h3 class="heading">WHEN RELEASED</h3>
</div>
<div
v-for="scene in scenes"
:key="`alert-scene-${scene.id}`"
class="scene"
>
<span class="scene-title">{{ scene.title }}</span>
<span class="scene-details">
<strong class="scene-date"><time :datetime="scene.date.toISOString()">{{ format(scene.date, 'yyyy-MM-dd') }}</time></strong> on
<span class="scene-channel">{{ scene.channel?.name || scene.network?.name }}</span>
</span>
</div>
</div>
<div
v-else
class="dialog-section"
>
<div class="section-header"> <div class="section-header">
<h3 class="heading">IF</h3> <h3 class="heading">IF</h3>
</div> </div>
@@ -462,7 +501,7 @@ function selectStash(selectedStash) {
<span>Notify me in traxxx</span> <span>Notify me in traxxx</span>
<Checkbox <Checkbox
:checked="notify" :checked="notify"
@change="(checked) => notify = checked" @change="(checked) => { notify = checked; rememberThen('notify', checked); }"
/> />
</label> </label>
@@ -474,11 +513,14 @@ function selectStash(selectedStash) {
<Checkbox <Checkbox
:checked="email" :checked="email"
@change="(checked) => email = checked" @change="(checked) => { email = checked; rememberThen('email', checked); }"
/> />
</label> </label>
<div class="stash"> <div
v-if="scenes.length === 0"
class="stash"
>
<ul class="field-items nolist noselect"> <ul class="field-items nolist noselect">
<li <li
v-for="stash in stashes" v-for="stash in stashes"
@@ -577,6 +619,20 @@ function selectStash(selectedStash) {
padding: 1rem; padding: 1rem;
} }
.scene {
padding: 0 1rem;
}
.scene-title {
display: block;
line-height: 1.5;
margin-bottom: .5rem;
}
.scene-date {
color: var(--primary);
}
.field { .field {
display: flex; display: flex;
align-items: stretch; align-items: stretch;

View File

@@ -15,9 +15,13 @@ import {
import { format } from 'date-fns'; import { format } from 'date-fns';
defineProps({ defineProps({
trigger: { triggers: {
type: Array,
default: () => mockTriggers,
},
user: {
type: Object, type: Object,
default: () => mockTrigger, default: () => mockUser,
}, },
address: { address: {
type: String, type: String,
@@ -30,167 +34,142 @@ defineProps({
// defineProps' default factory (above, in <script setup>) references this. It must live in a plain // 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> block: Vue's compiler forbids defineProps from referencing variables declared inside
// <script setup> itself, since that block gets hoisted out of the setup() function. // <script setup> itself, since that block gets hoisted out of the setup() function.
const mockTrigger = { const mockUser = {
userId: 1, id: 1,
username: 'DebuggingLibrarian', username: 'DebuggingLibrarian',
userEmail: 'debug@alerts.traxxx.me', email: '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 Blakes 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 whats 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, Vanessas thick body bouncing with every thrust. When shes 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 Skylars 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 theres 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',
},
},
],
}; };
const mockTriggers = [
{
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 Blakes 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 whats 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, Vanessas thick body bouncing with every thrust. When shes 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',
alertId: 123,
alertActors: [],
alertTags: [],
alertEntities: [
{
id: 522,
name: 'Jules Jordan',
},
],
alertMatches: [],
alertSceneIds: [],
},
{
scene: {
id: 684060,
title: 'Elizabeth Skylars 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 theres 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',
alertId: 123,
alertActors: [],
alertTags: [],
alertEntities: [
{
id: 522,
name: 'Jules Jordan',
},
],
alertMatches: [],
alertSceneIds: [],
},
];
</script> </script>
<template> <template>
<Html> <Html>
<Body style="font-family: sans-serif;"> <Body style="font-family: sans-serif;">
<Preview>{{ trigger.alerts.length }} new scene{{ trigger.alerts.length === 1 ? '' : 's' }} matching your alerts on traxxx</Preview> <Preview>{{ triggers.length }} new scene{{ triggers.length === 1 ? '' : 's' }} matching your alerts on traxxx</Preview>
<Container> <Container>
<Img <Img
@@ -203,44 +182,44 @@ const mockTrigger = {
<Heading <Heading
as="h3" as="h3"
style="color: #f65596; margin: 0 auto 0 auto;" style="color: #f65596; margin: 0 auto 0 auto;"
>Hi, {{ trigger.username }}</Heading> >Hi, {{ user.username }}</Heading>
<Text style="font-size: 16px; margin: 20px 0;">Scenes for which you set e-mail alerts have been added or released:</Text> <Text style="font-size: 16px; margin: 20px 0;">Scenes for which you set e-mail alerts have been added or released:</Text>
<div <div
v-for="(alert, index) in trigger.alerts" v-for="(trigger, index) in triggers"
:key="alert.alert.id" :key="trigger.scene.id"
style="margin-bottom: 15px; font-size: 14px;" style="margin-bottom: 15px; font-size: 14px;"
:style="{ :style="{
'padding-bottom': index < trigger.alerts.length - 1 ? '15px' : 0, 'padding-bottom': index < triggers.length - 1 ? '15px' : 0,
'border-bottom': index < trigger.alerts.length - 1 ? 'solid 1px #ddd' : 'none', 'border-bottom': index < triggers.length - 1 ? 'solid 1px #ddd' : 'none',
}" }"
> >
<Row style="margin-bottom: 8px;"> <Row style="margin-bottom: 8px;">
<Column valign="top"> <Column valign="top">
{{ format(alert.scene.date, 'yyyy-MM-dd') }} | {{ format(trigger.scene.date, 'yyyy-MM-dd') }} |
<span v-if="alert.alert.sceneIds?.length > 0">Scene is released</span> <span v-if="trigger.alertSceneIds.length > 0">Scene is released</span>
<span v-else> <span v-else>
New New
<template <template
v-if="alert.alert.tags.length > 0" v-if="trigger.alertTags.length > 0"
>{{ alert.alert.tags.map((tag) => tag.name).join(', ') }}</template> >{{ trigger.alertTags.map((tag) => tag.name).join(', ') }}</template>
scene scene
<template <template
v-if="alert.alert.actors.length > 0" v-if="trigger.alertActors.length > 0"
>with {{ alert.alert.actors.map((actor) => actor.name).join(', ') }}&nbsp;</template> >with {{ trigger.alertActors.map((actor) => actor.name).join(', ') }}&nbsp;</template>
<template <template
v-if="alert.alert.entities.length > 0" v-if="trigger.alertEntities.length > 0"
>for {{ alert.alert.entities[0].name }}&nbsp;</template> >for {{ trigger.alertEntities[0].name }}&nbsp;</template>
<template <template
v-if="alert.alert.matches.length > 0" v-if="trigger.alertMatches.length > 0"
>matching {{ alert.alert.matches.map((match) => match.expression).join(', ') }}</template> >matching {{ trigger.alertMatches.map((match) => match.expression).join(', ') }}</template>
</span> </span>
</Column> </Column>
@@ -249,7 +228,7 @@ const mockTrigger = {
valign="top" valign="top"
style="text-align: right;" style="text-align: right;"
> >
<span style="font-size: 14px; color: #aaa;">#{{ alert.alert.id }}</span> <span style="font-size: 14px; color: #aaa;">#{{ trigger.alertId }}</span>
</Column> </Column>
</Row> </Row>
@@ -259,12 +238,12 @@ const mockTrigger = {
width="175" width="175"
valign="top" valign="top"
> >
<Link :href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`"> <Link :href="`${address}/scene/${trigger.scene.id}/${trigger.scene.slug}`">
<Img <Img
v-if="alert.scene.posterUrl" v-if="trigger.posterUrl"
:src="alert.scene.posterUrl" :src="trigger.posterUrl"
width="160" width="160"
:alt="alert.scene.title" :alt="trigger.scene.title"
style="margin-top: 2px; border-radius: 8px;" style="margin-top: 2px; border-radius: 8px;"
/> />
@@ -278,17 +257,17 @@ const mockTrigger = {
<Column valign="top"> <Column valign="top">
<div style="margin-bottom: 5px;"> <div style="margin-bottom: 5px;">
<Link <Link
:href="`${address}/scene/${alert.scene.id}/${alert.scene.slug}`" :href="`${address}/scene/${trigger.scene.id}/${trigger.scene.slug}`"
style="font-weight: bold; line-height: 1.5; color: inherit;" style="font-weight: bold; line-height: 1.5; color: inherit;"
>{{ alert.scene.title }}</Link> >{{ trigger.scene.title }}</Link>
</div> </div>
<div style="margin-bottom: 8px;"> <div style="margin-bottom: 8px;">
<span style="font-size: 14px;">{{ alert.scene.actors.slice(0, 3).map((actor) => actor.name).join(', ') }}</span> <span style="font-size: 14px;">{{ trigger.scene.actors.slice(0, 3).map((actor) => actor.name).join(', ') }}</span>
</div> </div>
<div style="margin-bottom: 8px;"> <div style="margin-bottom: 8px;">
<span style="font-size: 14px; color: #666;">{{ alert.scene.tags.slice(0, 5).map((tag) => tag.name).join(', ') }}</span> <span style="font-size: 14px; color: #666;">{{ trigger.scene.tags.slice(0, 5).map((tag) => tag.name).join(', ') }}</span>
</div> </div>
</Column> </Column>
</Row> </Row>

View File

@@ -125,6 +125,9 @@ async function alertItem() {
...(props.domain === 'scenes' && { scenes: [props.item.id] }), ...(props.domain === 'scenes' && { scenes: [props.item.id] }),
notify: true, notify: true,
email: false, email: false,
// probably too confusing and annoying to have quick alerts inherit email preference from dialog
// notify: JSON.parse(localStorage.getItem('alert_notify')) ?? true,
// email: JSON.parse(localStorage.getItem('alert_email')) ?? false,
preset: true, preset: true,
}, { }, {
successFeedback: `Alert set for '${alertLabel}'`, successFeedback: `Alert set for '${alertLabel}'`,
@@ -173,7 +176,7 @@ async function removeAlert() {
function setAlerted(alert) { function setAlerted(alert) {
// only set alerted status if the current item is the only one in the new stash // only set alerted status if the current item is the only one in the new stash
const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches]; const items = [...alert.actors, ...alert.tags, ...alert.entities, ...alert.matches, ...alert.scenes];
if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) { if (items.length === 1 && alert[props.domain][0]?.id === props.item.id) {
itemAlerted.value = true; itemAlerted.value = true;
@@ -219,7 +222,6 @@ async function reloadStashes(newStash) {
icon="bell2" icon="bell2"
class="alert active noselect" class="alert active noselect"
@click="removeAlert" @click="removeAlert"
@contextmenu.prevent="showAlertDialog = true"
/> />
<Icon <Icon
@@ -228,7 +230,6 @@ async function reloadStashes(newStash) {
icon="bell-plus" icon="bell-plus"
class="alert partial noselect" class="alert partial noselect"
@click="alertItem" @click="alertItem"
@contextmenu.prevent="showAlertDialog = true"
/> />
<Icon <Icon

View File

@@ -9,6 +9,9 @@ const pageContext = inject('pageContext');
const { user } = pageContext; const { user } = pageContext;
const settings = reactive({ const settings = reactive({
alertNotificationsEnabled: true,
alertEmailsEnabled: true,
alertEmailsFrequency: null,
...user.settings, ...user.settings,
}); });
@@ -22,16 +25,32 @@ async function updateSettings() {
class="profile-section" class="profile-section"
@submit.prevent @submit.prevent
> >
<h3 class="heading settings-heading">Alerts</h3>
<div class="setting"> <div class="setting">
<span class="setting-label"> <span class="setting-label">
<strong class="setting-title">Enable alert e-mails</strong> <strong class="setting-title">Enable alert notifications</strong>
<span class="setting-description">Whether you receive e-mail alerts</span> <span class="setting-description">Your alerts can trigger notifications in traxxx</span>
</span> </span>
<span class="setting-value"> <span class="setting-value">
<Checkbox <Checkbox
:checked="settings.alertEmailEnabled" :checked="settings.alertNotificationsEnabled"
@change="(checked) => { settings.alertEmailEnabled = checked; updateSettings(); }" @change="(checked) => { settings.alertNotificationsEnabled = checked; updateSettings(); }"
/>
</span>
</div>
<div class="setting">
<span class="setting-label">
<strong class="setting-title">Enable alert e-mails</strong>
<span class="setting-description">Your alerts can trigger e-mails</span>
</span>
<span class="setting-value">
<Checkbox
:checked="settings.alertEmailsEnabled"
@change="(checked) => { settings.alertEmailsEnabled = checked; updateSettings(); }"
/> />
</span> </span>
</div> </div>
@@ -39,12 +58,12 @@ async function updateSettings() {
<label class="setting"> <label class="setting">
<span class="setting-label"> <span class="setting-label">
<strong class="setting-title">Alert e-mail frequency</strong> <strong class="setting-title">Alert e-mail frequency</strong>
<span class="setting-description">How often you receive your e-mail alerts</span> <span class="setting-description">Alert e-mails are sent immediately, or accumulated and sent periodically</span>
</span> </span>
<span class="setting-value"> <span class="setting-value">
<select <select
v-model="settings.alertEmailFrequency" v-model="settings.alertEmailsFrequency"
class="input" class="input"
@change="updateSettings" @change="updateSettings"
> >
@@ -63,6 +82,10 @@ async function updateSettings() {
background: var(--background); background: var(--background);
} }
.profile-section .heading {
margin: .5rem 1rem 0 1rem;
}
.setting { .setting {
width: 100%; width: 100%;
display: flex; display: flex;

View File

@@ -36,7 +36,7 @@ function scrollHorizontal(event) {
class="user nolink" class="user nolink"
> >
<Icon <Icon
v-if="section" v-if="section && profile.id === user?.id"
icon="arrow-left3" icon="arrow-left3"
class="profile-back" class="profile-back"
/> />
@@ -51,7 +51,7 @@ function scrollHorizontal(event) {
</a> </a>
<strong <strong
v-if="section" v-if="section && profile.id === user?.id"
class="profile-crumb" class="profile-crumb"
> >
<template v-if="domain">{{ domain }}</template> <template v-if="domain">{{ domain }}</template>

View File

@@ -17,7 +17,7 @@ export default (pageContext) => {
routeParams: { routeParams: {
username: matched.params.username, username: matched.params.username,
section: matched.params.section || '', section: matched.params.section || '',
domain: matched.params.domain || 'scenes', domain: matched.params.domain || '',
}, },
}; };
} }

View File

@@ -1,5 +1,6 @@
import config from 'config'; import config from 'config';
import { CronJob } from 'cron'; import { CronJob } from 'cron';
import { add } from 'date-fns';
import escapeRegexp from 'escape-string-regexp'; import escapeRegexp from 'escape-string-regexp';
import markdownIt from 'markdown-it'; import markdownIt from 'markdown-it';
import { Resend } from 'resend'; import { Resend } from 'resend';
@@ -374,6 +375,7 @@ export async function fetchNotifications(reqUser, options = {}) {
.leftJoin('alerts_matches', 'alerts_matches.alert_id', 'alerts.id') .leftJoin('alerts_matches', 'alerts_matches.alert_id', 'alerts.id')
.leftJoin('alerts_scenes', 'alerts_scenes.alert_id', 'alerts.id') .leftJoin('alerts_scenes', 'alerts_scenes.alert_id', 'alerts.id')
.where('notifications.user_id', reqUser.id) .where('notifications.user_id', reqUser.id)
.where('notifications.notify', true) // notification table is also used to defer e-mail alerts
.limit(options.limit) .limit(options.limit)
.groupBy('notifications.id', 'alerts.id') .groupBy('notifications.id', 'alerts.id')
.orderBy('created_at', 'desc'), .orderBy('created_at', 'desc'),
@@ -428,68 +430,155 @@ function groupByKey(data, byKey, itemKey) {
}, {}); }, {});
} }
async function sendEmailAlerts(triggers, scenes) { const frequencies = {
daily: { days: 1 },
weekly: { weeks: 1 },
monthly: { months: 1 },
};
async function sendEmailAlerts() {
if (!config.email.enabled) { if (!config.email.enabled) {
return; return;
} }
const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene])); const qualifyingUsers = await knex('notifications')
.select('users.id as user_id', 'users.settings', knex.raw('max(emailed_at) as last_email'))
.where('notifications.email', true)
.innerJoin('users', 'users.id', 'notifications.user_id')
.groupBy('users.id')
.having(knex.raw('bool_or(emailed_at is null)'));
const triggersByUserId = Object.values(triggers // prevent multiple emails for the same scene const dueUserIds = qualifyingUsers
.filter((trigger) => trigger.alert.email && trigger.alert.userEmail) .filter(({ settings, last_email }) => {
.reduce((acc, trigger) => { const frequency = frequencies[settings?.alertEmailFrequency];
if (!acc[trigger.alert.userId]) {
acc[trigger.alert.userId] = { if (!frequency || !last_email) {
userId: trigger.alert.userId, // email setting set to immediate or invalid, or no recent emails sent
username: trigger.alert.username, return true;
userEmail: trigger.alert.userEmail,
alerts: [],
};
} }
acc[trigger.alert.userId].alerts.push({ // is this last e-mail sent before the frequency setting timeframe?
...trigger, return add(last_email, frequency) <= new Date();
scene: scenesBySceneId[trigger.sceneId] || null, })
.map((row) => row.user_id);
// immediately mark as e-mailed to prevent double e-mails if cronjob coincides with direct trigger
const markedNotifications = await knex('notifications')
.where('email', true)
.whereNull('emailed_at')
.whereIn('user_id', dueUserIds)
.update('emailed_at', knex.fn.now())
.returning('*');
const userIds = Array.from(new Set(markedNotifications.map((notification) => notification.user_id)));
const sceneIds = Array.from(new Set(markedNotifications.map((notification) => notification.scene_id)));
const alertIds = Array.from(new Set(markedNotifications.map((notification) => notification.alert_id)));
const scenes = await fetchScenesById(sceneIds);
const [
users,
alertsActors,
alertsTags,
alertsEntities,
alertsScenes,
alertsMatches,
] = await Promise.all([
knex('users')
.select('users.id', 'users.username', 'users.email', 'users.settings')
.whereIn('users.id', userIds),
knex('alerts_actors')
.select('alerts_actors.alert_id', 'actors.id', 'actors.name', 'actors.slug')
.whereIn('alerts_actors.alert_id', alertIds)
.leftJoin('actors', 'actors.id', 'alerts_actors.actor_id'),
knex('alerts_tags')
.select('alerts_tags.alert_id', 'tags.id', 'tags.name', 'tags.slug')
.whereIn('alerts_tags.alert_id', alertIds)
.leftJoin('tags', 'tags.id', 'alerts_tags.tag_id'),
knex('alerts_entities')
.select('alerts_entities.alert_id', 'entities.id', 'entities.type', 'entities.name', 'entities.slug')
.whereIn('alerts_entities.alert_id', alertIds)
.leftJoin('entities', 'entities.id', 'alerts_entities.entity_id'),
knex('alerts_scenes')
.whereIn('alerts_scenes.alert_id', alertIds),
knex('alerts_matches')
.whereIn('alerts_matches.alert_id', alertIds),
]);
const usersByUserId = Object.fromEntries(users.map((user) => [user.id, user]));
const scenesBySceneId = Object.fromEntries(scenes.map((scene) => [scene.id, scene]));
const alertActorsByAlertId = groupByKey(alertsActors, 'alert_id');
const alertTagsByAlertId = groupByKey(alertsTags, 'alert_id');
const alertEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id');
const alertMatchesByAlertId = groupByKey(alertsMatches, 'alert_id');
const alertSceneIdsByAlertId = groupByKey(alertsScenes, 'alert_id', 'scene_id');
const triggers = markedNotifications
.map((notification) => ({
scene: scenesBySceneId[notification.scene_id] || null,
posterUrl: getImagePath(scenesBySceneId[notification.scene_id]?.poster),
user: usersByUserId[notification.user_id] || null,
alertId: notification.alert_id,
alertTags: alertTagsByAlertId[notification.alert_id] || [],
alertActors: alertActorsByAlertId[notification.alert_id] || [],
alertEntities: alertEntitiesByAlertId[notification.alert_id] || [],
alertMatches: alertMatchesByAlertId[notification.alert_id] || [],
alertSceneIds: alertSceneIdsByAlertId[notification.alert_id] || [],
}))
.filter((notification) => !!notification.user
&& !!notification.scene
&& notification.user.settings?.alertEmailsEnabled !== false); // keep the e-mails marked as 'sent', if emails get re-enabled there shouldn't be a barrage of old notifications
const emails = await Promise.all(Object.values(usersByUserId).map(async (user) => {
const userTriggers = Object.values(Object.fromEntries(triggers
.filter((trigger) => trigger.user.id === user.id)
.map((trigger) => [trigger.scene.id, trigger])))
.toSorted((triggerA, triggerB) => triggerA.scene.effectiveDate - triggerB.scene.effectiveDate);
if (userTriggers.length === 0) {
return null;
}
try {
const { html, text } = await emailTemplates.render('alert.vue', {
props: {
user,
triggers: userTriggers,
},
}); });
return acc; return {
}, {})); from: config.email.from,
to: user.email,
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
html,
text,
};
}
catch (error) {
return null;
}
})).then((allEmails) => allEmails.filter(Boolean));
const emails = await Promise.all(triggersByUserId.map(async (trigger) => { if (emails.length === 0) {
const { html, text } = await emailTemplates.render('alert.vue', { logger.info(`No alert e-mails sent`);
props: { return;
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); const { data, error } = await resend.batch.send(emails);
if (error) { if (error) {
logger.error(`Failed to send ${emails.length} alert e-mails: ${error}`); logger.error(`Failed to send ${emails.length} alert e-mails (${error.statusCode} ${error.name}): ${error.message}`);
} }
else { else {
logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`); logger.info(`Sent ${data.data.length}/${emails.length} alert e-mails`);
} }
} }
const genderOrder = ['female', 'transsexual', 'male']; export async function notify(sceneIds, options = {}) {
export async function notify(sceneIds) {
const scenes = await fetchScenesById(sceneIds); const scenes = await fetchScenesById(sceneIds);
const [ const [
releasesActors,
releasesTags,
rawAlerts, rawAlerts,
alertsActors, alertsActors,
alertsTags, alertsTags,
@@ -497,36 +586,19 @@ export async function notify(sceneIds) {
alertsMatches, alertsMatches,
alertsStashes, alertsStashes,
] = await Promise.all([ ] = await Promise.all([
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') knex('alerts')
.select('alerts.*', 'users.username', 'users.email as user_email') .select('alerts.*', 'users.username', 'users.email as user_email', 'users.settings as user_settings')
.leftJoin('users', 'users.id', 'alerts.user_id'), .leftJoin('users', 'users.id', 'alerts.user_id'),
knex('alerts_actors') knex('alerts_actors').select('alert_id', 'actor_id'),
.select('alerts_actors.*', 'actors.name as actor_name', 'actors.slug as actor_slug') knex('alerts_tags').select('alert_id', 'tag_id'),
.leftJoin('actors', 'actors.id', 'alerts_actors.actor_id'), knex('alerts_entities').select('alert_id', 'entity_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_matches'),
knex('alerts_stashes'), knex('alerts_stashes'),
]); ]);
const actorsByReleaseId = groupByKey(releasesActors, 'release_id', null); const alertActorIdsByAlertId = groupByKey(alertsActors, 'alert_id', 'actor_id');
const tagsByReleaseId = groupByKey(releasesTags, 'release_id', null); const alertTagIdsByAlertId = groupByKey(alertsTags, 'alert_id', 'tag_id');
const alertsActorsByAlertId = groupByKey(alertsActors, 'alert_id', null); const alertEntityIdsByAlertId = groupByKey(alertsEntities, 'alert_id', 'entity_id');
const alertsTagsByAlertId = groupByKey(alertsTags, 'alert_id', null);
const alertsEntitiesByAlertId = groupByKey(alertsEntities, 'alert_id', null);
const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id'); const alertsStashesByAlertId = groupByKey(alertsStashes, 'alert_id', 'stash_id');
const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => { const alertsMatchesByAlertId = alertsMatches.reduce((acc, alertMatch) => {
@@ -547,6 +619,7 @@ export async function notify(sceneIds) {
userId: alert.user_id, userId: alert.user_id,
username: alert.username, username: alert.username,
userEmail: alert.user_email, userEmail: alert.user_email,
userSettings: alert.user_settings,
notify: alert.notify, notify: alert.notify,
email: alert.email, email: alert.email,
all: alert.all, all: alert.all,
@@ -554,54 +627,47 @@ export async function notify(sceneIds) {
allEntities: alert.all_entities, allEntities: alert.all_entities,
allTags: alert.all_tags, allTags: alert.all_tags,
allMatches: alert.all_matches, allMatches: alert.all_matches,
actors: alertsActorsByAlertId[alert.id] actorIds: alertActorIdsByAlertId[alert.id] || [],
?.map((actor) => ({ id: actor.actor_id, name: actor.actor_name, gender: actor.actor_gender })) tagIds: alertTagIdsByAlertId[alert.id] || [],
.toSorted((actorA, actorB) => genderOrder.indexOf(actorA.gender) - genderOrder.indexOf(actorB.gender)) || [], entityIds: alertEntityIdsByAlertId[alert.id] || [],
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] || [], matches: alertsMatchesByAlertId[alert.id] || [],
stashIds: alertsStashesByAlertId[alert.id] || [], stashIds: alertsStashesByAlertId[alert.id] || [],
})); }));
const curatedScenes = scenes const curatedScenes = scenes
.filter((scene) => scene.isNew) .filter((scene) => scene.isNew || options.spoofNew)
.toSorted((sceneA, sceneB) => sceneB.date - sceneA.date) .toSorted((sceneA, sceneB) => sceneB.date - sceneA.date)
.map((scene) => ({ .map((scene) => ({
id: scene.id, id: scene.id,
title: scene.title, title: scene.title,
slug: scene.slug,
date: scene.date,
description: scene.description, description: scene.description,
actors: (actorsByReleaseId[scene.id] || []).filter((actor) => !!actor.actor_name).map((actor) => ({ id: actor.actor_id, name: actor.actor_name, slug: actor.slug })), actorIds: scene.actors.map((actor) => actor.id),
actorIds: (actorsByReleaseId[scene.id] || []).map((actor) => actor.actor_id), tagIds: scene.tags.map((tag) => tag.id),
tagIds: (tagsByReleaseId[scene.id] || []).map((tag) => tag.id),
tags: (tagsByReleaseId[scene.id] || []).filter((tag) => !!tag.tag_name).map((tag) => ({ slug: tag.tag_slug, name: tag.tag_name })),
entityId: scene.channel?.id || scene.network?.id || null, entityId: scene.channel?.id || scene.network?.id || null,
parentEntityId: scene.network?.id, parentEntityId: scene.network?.id,
posterUrl: getImagePath(scene.poster),
})); }));
const triggers = alerts.flatMap((alert) => { const triggers = alerts.flatMap((alert) => {
const alertScenes = curatedScenes.filter((scene) => { const alertScenes = curatedScenes.filter((scene) => {
if (alert.all) { if (alert.all) {
if (alert.actors.length === 0 if (alert.actorIds.length === 0
&& alert.tags.length === 0 && alert.tagIds.length === 0
&& alert.entities.length === 0 && alert.entityIds.length === 0
&& alert.matches.length === 0) { && alert.matches.length === 0) {
// we must match on *something*, this is likely a release date alert handled separately // we must match on *something*, this is likely a release date alert handled separately
return false; return false;
} }
if (alert.actors.length > 0 && !alert.actors[alert.allActors ? 'every' : 'some']((actor) => scene.actorIds.includes(actor.id))) { if (alert.actorIds.length > 0 && !alert.actorIds[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
return false; return false;
} }
if (alert.tags.length > 0 && !alert.tags[alert.allTags ? 'every' : 'some']((tag) => scene.tagIds.includes(tag.id))) { if (alert.tagIds.length > 0 && !alert.tagIds[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
return false; return false;
} }
// multiple entities can only be matched in OR mode // multiple entities can only be matched in OR mode
if (alert.entities.length > 0 && !alert.entities.some((alertEntity) => alertEntity.id === scene.entityId || alertEntity.id === scene.parentEntityId)) { if (alert.entityIds.length > 0 && !alert.entityIds.some((entityId) => entityId === scene.entityId || entityId === scene.parentEntityId)) {
return false; return false;
} }
@@ -612,16 +678,16 @@ export async function notify(sceneIds) {
return true; return true;
} }
if (alert.matches.length > 0 && alert.actors[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) { if (alert.matches.length > 0 && alert.actorIds[alert.allActors ? 'every' : 'some']((actorId) => scene.actorIds.includes(actorId))) {
return true; return true;
} }
if (alert.tags.length > 0 && alert.tags[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) { if (alert.tagIds.length > 0 && alert.tagIds[alert.allTags ? 'every' : 'some']((tagId) => scene.tagIds.includes(tagId))) {
return true; return true;
} }
// multiple entities can only be matched in OR mode // 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.entityIds.length > 0 && alert.entityIds.some((entityId) => entityId === scene.entityId || entityId === scene.parentEntityId)) {
return true; return true;
} }
@@ -638,15 +704,39 @@ export async function notify(sceneIds) {
})); }));
}); });
const notifications = Object.values(Object.fromEntries(triggers // prevent multiple notifications for the same scene // e-mails are stored as notifications to facilitate the e-mail frequency buffer
.filter((trigger) => trigger.alert.notify) const notifications = Object.values(triggers // prevent multiple notifications for the same scene
.map((trigger) => [`${trigger.alert.userId}:${trigger.sceneId}`, trigger]))) .map((trigger) => ({
...trigger,
notify: trigger.alert.notify && trigger.alert.userSettings?.alertNotificationsEnabled !== false,
email: trigger.alert.email && trigger.alert.userEmail && trigger.alert.userSettings?.alertEmailsEnabled !== false,
}))
.filter((trigger) => trigger.notify || trigger.email)
.reduce((acc, trigger) => {
const key = `${trigger.alert.userId}:${trigger.sceneId}`;
acc[key] = {
...trigger,
notify: acc[key]?.notify || trigger.notify,
email: acc[key]?.email || trigger.email,
};
return acc;
}, {}))
.map((trigger) => ({ .map((trigger) => ({
user_id: trigger.alert.userId, user_id: trigger.alert.userId,
alert_id: trigger.alert.id, alert_id: trigger.alert.id,
scene_id: trigger.sceneId, scene_id: trigger.sceneId,
// we assign the alert outputs to this specific notification, instead of joining them from the alerts table
// this ensures changing the alert outputs later doesn't trigger a barrage of e-mails from old notifications
notify: trigger.notify,
email: trigger.email,
})); }));
if (notifications.length === 0) {
logger.verbose(`No alert notifications triggered`);
}
const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashIds.map((stashId) => ({ const uniqueStashes = Object.values(Object.fromEntries(triggers.flatMap((trigger) => trigger.alert.stashIds.map((stashId) => ({
stashId, stashId,
sceneId: trigger.sceneId, sceneId: trigger.sceneId,
@@ -696,7 +786,7 @@ export async function notify(sceneIds) {
} }
} }
await sendEmailAlerts(triggers, curatedScenes); await sendEmailAlerts();
return triggers; return triggers;
} }
@@ -704,8 +794,7 @@ export async function notify(sceneIds) {
async function alertSceneReleases() { async function alertSceneReleases() {
const alerts = await knex('alerts_scenes') const alerts = await knex('alerts_scenes')
.select( .select(
'alerts.id', 'alerts.*',
'alerts.user_id',
'releases.id as scene_id', 'releases.id as scene_id',
'releases.title as scene_title', 'releases.title as scene_title',
'releases.slug as scene_slug', 'releases.slug as scene_slug',
@@ -728,6 +817,8 @@ async function alertSceneReleases() {
user_id: alert.user_id, user_id: alert.user_id,
scene_id: alert.scene_id, scene_id: alert.scene_id,
alert_id: alert.id, alert_id: alert.id,
notify: alert.notify,
email: alert.email,
}))); })));
await trx('alerts') await trx('alerts')
@@ -778,6 +869,9 @@ export function initAlertCron() {
alertSceneReleases(), alertSceneReleases(),
purgeExpiredAlerts(), purgeExpiredAlerts(),
]); ]);
// allow release alerts to set e-mail notifications
await sendEmailAlerts();
}, },
start: config.alerts.enabled, start: config.alerts.enabled,
runOnInit: true, runOnInit: true,

View File

@@ -1,5 +1,6 @@
import { initAlertCron } from './alerts.js'; import { initAlertCron } from './alerts.js';
import { initCaches } from './cache.js'; import { initCaches } from './cache.js';
import { initStashViewsCron } from './stashes.js';
import { initSyncCron } from './sync.js'; import { initSyncCron } from './sync.js';
import initServer from './web/server.js'; import initServer from './web/server.js';
@@ -9,6 +10,7 @@ async function init() {
initServer(); initServer();
initSyncCron(); initSyncCron();
initAlertCron(); initAlertCron();
initStashViewsCron();
} }
init(); init();

View File

@@ -602,16 +602,18 @@ export async function unstashMovie(movieId, stashId, sessionUser) {
return curateStashed(unstashed); return curateStashed(unstashed);
} }
CronJob.from({ export function initStashViewsCron() {
cronTime: config.stashes.viewRefreshCrontab, CronJob.from({
async onTick() { cronTime: config.stashes.viewRefreshCrontab,
logger.verbose('Updating stash views'); async onTick() {
logger.verbose('Updating stash views');
await refreshView('scenes'); await refreshView('scenes');
await refreshView('actors'); await refreshView('actors');
await refreshView('movies'); await refreshView('movies');
await refreshView('stashes'); await refreshView('stashes');
}, },
start: true, start: true,
runOnInit: true, runOnInit: true,
}); });
}

View File

@@ -138,8 +138,9 @@ export async function createBan(ban, reqUser) {
} }
const validSettingKeys = [ const validSettingKeys = [
'alertEmailEnabled', 'alertNotificationsEnabled',
'alertEmailFrequency', 'alertEmailsEnabled',
'alertEmailsFrequency',
]; ];
export async function updateSettings(settings, reqUser) { export async function updateSettings(settings, reqUser) {
@@ -147,15 +148,13 @@ export async function updateSettings(settings, reqUser) {
throw new HttpError('You need to be authenticated to change your settings', 401); throw new HttpError('You need to be authenticated to change your settings', 401);
} }
console.log('SETTINGS', settings);
const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key)); const invalidKeys = Object.keys(settings).filter((key) => !validSettingKeys.includes(key));
if (invalidKeys.length > 0) { if (invalidKeys.length > 0) {
throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400); throw new HttpError(`Invalid settings: ${invalidKeys.join()}`, 400);
} }
if (settings.alertEmailFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailFrequency)) { if (settings.alertEmailsFrequency && !['instant', 'daily', 'weekly', 'monthly'].includes(settings.alertEmailsFrequency)) {
throw new HttpError('Invalid email frequency setting', 400); throw new HttpError('Invalid email frequency setting', 400);
} }
@@ -164,7 +163,5 @@ export async function updateSettings(settings, reqUser) {
.update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)])) .update('settings', knex.raw('coalesce(settings, \'{}\'::jsonb) || ?::jsonb', [JSON.stringify(settings)]))
.returning('settings'); .returning('settings');
console.log('UPDATED SETTINGS', updatedSettings);
return updatedSettings; return updatedSettings;
} }

25
tools/notify.js Normal file
View File

@@ -0,0 +1,25 @@
import process from 'node:process';
import yargs from 'yargs';
import { notify } from '../src/alerts.js';
import { knexOwner as knex } from '../src/knex.js';
import redis from '../src/redis.js';
const { argv } = yargs(process.argv.slice(2))
.option('scenes', {
type: 'array',
alias: ['scene-ids'],
});
async function triggerNotify() {
if (!argv.scenes.length) {
throw new Error('Must specify at least one scene ID');
}
await notify(argv.scenes, { spoofNew: true });
await knex.destroy();
await redis.quit();
}
triggerNotify();