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) { 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()); }