Files
wtr/lib/push-store.ts
zv ee55521803
All checks were successful
CI / Lint, typecheck and build (push) Successful in 9m56s
chore: add prettier formatting
2026-06-14 20:26:56 +02:00

298 lines
10 KiB
TypeScript

import Database from "better-sqlite3";
import fs from "node:fs";
import path from "node:path";
import {
DEFAULT_TEMPERATURE_UNIT,
DEFAULT_WIND_SPEED_UNIT,
isTemperatureUnit,
isWindSpeedUnit,
} from "@/lib/weather-utils";
import type { WarningPushSubscription } from "@/types/notifications";
import type { Province } from "@/types/province";
import type { WeatherRegion } from "@/types/weather-region";
interface PushDatabase {
database: Database.Database;
}
interface PushSubscriptionRow {
endpoint: string;
subscription_json: string;
province: string;
region?: string;
language: string;
enabled: number;
morning_brief_enabled: number;
tomorrow_brief_enabled: number;
latitude: number | null;
longitude: number | null;
location_name: string | null;
timezone: string | null;
county_teryt: string | null;
temperature_unit: string | null;
wind_speed_unit: string | null;
created_at: string;
updated_at: string;
}
const globalStore = globalThis as typeof globalThis & { __wtrPushDatabase?: PushDatabase };
function getDatabasePath() {
const configuredPath = process.env.WTR_DATABASE_PATH;
if (configuredPath) {
return path.isAbsolute(configuredPath)
? configuredPath
: path.join(/* turbopackIgnore: true */ process.cwd(), configuredPath);
}
return path.join(/* turbopackIgnore: true */ process.cwd(), "data", "wtr.sqlite");
}
function ensureDatabaseDirectory(databasePath: string) {
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
}
function initializeDatabase(database: Database.Database) {
database.pragma("journal_mode = WAL");
database.pragma("foreign_keys = ON");
database.exec(`
CREATE TABLE IF NOT EXISTS push_subscriptions (
endpoint TEXT PRIMARY KEY,
subscription_json TEXT NOT NULL,
province TEXT NOT NULL DEFAULT 'global',
region TEXT NOT NULL DEFAULT 'PL',
language TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
morning_brief_enabled INTEGER NOT NULL DEFAULT 0,
tomorrow_brief_enabled INTEGER NOT NULL DEFAULT 0,
latitude REAL,
longitude REAL,
location_name TEXT,
timezone TEXT,
county_teryt TEXT,
temperature_unit TEXT NOT NULL DEFAULT '${DEFAULT_TEMPERATURE_UNIT}',
wind_speed_unit TEXT NOT NULL DEFAULT '${DEFAULT_WIND_SPEED_UNIT}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sent_warnings (
endpoint TEXT NOT NULL,
warning_id TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (endpoint, warning_id),
FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sent_morning_briefs (
endpoint TEXT NOT NULL,
date_key TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (endpoint, date_key),
FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sent_tomorrow_briefs (
endpoint TEXT NOT NULL,
date_key TEXT NOT NULL,
sent_at TEXT NOT NULL,
PRIMARY KEY (endpoint, date_key),
FOREIGN KEY (endpoint) REFERENCES push_subscriptions(endpoint) ON DELETE CASCADE
);
`);
const columns = database.prepare("PRAGMA table_info(push_subscriptions)").all() as Array<{ name: string }>;
if (!columns.some((column) => column.name === "region")) {
database.prepare("ALTER TABLE push_subscriptions ADD COLUMN region TEXT NOT NULL DEFAULT 'PL'").run();
}
if (!columns.some((column) => column.name === "timezone")) {
database.prepare("ALTER TABLE push_subscriptions ADD COLUMN timezone TEXT").run();
}
}
function getDatabase() {
if (!globalStore.__wtrPushDatabase) {
const databasePath = getDatabasePath();
ensureDatabaseDirectory(databasePath);
const database = new Database(databasePath);
initializeDatabase(database);
globalStore.__wtrPushDatabase = { database };
}
return globalStore.__wtrPushDatabase.database;
}
function parseSubscription(row: PushSubscriptionRow): WarningPushSubscription | null {
try {
const subscription = JSON.parse(row.subscription_json) as WarningPushSubscription["subscription"];
const region: WeatherRegion = row.region === "GLOBAL" || row.province === "global" ? "GLOBAL" : "PL";
return {
endpoint: row.endpoint,
subscription,
province: row.province === "global" ? null : (row.province as Province),
region,
language: row.language === "en" ? "en" : "pl",
enabled: row.enabled === 1,
morningBriefEnabled: row.morning_brief_enabled === 1,
tomorrowBriefEnabled: row.tomorrow_brief_enabled === 1,
latitude: row.latitude,
longitude: row.longitude,
locationName: row.location_name,
timezone: row.timezone,
countyTeryt: row.county_teryt,
temperatureUnit: isTemperatureUnit(row.temperature_unit) ? row.temperature_unit : DEFAULT_TEMPERATURE_UNIT,
windSpeedUnit: isWindSpeedUnit(row.wind_speed_unit) ? row.wind_speed_unit : DEFAULT_WIND_SPEED_UNIT,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
} catch {
return null;
}
}
export function upsertPushSubscription(subscription: WarningPushSubscription) {
getDatabase()
.prepare(
`
INSERT INTO push_subscriptions (
endpoint,
subscription_json,
province,
region,
language,
enabled,
morning_brief_enabled,
tomorrow_brief_enabled,
latitude,
longitude,
location_name,
timezone,
county_teryt,
temperature_unit,
wind_speed_unit,
created_at,
updated_at
) VALUES (
@endpoint,
@subscriptionJson,
@province,
@region,
@language,
@enabled,
@morningBriefEnabled,
@tomorrowBriefEnabled,
@latitude,
@longitude,
@locationName,
@timezone,
@countyTeryt,
@temperatureUnit,
@windSpeedUnit,
@createdAt,
@updatedAt
)
ON CONFLICT(endpoint) DO UPDATE SET
subscription_json = excluded.subscription_json,
province = excluded.province,
region = excluded.region,
language = excluded.language,
enabled = excluded.enabled,
morning_brief_enabled = excluded.morning_brief_enabled,
tomorrow_brief_enabled = excluded.tomorrow_brief_enabled,
latitude = excluded.latitude,
longitude = excluded.longitude,
location_name = excluded.location_name,
timezone = excluded.timezone,
county_teryt = excluded.county_teryt,
temperature_unit = excluded.temperature_unit,
wind_speed_unit = excluded.wind_speed_unit,
updated_at = excluded.updated_at
`,
)
.run({
endpoint: subscription.endpoint,
subscriptionJson: JSON.stringify(subscription.subscription),
province: subscription.province ?? "global",
region: subscription.region,
language: subscription.language,
enabled: subscription.enabled ? 1 : 0,
morningBriefEnabled: subscription.morningBriefEnabled ? 1 : 0,
tomorrowBriefEnabled: subscription.tomorrowBriefEnabled ? 1 : 0,
latitude: subscription.latitude,
longitude: subscription.longitude,
locationName: subscription.locationName,
timezone: subscription.timezone,
countyTeryt: subscription.countyTeryt,
temperatureUnit: subscription.temperatureUnit,
windSpeedUnit: subscription.windSpeedUnit,
createdAt: subscription.createdAt,
updatedAt: subscription.updatedAt,
});
}
export function removePushSubscription(endpoint: string) {
getDatabase().prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
}
export function getPushSubscriptions() {
return getDatabase()
.prepare("SELECT * FROM push_subscriptions ORDER BY created_at ASC")
.all()
.map((row) => parseSubscription(row as PushSubscriptionRow))
.filter((subscription): subscription is WarningPushSubscription => subscription !== null);
}
export function getPushSubscription(endpoint: string) {
const row = getDatabase().prepare("SELECT * FROM push_subscriptions WHERE endpoint = ?").get(endpoint);
return row ? parseSubscription(row as PushSubscriptionRow) : null;
}
export function hasSentWarning(endpoint: string, warningId: string) {
const row = getDatabase()
.prepare("SELECT 1 FROM sent_warnings WHERE endpoint = ? AND warning_id = ?")
.get(endpoint, warningId);
return Boolean(row);
}
export function markWarningSent(endpoint: string, warningId: string) {
getDatabase()
.prepare("INSERT OR IGNORE INTO sent_warnings (endpoint, warning_id, sent_at) VALUES (?, ?, ?)")
.run(endpoint, warningId, new Date().toISOString());
}
export function pruneSentWarnings(activeWarningIds: Set<string>) {
const database = getDatabase();
if (!activeWarningIds.size) {
database.prepare("DELETE FROM sent_warnings").run();
return;
}
database.transaction((warningIds: string[]) => {
const placeholders = warningIds.map(() => "?").join(", ");
database.prepare(`DELETE FROM sent_warnings WHERE warning_id NOT IN (${placeholders})`).run(...warningIds);
})(Array.from(activeWarningIds));
}
export function hasSentMorningBrief(endpoint: string, dateKey: string) {
const row = getDatabase()
.prepare("SELECT 1 FROM sent_morning_briefs WHERE endpoint = ? AND date_key = ?")
.get(endpoint, dateKey);
return Boolean(row);
}
export function markMorningBriefSent(endpoint: string, dateKey: string) {
getDatabase()
.prepare("INSERT OR IGNORE INTO sent_morning_briefs (endpoint, date_key, sent_at) VALUES (?, ?, ?)")
.run(endpoint, dateKey, new Date().toISOString());
}
export function hasSentTomorrowBrief(endpoint: string, dateKey: string) {
const row = getDatabase()
.prepare("SELECT 1 FROM sent_tomorrow_briefs WHERE endpoint = ? AND date_key = ?")
.get(endpoint, dateKey);
return Boolean(row);
}
export function markTomorrowBriefSent(endpoint: string, dateKey: string) {
getDatabase()
.prepare("INSERT OR IGNORE INTO sent_tomorrow_briefs (endpoint, date_key, sent_at) VALUES (?, ?, ?)")
.run(endpoint, dateKey, new Date().toISOString());
}