/** * DailyNotificationDatabase.swift * * iOS SQLite database management for daily notifications * * @author Matthew Raymer * @version 1.0.0 */ import Foundation import SQLite3 /** * SQLite database manager for daily notifications on iOS * * This class manages the SQLite database with the three-table schema: * - notif_contents: keep history, fast newest-first reads * - notif_deliveries: track many deliveries per slot/time * - notif_config: generic configuration KV */ class DailyNotificationDatabase { // MARK: - Constants private static let TAG = "DailyNotificationDatabase" // Table names static let TABLE_NOTIF_CONTENTS = "notif_contents" static let TABLE_NOTIF_DELIVERIES = "notif_deliveries" static let TABLE_NOTIF_CONFIG = "notif_config" // Column names static let COL_CONTENTS_ID = "id" static let COL_CONTENTS_SLOT_ID = "slot_id" static let COL_CONTENTS_PAYLOAD_JSON = "payload_json" static let COL_CONTENTS_FETCHED_AT = "fetched_at" static let COL_CONTENTS_ETAG = "etag" static let COL_DELIVERIES_ID = "id" static let COL_DELIVERIES_SLOT_ID = "slot_id" static let COL_DELIVERIES_FIRE_AT = "fire_at" static let COL_DELIVERIES_DELIVERED_AT = "delivered_at" static let COL_DELIVERIES_STATUS = "status" static let COL_DELIVERIES_ERROR_CODE = "error_code" static let COL_DELIVERIES_ERROR_MESSAGE = "error_message" static let COL_CONFIG_K = "k" static let COL_CONFIG_V = "v" // Status values static let STATUS_SCHEDULED = "scheduled" static let STATUS_SHOWN = "shown" static let STATUS_ERROR = "error" static let STATUS_CANCELED = "canceled" // MARK: - Properties private var db: OpaquePointer? private let path: String // MARK: - Initialization /** * Initialize database with path * * @param path Database file path */ init(path: String) { self.path = path openDatabase() } /** * Open database connection */ private func openDatabase() { if sqlite3_open(path, &db) == SQLITE_OK { print("\(Self.TAG): Database opened successfully at \(path)") createTables() configureDatabase() } else { print("\(Self.TAG): Error opening database: \(String(cString: sqlite3_errmsg(db)))") } } /** * Create database tables */ private func createTables() { // Create notif_contents table let createContentsTable = """ CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_CONTENTS)( \(Self.COL_CONTENTS_ID) INTEGER PRIMARY KEY AUTOINCREMENT, \(Self.COL_CONTENTS_SLOT_ID) TEXT NOT NULL, \(Self.COL_CONTENTS_PAYLOAD_JSON) TEXT NOT NULL, \(Self.COL_CONTENTS_FETCHED_AT) INTEGER NOT NULL, \(Self.COL_CONTENTS_ETAG) TEXT, UNIQUE(\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_FETCHED_AT)) ); """ // Create notif_deliveries table let createDeliveriesTable = """ CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_DELIVERIES)( \(Self.COL_DELIVERIES_ID) INTEGER PRIMARY KEY AUTOINCREMENT, \(Self.COL_DELIVERIES_SLOT_ID) TEXT NOT NULL, \(Self.COL_DELIVERIES_FIRE_AT) INTEGER NOT NULL, \(Self.COL_DELIVERIES_DELIVERED_AT) INTEGER, \(Self.COL_DELIVERIES_STATUS) TEXT NOT NULL DEFAULT '\(Self.STATUS_SCHEDULED)', \(Self.COL_DELIVERIES_ERROR_CODE) TEXT, \(Self.COL_DELIVERIES_ERROR_MESSAGE) TEXT ); """ // Create notif_config table let createConfigTable = """ CREATE TABLE IF NOT EXISTS \(Self.TABLE_NOTIF_CONFIG)( \(Self.COL_CONFIG_K) TEXT PRIMARY KEY, \(Self.COL_CONFIG_V) TEXT NOT NULL ); """ // Create indexes let createContentsIndex = """ CREATE INDEX IF NOT EXISTS notif_idx_contents_slot_time ON \(Self.TABLE_NOTIF_CONTENTS)(\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_FETCHED_AT) DESC); """ // Execute table creation executeSQL(createContentsTable) executeSQL(createDeliveriesTable) executeSQL(createConfigTable) executeSQL(createContentsIndex) print("\(Self.TAG): Database tables created successfully") } /** * Configure database settings */ private func configureDatabase() { // Enable WAL mode executeSQL("PRAGMA journal_mode=WAL") // Set synchronous mode executeSQL("PRAGMA synchronous=NORMAL") // Set busy timeout executeSQL("PRAGMA busy_timeout=5000") // Enable foreign keys executeSQL("PRAGMA foreign_keys=ON") // Set user version executeSQL("PRAGMA user_version=1") print("\(Self.TAG): Database configured successfully") } /** * Execute SQL statement * * @param sql SQL statement to execute */ func executeSQL(_ sql: String) { var statement: OpaquePointer? if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { if sqlite3_step(statement) == SQLITE_DONE { print("\(Self.TAG): SQL executed successfully: \(sql)") } else { print("\(Self.TAG): SQL execution failed: \(String(cString: sqlite3_errmsg(db)))") } } else { print("\(Self.TAG): SQL preparation failed: \(String(cString: sqlite3_errmsg(db)))") } sqlite3_finalize(statement) } /** * Query SQL and return integer result * * @param sql SQL query statement * @return Integer result or nil if query fails */ func queryInt(_ sql: String) -> Int? { var statement: OpaquePointer? var result: Int? = nil if sqlite3_prepare_v2(db, sql, -1, &statement, nil) == SQLITE_OK { if sqlite3_step(statement) == SQLITE_ROW { result = Int(sqlite3_column_int(statement, 0)) } } else { print("\(Self.TAG): Query preparation failed: \(String(cString: sqlite3_errmsg(db)))") } sqlite3_finalize(statement) return result } // MARK: - Public Methods /** * Close database connection */ func close() { if sqlite3_close(db) == SQLITE_OK { print("\(Self.TAG): Database closed successfully") } else { print("\(Self.TAG): Error closing database: \(String(cString: sqlite3_errmsg(db)))") } } /** * Get database path * * @return Database file path */ func getPath() -> String { return path } /** * Check if database is open * * @return true if database is open */ func isOpen() -> Bool { return db != nil } /** * Save notification content to database * * @param content Notification content to save */ func saveNotificationContent(_ content: NotificationContent) { do { guard db != nil else { print("\(Self.TAG): DB not open; cannot saveNotificationContent for \(content.id)") return } let encoder = JSONEncoder() let data = try encoder.encode(content) guard let json = String(data: data, encoding: .utf8) else { print("\(Self.TAG): Failed to encode NotificationContent to UTF-8 JSON for \(content.id)") return } let sql = """ INSERT OR REPLACE INTO \(Self.TABLE_NOTIF_CONTENTS) (\(Self.COL_CONTENTS_SLOT_ID), \(Self.COL_CONTENTS_PAYLOAD_JSON), \(Self.COL_CONTENTS_FETCHED_AT), \(Self.COL_CONTENTS_ETAG)) VALUES (?, ?, ?, ?); """ var stmt: OpaquePointer? if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK { print("\(Self.TAG): saveNotificationContent prepare failed: \(String(cString: sqlite3_errmsg(db)))") sqlite3_finalize(stmt) return } sqlite3_bind_text(stmt, 1, (content.id as NSString).utf8String, -1, nil) sqlite3_bind_text(stmt, 2, (json as NSString).utf8String, -1, nil) sqlite3_bind_int64(stmt, 3, sqlite3_int64(content.fetchedAt)) if let etag = content.etag { sqlite3_bind_text(stmt, 4, (etag as NSString).utf8String, -1, nil) } else { sqlite3_bind_null(stmt, 4) } if sqlite3_step(stmt) != SQLITE_DONE { print("\(Self.TAG): saveNotificationContent step failed: \(String(cString: sqlite3_errmsg(db)))") } else { print("\(Self.TAG): Saved notification content: slot=\(content.id) fetched_at=\(content.fetchedAt)") } sqlite3_finalize(stmt) } catch { print("\(Self.TAG): saveNotificationContent error for \(content.id): \(error)") } } /** * Delete notification content from database * * @param id Notification ID */ func deleteNotificationContent(id: String) { do { guard db != nil else { print("\(Self.TAG): DB not open; cannot deleteNotificationContent for \(id)") return } let sql = """ DELETE FROM \(Self.TABLE_NOTIF_CONTENTS) WHERE \(Self.COL_CONTENTS_SLOT_ID) = ?; """ var stmt: OpaquePointer? if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK { print("\(Self.TAG): deleteNotificationContent prepare failed: \(String(cString: sqlite3_errmsg(db)))") sqlite3_finalize(stmt) return } sqlite3_bind_text(stmt, 1, (id as NSString).utf8String, -1, nil) if sqlite3_step(stmt) != SQLITE_DONE { print("\(Self.TAG): deleteNotificationContent step failed: \(String(cString: sqlite3_errmsg(db)))") } else { print("\(Self.TAG): Deleted notification content rows for slot=\(id)") } sqlite3_finalize(stmt) } catch { print("\(Self.TAG): deleteNotificationContent error for \(id): \(error)") } } /** * Clear all notifications from database */ func clearAllNotifications() { do { guard db != nil else { print("\(Self.TAG): DB not open; cannot clearAllNotifications") return } executeSQL("DELETE FROM \(Self.TABLE_NOTIF_CONTENTS);") executeSQL("DELETE FROM \(Self.TABLE_NOTIF_DELIVERIES);") print("\(Self.TAG): Cleared all notifications (contents + deliveries)") } catch { print("\(Self.TAG): clearAllNotifications error: \(error)") } } }