Initial TypeScript event calendar service
This commit is contained in:
commit
5887d1255a
11 files changed
+1389
No files matched your search
+110
@@ -0,0 +1,110 @@
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { getEvents, getPollStatus, refresh, startPolling } from './poller.js';
|
||||
import type { StoredCalendarEvent } from './types.js';
|
||||
import { getCalendarEntries } from './storage.js';
|
||||
|
||||
const port = Number(process.env.PORT ?? 3000);
|
||||
|
||||
function sendJson(response: ServerResponse, status: number, body: unknown): void {
|
||||
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
||||
response.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function escapeIcs(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/\r?\n/g, '\\n').replace(/,/g, '\\,').replace(/;/g, '\\;');
|
||||
}
|
||||
|
||||
function icsDate(value: string): string {
|
||||
return new Date(value).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
function icsDay(value: string): string {
|
||||
return value.slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
function toIcsEvent(event: StoredCalendarEvent): string {
|
||||
return [
|
||||
'BEGIN:VEVENT',
|
||||
`UID:${escapeIcs(event.id)}@77th-jsoc.com`,
|
||||
`DTSTAMP:${icsDate(new Date().toISOString())}`,
|
||||
`SEQUENCE:${event.sequence}`,
|
||||
`STATUS:${event.status === 'cancelled' ? 'CANCELLED' : 'CONFIRMED'}`,
|
||||
...(event.allDay
|
||||
? [`DTSTART;VALUE=DATE:${icsDay(event.start)}`, `DTEND;VALUE=DATE:${icsDay(event.end)}`]
|
||||
: [`DTSTART:${icsDate(event.start)}`, `DTEND:${icsDate(event.end)}`]),
|
||||
`SUMMARY:${escapeIcs(event.title)}`,
|
||||
...(event.description ? [`DESCRIPTION:${escapeIcs(event.description)}`] : []),
|
||||
...(event.location ? [`LOCATION:${escapeIcs(event.location)}`] : []),
|
||||
...(event.url ? [`URL:${escapeIcs(event.url)}`] : []),
|
||||
'END:VEVENT',
|
||||
].join('\r\n');
|
||||
}
|
||||
|
||||
function calendarBody(): string {
|
||||
return [
|
||||
'BEGIN:VCALENDAR',
|
||||
'VERSION:2.0',
|
||||
'PRODID:-//77th EV Calendar Notify Service//EN',
|
||||
'CALSCALE:GREGORIAN',
|
||||
'METHOD:PUBLISH',
|
||||
'X-WR-CALNAME:77th EV Calendar',
|
||||
...getCalendarEntries().map(toIcsEvent),
|
||||
'END:VCALENDAR',
|
||||
'',
|
||||
].join('\r\n');
|
||||
}
|
||||
|
||||
async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
|
||||
const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`);
|
||||
if (request.method === 'GET' && url.pathname === '/health') {
|
||||
sendJson(response, 200, { status: 'ok', ...getPollStatus() });
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/api/events') {
|
||||
sendJson(response, 200, { events: getEvents(), ...getPollStatus() });
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && url.pathname === '/api/refresh') {
|
||||
await refresh();
|
||||
const status = getPollStatus();
|
||||
sendJson(response, status.lastError ? 502 : 200, status);
|
||||
return;
|
||||
}
|
||||
if (request.method === 'GET' && url.pathname === '/calendar.ics') {
|
||||
response.writeHead(200, {
|
||||
'content-type': 'text/calendar; charset=utf-8',
|
||||
'content-disposition': 'inline; filename="calendar.ics"',
|
||||
'cache-control': 'public, max-age=300',
|
||||
});
|
||||
response.end(calendarBody());
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
}
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
void handle(request, response).catch((error: unknown) => {
|
||||
sendJson(response, 500, { error: error instanceof Error ? error.message : 'Internal server error' });
|
||||
});
|
||||
});
|
||||
|
||||
const pollTimer = startPolling();
|
||||
server.listen(port, () => {
|
||||
console.log(`HTTP server listening on port ${port}`);
|
||||
console.log(`Calendar feed: http://localhost:${port}/calendar.ics`);
|
||||
});
|
||||
|
||||
function shutdown(): void {
|
||||
clearInterval(pollTimer);
|
||||
server.close(() => process.exit(0));
|
||||
}
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { PendingNotification } from './types.js';
|
||||
|
||||
interface NotificationPayload {
|
||||
urls: string[];
|
||||
title: string;
|
||||
body: string;
|
||||
format: 'markdown';
|
||||
}
|
||||
|
||||
function queryString(values: Record<string, string | undefined>): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value) query.set(key, value);
|
||||
}
|
||||
const result = query.toString();
|
||||
return result ? `?${result}` : '';
|
||||
}
|
||||
|
||||
function ntfyUrl(link?: string): string {
|
||||
const host = process.env.ntfy_host;
|
||||
const topic = process.env.ntfy_topic;
|
||||
if (!host || !topic) throw new Error('ntfy_host and ntfy_topic must be configured');
|
||||
const username = process.env.ntfy_username;
|
||||
const password = process.env.ntfy_password;
|
||||
if (Boolean(username) !== Boolean(password)) throw new Error('Configure both ntfy_username and ntfy_password, or neither');
|
||||
const credentials = username && password
|
||||
? `${encodeURIComponent(username)}:${encodeURIComponent(password)}@`
|
||||
: '';
|
||||
return `ntfys://${credentials}${host}/${encodeURIComponent(topic)}${queryString({ click: link, format: 'markdown' })}`;
|
||||
}
|
||||
|
||||
function getDiscordWebhookParts(value: string): { id: string; token: string } {
|
||||
let parts: string[];
|
||||
if (value.startsWith('https://') || value.startsWith('http://') || value.startsWith('discord://')) {
|
||||
const parsed = new URL(value);
|
||||
parts = parsed.pathname.split('/').filter(Boolean);
|
||||
if (parsed.protocol === 'discord:') parts = [parsed.hostname, ...parts];
|
||||
if (parts[0] === 'api' && parts[1] === 'webhooks') parts = parts.slice(2);
|
||||
} else {
|
||||
parts = value.split('/').filter(Boolean);
|
||||
}
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
||||
throw new Error('dc_webhook must be a Discord webhook URL or WEBHOOK_ID/WEBHOOK_TOKEN');
|
||||
}
|
||||
return { id: decodeURIComponent(parts[0]), token: decodeURIComponent(parts[1]) };
|
||||
}
|
||||
|
||||
function discordUrl(link?: string): string {
|
||||
const webhook = process.env.dc_webhook;
|
||||
if (!webhook) throw new Error('dc_webhook must be configured');
|
||||
const { id, token } = getDiscordWebhookParts(webhook);
|
||||
const botname = process.env.discord_botname;
|
||||
const username = botname ? `${encodeURIComponent(botname)}@` : '';
|
||||
return `discord://${username}${encodeURIComponent(id)}/${encodeURIComponent(token)}/${queryString({
|
||||
href: link ?? process.env.discord_href,
|
||||
avatar_url: process.env.discord_avatar_url,
|
||||
format: 'markdown',
|
||||
})}`;
|
||||
}
|
||||
|
||||
function appriseEndpoint(): string {
|
||||
const protocol = process.env.apprise_https === 'true' ? 'https' : 'http';
|
||||
const host = process.env.apprise_host || 'apprise';
|
||||
const port = process.env.apprise_port || (protocol === 'https' ? '443' : '80');
|
||||
return `${protocol}://${host}:${port}/notify`;
|
||||
}
|
||||
|
||||
export async function sendNotification(notification: PendingNotification): Promise<void> {
|
||||
if (process.env.notification_mock === 'true') {
|
||||
console.info('[notification mock]', notification.title, notification.body);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
urls: [ntfyUrl(notification.link), discordUrl(notification.link)],
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
format: 'markdown',
|
||||
};
|
||||
const response = await fetch(appriseEndpoint(), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Apprise returned HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
import type { CalendarEvent, PollStatus, StoredCalendarEvent } from './types.js';
|
||||
import { getActiveEvents, getPendingNotifications, markNotificationFailed, markNotificationSent, saveMonthlySnapshot } from './storage.js';
|
||||
import { sendNotification } from './notifications.js';
|
||||
|
||||
const endpoint = 'https://77th-jsoc.com/service.php?action=get_events';
|
||||
const timezoneOffsetMinutes = -120;
|
||||
const pollIntervalMs = 15 * 60 * 1000;
|
||||
const horizonDays = 60;
|
||||
let events: CalendarEvent[] = getActiveEvents();
|
||||
let lastCheckedAt: string | null = null;
|
||||
let lastUpdatedAt: string | null = null;
|
||||
let lastError: string | null = null;
|
||||
let inFlight: Promise<void> | undefined;
|
||||
|
||||
interface SourceEvent {
|
||||
uid: string | number;
|
||||
title: string;
|
||||
description?: string;
|
||||
date_at: string;
|
||||
time_start: string;
|
||||
time_end: string;
|
||||
timezone?: string | number;
|
||||
location?: string;
|
||||
link?: string;
|
||||
posted_by?: string;
|
||||
event_type?: string | number;
|
||||
}
|
||||
|
||||
interface MonthlySnapshot {
|
||||
monthKey: string;
|
||||
events: StoredCalendarEvent[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function eventDateTime(date: string, time: string, offsetMinutes: number): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
||||
const timeMatch = /^(\d{1,2}):(\d{2})$/.exec(time);
|
||||
if (!match || !timeMatch) throw new Error(`Invalid event date or time: ${date} ${time}`);
|
||||
const [, year, month, day] = match;
|
||||
const [, hour, minute] = timeMatch;
|
||||
const localAsUtc = Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute));
|
||||
return new Date(localAsUtc + offsetMinutes * 60_000).toISOString();
|
||||
}
|
||||
|
||||
function nextDate(date: string): string {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return new Date(Date.UTC(year!, month! - 1, day! + 1)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function mapEvent(value: unknown, index: number, sourceMonth: string): StoredCalendarEvent {
|
||||
if (!isRecord(value)) throw new Error(`Event at index ${index} must be an object`);
|
||||
const source = value as unknown as Partial<SourceEvent>;
|
||||
if ((typeof source.uid !== 'string' && typeof source.uid !== 'number') || String(source.uid).trim() === '') {
|
||||
throw new Error(`Event at index ${index} is missing its uid`);
|
||||
}
|
||||
if (typeof source.title !== 'string' || !source.title.trim() || typeof source.date_at !== 'string') {
|
||||
throw new Error(`Event at index ${index} is missing its title or date_at`);
|
||||
}
|
||||
if (typeof source.time_start !== 'string' || typeof source.time_end !== 'string') {
|
||||
throw new Error(`Event at index ${index} is missing its start or end time`);
|
||||
}
|
||||
|
||||
const allDay = source.time_start.toLowerCase() === 'all' || source.time_end.toLowerCase() === 'all';
|
||||
let start: string;
|
||||
let end: string;
|
||||
if (allDay) {
|
||||
start = `${source.date_at}T00:00:00.000Z`;
|
||||
end = `${nextDate(source.date_at)}T00:00:00.000Z`;
|
||||
} else {
|
||||
const offset = Number(source.timezone ?? timezoneOffsetMinutes);
|
||||
if (!Number.isFinite(offset)) throw new Error(`Event at index ${index} has an invalid timezone offset`);
|
||||
start = eventDateTime(source.date_at, source.time_start, offset);
|
||||
let endMs = Date.parse(eventDateTime(source.date_at, source.time_end, offset));
|
||||
const startMs = Date.parse(start);
|
||||
if (endMs < startMs) endMs += 24 * 60 * 60 * 1000;
|
||||
if (endMs === startMs) endMs += 60 * 60 * 1000;
|
||||
end = new Date(endMs).toISOString();
|
||||
}
|
||||
|
||||
const eventTypes: Record<string, string> = { '1': 'Public Event', '2': 'Private Mission', '3': 'Private Meeting' };
|
||||
const eventType = eventTypes[String(source.event_type)] ?? (source.event_type === undefined ? 'Unknown' : `Type ${source.event_type}`);
|
||||
return {
|
||||
id: String(source.uid),
|
||||
title: source.title,
|
||||
eventType,
|
||||
start,
|
||||
end,
|
||||
sourceMonth,
|
||||
status: 'confirmed',
|
||||
sequence: 0,
|
||||
...(allDay ? { allDay: true } : {}),
|
||||
...(source.description ? { description: source.description } : {}),
|
||||
...(source.location ? { location: source.location } : {}),
|
||||
...(source.posted_by ? { postedBy: source.posted_by } : {}),
|
||||
...(source.link ? { url: source.link } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function monthsInRange(from: Date, through: Date): Array<{ year: number; month: number; monthKey: string }> {
|
||||
const months: Array<{ year: number; month: number; monthKey: string }> = [];
|
||||
const cursor = new Date(Date.UTC(from.getUTCFullYear(), from.getUTCMonth(), 1));
|
||||
const last = new Date(Date.UTC(through.getUTCFullYear(), through.getUTCMonth(), 1));
|
||||
while (cursor <= last) {
|
||||
const year = cursor.getUTCFullYear();
|
||||
const month = cursor.getUTCMonth() + 1;
|
||||
months.push({ year, month, monthKey: `${year}-${String(month).padStart(2, '0')}` });
|
||||
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
async function fetchMonth(year: number, month: number, monthKey: string): Promise<MonthlySnapshot> {
|
||||
const url = new URL(endpoint);
|
||||
url.searchParams.set('year', String(year));
|
||||
url.searchParams.set('month', String(month));
|
||||
url.searchParams.set('timezone', String(timezoneOffsetMinutes));
|
||||
const response = await fetch(url, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(20_000) });
|
||||
if (!response.ok) throw new Error(`Events endpoint returned HTTP ${response.status} for ${monthKey}`);
|
||||
const payload: unknown = await response.json();
|
||||
if (!isRecord(payload) || !Array.isArray(payload.events)) {
|
||||
throw new Error(`Events endpoint returned an invalid response for ${monthKey}`);
|
||||
}
|
||||
return { monthKey, events: payload.events.map((event, index) => mapEvent(event, index, monthKey)) };
|
||||
}
|
||||
|
||||
async function deliverPendingNotifications(): Promise<void> {
|
||||
for (const notification of getPendingNotifications()) {
|
||||
try {
|
||||
await sendNotification(notification);
|
||||
markNotificationSent(notification.id);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
markNotificationFailed(notification.id, message);
|
||||
console.error(`Apprise notification ${notification.id} failed: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getEvents(): CalendarEvent[] {
|
||||
return events;
|
||||
}
|
||||
|
||||
export function getPollStatus(): PollStatus {
|
||||
return { configured: true, lastCheckedAt, lastUpdatedAt, lastError, eventCount: events.length };
|
||||
}
|
||||
|
||||
export function refresh(): Promise<void> {
|
||||
if (inFlight) return inFlight;
|
||||
inFlight = (async () => {
|
||||
lastCheckedAt = new Date().toISOString();
|
||||
try {
|
||||
const now = new Date();
|
||||
const through = new Date(now.getTime() + horizonDays * 24 * 60 * 60 * 1000);
|
||||
const months = monthsInRange(now, through);
|
||||
const snapshots = await Promise.all(months.map(({ year, month, monthKey }) => fetchMonth(year, month, monthKey)));
|
||||
const monthKeys = snapshots.map((snapshot) => snapshot.monthKey);
|
||||
const incoming = snapshots.flatMap((snapshot) => snapshot.events);
|
||||
const uniqueByUid = new Map(incoming.map((event) => [event.id, event]));
|
||||
const { initialPoll } = saveMonthlySnapshot(monthKeys, [...uniqueByUid.values()]);
|
||||
if (initialPoll) console.info('Initial event snapshot saved; notifications are suppressed for this poll.');
|
||||
events = getActiveEvents();
|
||||
lastUpdatedAt = new Date().toISOString();
|
||||
lastError = null;
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
await deliverPendingNotifications();
|
||||
})().finally(() => {
|
||||
inFlight = undefined;
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
export function startPolling(): NodeJS.Timeout {
|
||||
void refresh();
|
||||
return setInterval(() => void refresh(), pollIntervalMs);
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import type { CalendarEvent, PendingNotification, StoredCalendarEvent } from './types.js';
|
||||
|
||||
const databasePath = resolve(process.env.DB_PATH ?? './data/events.sqlite');
|
||||
mkdirSync(dirname(databasePath), { recursive: true });
|
||||
const database = new Database(databasePath);
|
||||
database.pragma('journal_mode = WAL');
|
||||
|
||||
const existingEventsTable = database.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'events'",
|
||||
).get();
|
||||
if (existingEventsTable) {
|
||||
const columns = database.pragma('table_info(events)') as Array<{ name: string }>;
|
||||
if (!columns.some((column) => column.name === 'uid')) {
|
||||
database.exec(`ALTER TABLE events RENAME TO events_legacy_title_${Date.now()}`);
|
||||
}
|
||||
}
|
||||
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
uid TEXT PRIMARY KEY COLLATE BINARY,
|
||||
title TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL DEFAULT 'Unknown',
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL,
|
||||
description TEXT,
|
||||
location TEXT,
|
||||
posted_by TEXT,
|
||||
url TEXT,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
source_month TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('confirmed', 'cancelled')),
|
||||
sequence INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT NOT NULL,
|
||||
cancelled_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notification_outbox (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT NOT NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
change_type TEXT NOT NULL CHECK (change_type IN ('added', 'changed', 'removed')),
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
link TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
sent_at TEXT,
|
||||
UNIQUE (uid, sequence, change_type)
|
||||
);
|
||||
`);
|
||||
|
||||
const eventColumns = database.pragma('table_info(events)') as Array<{ name: string }>;
|
||||
if (!eventColumns.some((column) => column.name === 'event_type')) database.exec("ALTER TABLE events ADD COLUMN event_type TEXT NOT NULL DEFAULT 'Unknown'");
|
||||
if (!eventColumns.some((column) => column.name === 'posted_by')) database.exec('ALTER TABLE events ADD COLUMN posted_by TEXT');
|
||||
|
||||
const outboxColumns = database.pragma('table_info(notification_outbox)') as Array<{ name: string }>;
|
||||
if (!outboxColumns.some((column) => column.name === 'link')) database.exec('ALTER TABLE notification_outbox ADD COLUMN link TEXT');
|
||||
|
||||
const activeRows = database.prepare(`
|
||||
SELECT uid AS id, uid, title, event_type AS eventType, start, end, description, location, posted_by AS postedBy, url,
|
||||
all_day AS allDay, source_month AS sourceMonth, status, sequence
|
||||
FROM events WHERE status = 'confirmed' ORDER BY start, uid
|
||||
`);
|
||||
const calendarRows = database.prepare(`
|
||||
SELECT uid AS id, uid, title, event_type AS eventType, start, end, description, location, posted_by AS postedBy, url,
|
||||
all_day AS allDay, source_month AS sourceMonth, status, sequence
|
||||
FROM events ORDER BY start, uid
|
||||
`);
|
||||
const eventByUid = database.prepare(`
|
||||
SELECT uid AS id, uid, title, event_type AS eventType, start, end, description, location, posted_by AS postedBy, url,
|
||||
all_day AS allDay, source_month AS sourceMonth, status, sequence
|
||||
FROM events WHERE uid = ?
|
||||
`);
|
||||
const upsertEvent = database.prepare(`
|
||||
INSERT INTO events (uid, title, event_type, start, end, description, location, posted_by, url, all_day, source_month, status, sequence, updated_at, cancelled_at)
|
||||
VALUES (@uid, @title, @eventType, @start, @end, @description, @location, @postedBy, @url, @allDay, @sourceMonth, 'confirmed', @sequence, @updatedAt, NULL)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
event_type = excluded.event_type,
|
||||
start = excluded.start,
|
||||
end = excluded.end,
|
||||
description = excluded.description,
|
||||
location = excluded.location,
|
||||
posted_by = excluded.posted_by,
|
||||
url = excluded.url,
|
||||
all_day = excluded.all_day,
|
||||
source_month = excluded.source_month,
|
||||
status = 'confirmed',
|
||||
sequence = excluded.sequence,
|
||||
updated_at = excluded.updated_at,
|
||||
cancelled_at = NULL
|
||||
`);
|
||||
const cancelEvent = database.prepare(`
|
||||
UPDATE events SET status = 'cancelled', sequence = sequence + 1, updated_at = ?, cancelled_at = ?
|
||||
WHERE uid = ? AND status = 'confirmed'
|
||||
`);
|
||||
const pendingRows = database.prepare(`
|
||||
SELECT id, title, body, link, 'markdown' AS format
|
||||
FROM notification_outbox WHERE sent_at IS NULL ORDER BY id
|
||||
`);
|
||||
const markSent = database.prepare('UPDATE notification_outbox SET sent_at = ?, last_error = NULL WHERE id = ?');
|
||||
const markFailed = database.prepare('UPDATE notification_outbox SET attempts = attempts + 1, last_error = ? WHERE id = ?');
|
||||
|
||||
function mapRow(row: Record<string, unknown>): StoredCalendarEvent {
|
||||
return {
|
||||
id: String(row.uid),
|
||||
title: String(row.title),
|
||||
eventType: String(row.eventType ?? 'Unknown'),
|
||||
start: String(row.start),
|
||||
end: String(row.end),
|
||||
allDay: Number(row.allDay) === 1,
|
||||
sourceMonth: String(row.sourceMonth),
|
||||
status: row.status === 'cancelled' ? 'cancelled' : 'confirmed',
|
||||
sequence: Number(row.sequence),
|
||||
...(typeof row.description === 'string' ? { description: row.description } : {}),
|
||||
...(typeof row.location === 'string' ? { location: row.location } : {}),
|
||||
...(typeof row.postedBy === 'string' ? { postedBy: row.postedBy } : {}),
|
||||
...(typeof row.url === 'string' ? { url: row.url } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function eventDetails(event: StoredCalendarEvent): string[] {
|
||||
// Source event times are wall-clock values in fixed GMT+2 throughout the year.
|
||||
const start = event.allDay ? undefined : new Date(Date.parse(event.start) + 120 * 60_000);
|
||||
const date = start ? start.toISOString().slice(0, 10) : event.start.slice(0, 10);
|
||||
let time = 'All day';
|
||||
if (start) {
|
||||
const hour = start.getUTCHours();
|
||||
const minute = start.getUTCMinutes();
|
||||
const delta = hour * 60 + minute - 21 * 60;
|
||||
const offset = delta === 0 ? ' (OP Time)' : ` (OP Time ${delta > 0 ? '+' : '-'}${Math.floor(Math.abs(delta) / 60)}:${String(Math.abs(delta) % 60).padStart(2, '0')})`;
|
||||
time = `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}${offset}`;
|
||||
}
|
||||
return [
|
||||
`Title: ${event.title}`,
|
||||
`Date: ${date}`,
|
||||
`Time: ${time}`,
|
||||
`Type: ${event.eventType}`,
|
||||
...(event.location ? [`Location: ${event.location}`] : []),
|
||||
...(event.postedBy ? [`By: ${event.postedBy}`] : []),
|
||||
...(event.url ? [`Link: ${event.url}`] : []),
|
||||
...(event.description ? [`Description: ${event.description}`] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function queueNotification(
|
||||
kind: 'added' | 'changed' | 'removed',
|
||||
event: StoredCalendarEvent,
|
||||
previous?: StoredCalendarEvent,
|
||||
): void {
|
||||
const label = kind === 'added' ? '<New>' : kind === 'changed' ? '<Changed>' : '<Removed>';
|
||||
const metaTitle = `${label} - ${event.title} (${event.eventType})`;
|
||||
const body = [
|
||||
metaTitle,
|
||||
'',
|
||||
...(previous && kind === 'changed' ? ['Previous details:', ...eventDetails(previous), '', 'Current details:', ...eventDetails(event)] : eventDetails(event)),
|
||||
].join('\n');
|
||||
database.prepare(`
|
||||
INSERT OR IGNORE INTO notification_outbox (uid, sequence, change_type, title, body, link, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(event.id, event.sequence, kind, metaTitle, body, event.url ?? null, new Date().toISOString());
|
||||
}
|
||||
|
||||
export function getActiveEvents(): CalendarEvent[] {
|
||||
return (activeRows.all() as Record<string, unknown>[]).map((row) => {
|
||||
const event = mapRow(row);
|
||||
return {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
...(event.allDay ? { allDay: true } : {}),
|
||||
...(event.description ? { description: event.description } : {}),
|
||||
...(event.location ? { location: event.location } : {}),
|
||||
...(event.url ? { url: event.url } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getCalendarEntries(): StoredCalendarEvent[] {
|
||||
return (calendarRows.all() as Record<string, unknown>[]).map(mapRow);
|
||||
}
|
||||
|
||||
export function saveMonthlySnapshot(monthKeys: string[], incoming: StoredCalendarEvent[]): { initialPoll: boolean } {
|
||||
const now = new Date().toISOString();
|
||||
const uniqueIncoming = new Map(incoming.map((event) => [event.id, event]));
|
||||
const presentUids = new Set(uniqueIncoming.keys());
|
||||
const transaction = database.transaction(() => {
|
||||
const count = database.prepare('SELECT COUNT(*) AS count FROM events').get() as { count: number };
|
||||
const initialPoll = count.count === 0;
|
||||
|
||||
for (const event of uniqueIncoming.values()) {
|
||||
const row = eventByUid.get(event.id) as Record<string, unknown> | undefined;
|
||||
const previous = row ? mapRow(row) : undefined;
|
||||
const changed = previous !== undefined && previous.status === 'confirmed' && (
|
||||
previous.title !== event.title || previous.eventType !== event.eventType || previous.start !== event.start || previous.end !== event.end ||
|
||||
(previous.description ?? '') !== (event.description ?? '') ||
|
||||
(previous.location ?? '') !== (event.location ?? '') || (previous.postedBy ?? '') !== (event.postedBy ?? '') || (previous.url ?? '') !== (event.url ?? '') ||
|
||||
Boolean(previous.allDay) !== Boolean(event.allDay)
|
||||
);
|
||||
const reactivated = previous?.status === 'cancelled';
|
||||
const kind = initialPoll ? undefined : previous === undefined || reactivated ? 'added' : changed ? 'changed' : undefined;
|
||||
const sequence = (previous?.sequence ?? 0) + (kind ? 1 : 0);
|
||||
const saved: StoredCalendarEvent = { ...event, sequence, status: 'confirmed' };
|
||||
|
||||
upsertEvent.run({
|
||||
uid: event.id,
|
||||
title: event.title,
|
||||
eventType: event.eventType,
|
||||
start: event.start,
|
||||
end: event.end,
|
||||
description: event.description ?? null,
|
||||
location: event.location ?? null,
|
||||
postedBy: event.postedBy ?? null,
|
||||
url: event.url ?? null,
|
||||
allDay: event.allDay ? 1 : 0,
|
||||
sourceMonth: event.sourceMonth,
|
||||
sequence,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (!initialPoll && kind) queueNotification(kind, saved, changed ? previous : undefined);
|
||||
}
|
||||
|
||||
if (!initialPoll && monthKeys.length > 0) {
|
||||
const scopedRows = database.prepare(
|
||||
`SELECT uid AS id, uid, title, event_type AS eventType, start, end, description, location, posted_by AS postedBy, url, all_day AS allDay, source_month AS sourceMonth, status, sequence
|
||||
FROM events WHERE status = 'confirmed' AND source_month IN (${monthKeys.map(() => '?').join(', ')})`,
|
||||
).all(...monthKeys) as Record<string, unknown>[];
|
||||
for (const row of scopedRows) {
|
||||
const previous = mapRow(row);
|
||||
if (presentUids.has(previous.id)) continue;
|
||||
const removed: StoredCalendarEvent = { ...previous, status: 'cancelled', sequence: previous.sequence + 1 };
|
||||
cancelEvent.run(now, now, previous.id);
|
||||
queueNotification('removed', removed);
|
||||
}
|
||||
}
|
||||
|
||||
return { initialPoll };
|
||||
});
|
||||
return transaction();
|
||||
}
|
||||
|
||||
export function getPendingNotifications(): PendingNotification[] {
|
||||
return (pendingRows.all() as Record<string, unknown>[]).map((row) => ({
|
||||
id: Number(row.id),
|
||||
title: String(row.title),
|
||||
body: String(row.body),
|
||||
format: 'markdown',
|
||||
...(typeof row.link === 'string' ? { link: row.link } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function markNotificationSent(id: number): void {
|
||||
markSent.run(new Date().toISOString(), id);
|
||||
}
|
||||
|
||||
export function markNotificationFailed(id: number, error: string): void {
|
||||
markFailed.run(error.slice(0, 1000), id);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
description?: string;
|
||||
location?: string;
|
||||
url?: string;
|
||||
allDay?: boolean;
|
||||
}
|
||||
|
||||
export interface StoredCalendarEvent extends CalendarEvent {
|
||||
eventType: string;
|
||||
postedBy?: string;
|
||||
sourceMonth: string;
|
||||
status: 'confirmed' | 'cancelled';
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export interface PendingNotification {
|
||||
id: number;
|
||||
title: string;
|
||||
body: string;
|
||||
format: 'markdown';
|
||||
link?: string;
|
||||
}
|
||||
|
||||
export interface PollStatus {
|
||||
configured: boolean;
|
||||
lastCheckedAt: string | null;
|
||||
lastUpdatedAt: string | null;
|
||||
lastError: string | null;
|
||||
eventCount: number;
|
||||
}
|
||||
Reference in new issue
Block a user