348 lines
12 KiB
TypeScript
348 lines
12 KiB
TypeScript
import Database from "better-sqlite3";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import {
|
|
DEFAULT_DISTANCE_UNIT,
|
|
DEFAULT_PRECIPITATION_UNIT,
|
|
DEFAULT_PRESSURE_UNIT,
|
|
DEFAULT_TEMPERATURE_UNIT,
|
|
DEFAULT_WIND_SPEED_UNIT,
|
|
isDistanceUnit,
|
|
isPrecipitationUnit,
|
|
isPressureUnit,
|
|
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;
|
|
precipitation_unit: string | null;
|
|
pressure_unit: string | null;
|
|
distance_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}',
|
|
precipitation_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRECIPITATION_UNIT}',
|
|
pressure_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRESSURE_UNIT}',
|
|
distance_unit TEXT NOT NULL DEFAULT '${DEFAULT_DISTANCE_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();
|
|
}
|
|
if (!columns.some((column) => column.name === "precipitation_unit")) {
|
|
database
|
|
.prepare(
|
|
`ALTER TABLE push_subscriptions ADD COLUMN precipitation_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRECIPITATION_UNIT}'`,
|
|
)
|
|
.run();
|
|
}
|
|
if (!columns.some((column) => column.name === "pressure_unit")) {
|
|
database
|
|
.prepare(
|
|
`ALTER TABLE push_subscriptions ADD COLUMN pressure_unit TEXT NOT NULL DEFAULT '${DEFAULT_PRESSURE_UNIT}'`,
|
|
)
|
|
.run();
|
|
}
|
|
if (!columns.some((column) => column.name === "distance_unit")) {
|
|
database
|
|
.prepare(
|
|
`ALTER TABLE push_subscriptions ADD COLUMN distance_unit TEXT NOT NULL DEFAULT '${DEFAULT_DISTANCE_UNIT}'`,
|
|
)
|
|
.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,
|
|
precipitationUnit: isPrecipitationUnit(row.precipitation_unit)
|
|
? row.precipitation_unit
|
|
: DEFAULT_PRECIPITATION_UNIT,
|
|
pressureUnit: isPressureUnit(row.pressure_unit) ? row.pressure_unit : DEFAULT_PRESSURE_UNIT,
|
|
distanceUnit: isDistanceUnit(row.distance_unit) ? row.distance_unit : DEFAULT_DISTANCE_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,
|
|
precipitation_unit,
|
|
pressure_unit,
|
|
distance_unit,
|
|
created_at,
|
|
updated_at
|
|
) VALUES (
|
|
@endpoint,
|
|
@subscriptionJson,
|
|
@province,
|
|
@region,
|
|
@language,
|
|
@enabled,
|
|
@morningBriefEnabled,
|
|
@tomorrowBriefEnabled,
|
|
@latitude,
|
|
@longitude,
|
|
@locationName,
|
|
@timezone,
|
|
@countyTeryt,
|
|
@temperatureUnit,
|
|
@windSpeedUnit,
|
|
@precipitationUnit,
|
|
@pressureUnit,
|
|
@distanceUnit,
|
|
@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,
|
|
precipitation_unit = excluded.precipitation_unit,
|
|
pressure_unit = excluded.pressure_unit,
|
|
distance_unit = excluded.distance_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,
|
|
precipitationUnit: subscription.precipitationUnit,
|
|
pressureUnit: subscription.pressureUnit,
|
|
distanceUnit: subscription.distanceUnit,
|
|
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());
|
|
}
|