Integrated e-mail alerts, added dialog option for release alerts.
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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,36 +34,14 @@ 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: [
|
};
|
||||||
|
|
||||||
|
const mockTriggers = [
|
||||||
{
|
{
|
||||||
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: {
|
scene: {
|
||||||
id: 684061,
|
id: 684061,
|
||||||
title: 'Thick & Bootylicious: Vanessa Blake Takes A BBC Up Her Ass',
|
title: 'Thick & Bootylicious: Vanessa Blake Takes A BBC Up Her Ass',
|
||||||
@@ -108,34 +90,21 @@ const mockTrigger = {
|
|||||||
],
|
],
|
||||||
entityId: 522,
|
entityId: 522,
|
||||||
parentEntityId: 47,
|
parentEntityId: 47,
|
||||||
// posterUrl: 'http://localhost:5100/media/posters/lazy/cd/b5/99375c7998a20acffef2217a8a21c3546924312cec65.jpeg',
|
|
||||||
},
|
},
|
||||||
},
|
posterUrl: 'http://localhost:5100/media/posters/lazy/cd/b5/99375c7998a20acffef2217a8a21c3546924312cec65.jpeg',
|
||||||
{
|
alertId: 123,
|
||||||
sceneId: 684060,
|
alertActors: [],
|
||||||
alert: {
|
alertTags: [],
|
||||||
id: 565,
|
alertEntities: [
|
||||||
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,
|
id: 522,
|
||||||
name: 'Jules Jordan',
|
name: 'Jules Jordan',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
matches: [],
|
alertMatches: [],
|
||||||
stashIds: [],
|
alertSceneIds: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
scene: {
|
scene: {
|
||||||
id: 684060,
|
id: 684060,
|
||||||
title: 'Elizabeth Skylar’s Big ASSets Drive The Milfomaniac Manuel Ferrara Wild',
|
title: 'Elizabeth Skylar’s Big ASSets Drive The Milfomaniac Manuel Ferrara Wild',
|
||||||
@@ -180,17 +149,27 @@ const mockTrigger = {
|
|||||||
],
|
],
|
||||||
entityId: 522,
|
entityId: 522,
|
||||||
parentEntityId: 47,
|
parentEntityId: 47,
|
||||||
posterUrl: 'http://localhost:5100/media/posters/lazy/3c/84/f53e772e4710ca71fb74bdb6896af3d180725d17710b.jpeg',
|
|
||||||
},
|
},
|
||||||
|
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(', ') }} </template>
|
>with {{ trigger.alertActors.map((actor) => actor.name).join(', ') }} </template>
|
||||||
|
|
||||||
<template
|
<template
|
||||||
v-if="alert.alert.entities.length > 0"
|
v-if="trigger.alertEntities.length > 0"
|
||||||
>for {{ alert.alert.entities[0].name }} </template>
|
>for {{ trigger.alertEntities[0].name }} </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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
258
src/alerts.js
258
src/alerts.js
@@ -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);
|
||||||
|
|
||||||
return acc;
|
// 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 emails = await Promise.all(triggersByUserId.map(async (trigger) => {
|
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', {
|
const { html, text } = await emailTemplates.render('alert.vue', {
|
||||||
props: {
|
props: {
|
||||||
trigger,
|
user,
|
||||||
address: config.web.address,
|
triggers: userTriggers,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
from: config.email.from,
|
from: config.email.from,
|
||||||
to: [trigger.userEmail],
|
to: user.email,
|
||||||
subject: `Notification for ${trigger.alerts.length} scenes on traxxx`,
|
subject: `Notification for ${userTriggers.length} scenes on traxxx`,
|
||||||
html,
|
html,
|
||||||
text,
|
text,
|
||||||
};
|
};
|
||||||
}));
|
}
|
||||||
|
catch (error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})).then((allEmails) => allEmails.filter(Boolean));
|
||||||
|
|
||||||
|
if (emails.length === 0) {
|
||||||
|
logger.info(`No alert e-mails sent`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -602,7 +602,8 @@ export async function unstashMovie(movieId, stashId, sessionUser) {
|
|||||||
return curateStashed(unstashed);
|
return curateStashed(unstashed);
|
||||||
}
|
}
|
||||||
|
|
||||||
CronJob.from({
|
export function initStashViewsCron() {
|
||||||
|
CronJob.from({
|
||||||
cronTime: config.stashes.viewRefreshCrontab,
|
cronTime: config.stashes.viewRefreshCrontab,
|
||||||
async onTick() {
|
async onTick() {
|
||||||
logger.verbose('Updating stash views');
|
logger.verbose('Updating stash views');
|
||||||
@@ -614,4 +615,5 @@ CronJob.from({
|
|||||||
},
|
},
|
||||||
start: true,
|
start: true,
|
||||||
runOnInit: true,
|
runOnInit: true,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
11
src/users.js
11
src/users.js
@@ -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
25
tools/notify.js
Normal 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();
|
||||||
Reference in New Issue
Block a user