Run scheduled tasks through application database
This commit is contained in:
@@ -37,3 +37,6 @@ SMTP_SECURE=false
|
|||||||
SMTP_USER=
|
SMTP_USER=
|
||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=SHD.build <no-reply@shd.build>
|
SMTP_FROM=SHD.build <no-reply@shd.build>
|
||||||
|
|
||||||
|
# Long random bearer token used only by the internal container scheduler.
|
||||||
|
SCHEDULER_TOKEN=
|
||||||
|
|||||||
@@ -25,3 +25,4 @@ SMTP_PASSWORD="encrypted:BNsDbxMZqE142HZ8PyEytL9mG7L2y79rMvM6aqDcs0ONNYtjM2ffDwK
|
|||||||
SMTP_FROM="encrypted:BDsOlqmQFyAGOOJ2f+h9AS+CmV3uxEpy1mJaNzWIGFI1NMumy9s+dFgvKrunhAIImDSzod5BphAAOtUW8RyrIEO5wJvB1IJ+qoAw06hSWa4qWu1thdSu2ZEP3D+4ll7kpsOcMEU5PVFlSHQRLsPRdOS5mSGCy5VkXb2zhNAQ0A=="
|
SMTP_FROM="encrypted:BDsOlqmQFyAGOOJ2f+h9AS+CmV3uxEpy1mJaNzWIGFI1NMumy9s+dFgvKrunhAIImDSzod5BphAAOtUW8RyrIEO5wJvB1IJ+qoAw06hSWa4qWu1thdSu2ZEP3D+4ll7kpsOcMEU5PVFlSHQRLsPRdOS5mSGCy5VkXb2zhNAQ0A=="
|
||||||
|
|
||||||
ORIGIN="encrypted:BHOwfV6ahbNXg8A7HNbApjaDrQiwVXSI5f/tBx/eeFYdBxgQJ8zQQKUOzSeICdOtx1z9BxIOWHr86+E8ZG67k0RxSeevpSLWe8bINOy+7Xf9IzXBgg2aOpY0gWxAcXercLKvP+UNJJy3TuEl9sQZqRJBjNPyhyJB"
|
ORIGIN="encrypted:BHOwfV6ahbNXg8A7HNbApjaDrQiwVXSI5f/tBx/eeFYdBxgQJ8zQQKUOzSeICdOtx1z9BxIOWHr86+E8ZG67k0RxSeevpSLWe8bINOy+7Xf9IzXBgg2aOpY0gWxAcXercLKvP+UNJJy3TuEl9sQZqRJBjNPyhyJB"
|
||||||
|
SCHEDULER_TOKEN="encrypted:BOQwJdKcGoxWA70HS/7Rpv6uQiupCw+QuY54At7zuDZFopdUCOj+lu2N9I8L+497bROGBsWG2knGzu4NXvb264PS6c5kk3UGrcctAPMalN4Cb9mAm/ZHeC0rvTkF0Lk50/2sesc9Zmi8Qess8+hSRdyAttt8V7YoT6b4kDsX6sK1ws+s6cd+IG7Oxfc="
|
||||||
|
|||||||
@@ -40,12 +40,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env.staging.runtime
|
- .env.staging.runtime
|
||||||
environment:
|
environment:
|
||||||
SKIP_DEFAULT_USERS: "true"
|
SCHEDULER_URL: http://app:3000/api/internal/scheduler
|
||||||
TDR_DATABASE_PATH: /app/data/shd-build-staging.db
|
|
||||||
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
|
|
||||||
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
|
|
||||||
volumes:
|
|
||||||
- shd_stage_data:/app/data
|
|
||||||
depends_on:
|
depends_on:
|
||||||
app:
|
app:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -7,12 +7,7 @@ if [ "${1:-}" = "serve" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "${1:-}" = "scheduler" ]; then
|
if [ "${1:-}" = "scheduler" ]; then
|
||||||
while true; do
|
exec node scripts/scheduler-client.mjs
|
||||||
node scripts/system-notification-dispatch.mjs
|
|
||||||
node scripts/oversight-notification-dispatch.mjs
|
|
||||||
node scripts/notification-digest.mjs
|
|
||||||
sleep 60
|
|
||||||
done
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
+5
-2
@@ -109,8 +109,11 @@ The workflow:
|
|||||||
|
|
||||||
The short outage is limited to backup, migration, and container startup. This
|
The short outage is limited to backup, migration, and container startup. This
|
||||||
follows the database-preservation policy while minimizing data-entry downtime.
|
follows the database-preservation policy while minimizing data-entry downtime.
|
||||||
The scheduler runs automated system messages, Oversight consolidation, and
|
The scheduler calls a bearer-authenticated endpoint on the application service
|
||||||
email digest checks once per minute.
|
once per minute. Scheduled message delivery therefore uses the web process's
|
||||||
|
existing embedded-database connection instead of opening the locked database
|
||||||
|
from a second process. The endpoint runs automated system messages, Oversight
|
||||||
|
consolidation, and email digest checks and is not published through a host port.
|
||||||
|
|
||||||
## Initial development-data bootstrap
|
## Initial development-data bootstrap
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const endpoint = process.env.SCHEDULER_URL;
|
||||||
|
const token = process.env.SCHEDULER_TOKEN;
|
||||||
|
if (!endpoint || !token) throw new Error('SCHEDULER_URL and SCHEDULER_TOKEN are required.');
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Scheduler endpoint returned HTTP ${response.status}.`);
|
||||||
|
console.log(`Scheduler tick: ${JSON.stringify(await response.json())}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Scheduler tick failed', error);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 60_000));
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { getDatabase } from '$lib/server/database';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
function mailTransport() {
|
||||||
|
if (!env.SMTP_HOST || !env.SMTP_FROM) return null;
|
||||||
|
return nodemailer.createTransport({
|
||||||
|
host: env.SMTP_HOST,
|
||||||
|
port: Number(env.SMTP_PORT || 587),
|
||||||
|
secure: env.SMTP_SECURE === 'true',
|
||||||
|
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } : undefined,
|
||||||
|
requireTLS: env.SMTP_SECURE !== 'true',
|
||||||
|
disableFileAccess: true,
|
||||||
|
disableUrlAccess: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (value: unknown) => String(value).replace(/[&<>"']/g, (character) => ({
|
||||||
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
|
})[character] ?? character);
|
||||||
|
|
||||||
|
async function deliverSystemMessages() {
|
||||||
|
const database = await getDatabase();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const due = await (await database.prepare(`SELECT delivery.id AS delivery_id,delivery.user_id,message.*
|
||||||
|
FROM automated_system_message_deliveries delivery
|
||||||
|
JOIN automated_system_messages message ON message.id=delivery.message_id
|
||||||
|
WHERE delivery.delivered_at IS NULL AND delivery.scheduled_for<=? AND message.enabled=1
|
||||||
|
ORDER BY delivery.scheduled_for LIMIT 250`)).all(now);
|
||||||
|
const transport = mailTransport();
|
||||||
|
const origin = (env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
||||||
|
let delivered = 0;
|
||||||
|
for (const message of due) {
|
||||||
|
const notificationId = randomUUID();
|
||||||
|
await (await database.prepare(`INSERT INTO notifications
|
||||||
|
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
|
||||||
|
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)).run(
|
||||||
|
notificationId, message.user_id, message.source, message.title, message.body,
|
||||||
|
message.link ?? null, message.actions_json ?? null, now
|
||||||
|
);
|
||||||
|
await (await database.prepare(`UPDATE automated_system_message_deliveries
|
||||||
|
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
|
||||||
|
.run(now, notificationId, message.delivery_id);
|
||||||
|
const preference = await (await database.prepare(
|
||||||
|
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
||||||
|
)).get(message.user_id);
|
||||||
|
if (transport && preference?.email_frequency === 'immediate') {
|
||||||
|
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(message.user_id);
|
||||||
|
if (typeof identity?.email === 'string') {
|
||||||
|
try {
|
||||||
|
await transport.sendMail({
|
||||||
|
from: env.SMTP_FROM,
|
||||||
|
to: identity.email,
|
||||||
|
subject: `SHD.build · ${message.title}`,
|
||||||
|
text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
|
||||||
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">${escapeHtml(message.source)}</p><h1>${escapeHtml(message.title)}</h1><p style="color:#a5aaab">${escapeHtml(message.body)}</p>${message.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${message.link}`)}">OPEN SHD.BUILD</a>` : ''}</div>`
|
||||||
|
});
|
||||||
|
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Unable to email automated notification ${notificationId}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delivered++;
|
||||||
|
}
|
||||||
|
return delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverOversightMessages() {
|
||||||
|
const database = await getDatabase();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const due = await (await database.prepare(`SELECT * FROM oversight_notification_queue
|
||||||
|
WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`)).all(now);
|
||||||
|
const groups = new Map<string, Record<string, unknown>[]>();
|
||||||
|
for (const item of due) {
|
||||||
|
const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`;
|
||||||
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
|
groups.get(key)?.push(item);
|
||||||
|
}
|
||||||
|
const transport = mailTransport();
|
||||||
|
const origin = (env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
||||||
|
let delivered = 0;
|
||||||
|
for (const items of groups.values()) {
|
||||||
|
const first = items[0];
|
||||||
|
const categoryLabel = first.oversight_category === 'avatars' ? 'avatar' : 'data change';
|
||||||
|
const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`;
|
||||||
|
const details = items.slice(0, 10).map((item) => `• ${item.summary}`).join('\n');
|
||||||
|
const body = `${details}${items.length > 10 ? `\n• ${items.length - 10} additional review items` : ''}`;
|
||||||
|
const notificationId = randomUUID();
|
||||||
|
await (await database.prepare(`INSERT INTO notifications
|
||||||
|
(id,user_id,category,source,title,body,link,mandatory,created_at)
|
||||||
|
VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)).run(
|
||||||
|
notificationId, first.user_id, title, body, first.link, now
|
||||||
|
);
|
||||||
|
for (const item of items) {
|
||||||
|
await (await database.prepare(
|
||||||
|
'UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL'
|
||||||
|
)).run(now, item.id);
|
||||||
|
}
|
||||||
|
const preference = await (await database.prepare(
|
||||||
|
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
|
||||||
|
)).get(first.user_id);
|
||||||
|
if (transport && preference?.email_frequency === 'immediate') {
|
||||||
|
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(first.user_id);
|
||||||
|
if (typeof identity?.email === 'string') {
|
||||||
|
try {
|
||||||
|
await transport.sendMail({
|
||||||
|
from: env.SMTP_FROM,
|
||||||
|
to: identity.email,
|
||||||
|
subject: `SHD.build · ${title}`,
|
||||||
|
text: `${body}\n\n${origin}${first.link}`,
|
||||||
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">ISAC OVERSIGHT NETWORK</p><h1>${escapeHtml(title)}</h1><p style="white-space:pre-line;color:#a5aaab">${escapeHtml(body)}</p><a style="color:#f6a821" href="${escapeHtml(`${origin}${first.link}`)}">OPEN OVERSIGHT QUEUE</a></div>`
|
||||||
|
});
|
||||||
|
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Unable to email Oversight digest ${notificationId}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delivered += items.length;
|
||||||
|
}
|
||||||
|
return delivered;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deliverDailyDigests() {
|
||||||
|
const transport = mailTransport();
|
||||||
|
if (!transport) return 0;
|
||||||
|
const database = await getDatabase();
|
||||||
|
const origin = (env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
|
||||||
|
const recipients = await (await database.prepare(`SELECT DISTINCT n.user_id,e.email
|
||||||
|
FROM notifications n
|
||||||
|
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
||||||
|
JOIN user_emails e ON e.user_id=n.user_id
|
||||||
|
WHERE n.emailed_at IS NULL AND n.created_at>=p.updated_at`)).all();
|
||||||
|
let sent = 0;
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const items = await (await database.prepare(`SELECT n.id,n.title,n.body,n.link FROM notifications n
|
||||||
|
JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category AND p.email_frequency='daily'
|
||||||
|
WHERE n.user_id=? AND n.emailed_at IS NULL AND n.created_at>=p.updated_at ORDER BY n.created_at LIMIT 100`
|
||||||
|
)).all(recipient.user_id);
|
||||||
|
if (!items.length) continue;
|
||||||
|
const rows = items.map((item) => `<div style="border-top:1px solid #34393a;padding:16px 0"><strong>${escapeHtml(item.title)}</strong><p style="color:#a5aaab">${escapeHtml(item.body)}</p>${item.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${item.link}`)}">VIEW INTELLIGENCE</a>` : ''}</div>`).join('');
|
||||||
|
await transport.sendMail({
|
||||||
|
from: env.SMTP_FROM,
|
||||||
|
to: String(recipient.email),
|
||||||
|
subject: `SHD.build daily notification summary · ${items.length} update${items.length === 1 ? '' : 's'}`,
|
||||||
|
text: items.map((item) => `${item.title}\n${item.body}\n${item.link ? `${origin}${item.link}` : ''}`).join('\n\n'),
|
||||||
|
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD DAILY INTELLIGENCE</p><h1>${items.length} notification${items.length === 1 ? '' : 's'}</h1>${rows}</div>`
|
||||||
|
});
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const item of items) {
|
||||||
|
await (await database.prepare(
|
||||||
|
'UPDATE notifications SET emailed_at=? WHERE id=? AND emailed_at IS NULL'
|
||||||
|
)).run(now, item.id);
|
||||||
|
}
|
||||||
|
sent++;
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
let running: Promise<{ system: number; oversight: number; digests: number }> | null = null;
|
||||||
|
export function runScheduledNotificationTasks() {
|
||||||
|
if (running) return running;
|
||||||
|
running = (async () => {
|
||||||
|
const system = await deliverSystemMessages();
|
||||||
|
const oversight = await deliverOversightMessages();
|
||||||
|
const digests = await deliverDailyDigests();
|
||||||
|
return { system, oversight, digests };
|
||||||
|
})()
|
||||||
|
.finally(() => { running = null; });
|
||||||
|
return running;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { createHash, timingSafeEqual } from 'node:crypto';
|
||||||
|
import { env } from '$env/dynamic/private';
|
||||||
|
import { runScheduledNotificationTasks } from '$lib/server/scheduled-notifications';
|
||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
function tokenMatches(header: string | null) {
|
||||||
|
const configured = env.SCHEDULER_TOKEN;
|
||||||
|
const provided = header?.startsWith('Bearer ') ? header.slice(7) : '';
|
||||||
|
if (!configured || !provided) return false;
|
||||||
|
const expectedHash = createHash('sha256').update(configured).digest();
|
||||||
|
const providedHash = createHash('sha256').update(provided).digest();
|
||||||
|
return timingSafeEqual(expectedHash, providedHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST({ request }) {
|
||||||
|
if (!tokenMatches(request.headers.get('authorization'))) {
|
||||||
|
return json({ error: 'Not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
return json({ status: 'ok', ...(await runScheduledNotificationTasks()) });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user