diff --git a/package-lock.json b/package-lock.json index 5b06ebe..26886f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "inversify-binding-decorators": "^4.0.0", "inversify-express-utils": "^6.5.0", "jsonwebtoken": "^9.0.2", + "kysely": "^0.28.11", "moment-timezone": "^0.5.46", "mysql2": "^3.14.3", "node-geocoder": "^4.4.1", @@ -13914,6 +13915,15 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "license": "MIT" }, + "node_modules/kysely": { + "version": "0.28.11", + "resolved": "https://registry.npmjs.org/kysely/-/kysely-0.28.11.tgz", + "integrity": "sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", diff --git a/package.json b/package.json index 73c9875..0b97554 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,20 @@ "populateDemo:messaging": "tsx tools/initdb.ts --module=messaging --demo-only", "populateDemo:doing": "tsx tools/initdb.ts --module=doing --demo-only", "reset-db": "tsx tools/initdb.ts --reset", + "migrate": "tsx tools/migrate.ts", + "migrate:up": "tsx tools/migrate.ts --action=up", + "migrate:down": "tsx tools/migrate.ts --action=down", + "migrate:status": "tsx tools/migrate.ts --action=status", + "migrate:create": "tsx tools/migrate-create.ts", + "migrate:baseline": "tsx tools/migrate-baseline.ts", + "migrate:membership": "tsx tools/migrate.ts --module=membership", + "migrate:attendance": "tsx tools/migrate.ts --module=attendance", + "migrate:content": "tsx tools/migrate.ts --module=content", + "migrate:giving": "tsx tools/migrate.ts --module=giving", + "migrate:messaging": "tsx tools/migrate.ts --module=messaging", + "migrate:doing": "tsx tools/migrate.ts --module=doing", + "seed": "tsx tools/seed.ts", + "seed:reset": "tsx tools/seed.ts --reset", "dev": "tsx watch src/index.ts", "clean": "rimraf dist", "lint": "prettier --write src/**/*.ts && eslint . --fix", @@ -77,6 +91,7 @@ "inversify-binding-decorators": "^4.0.0", "inversify-express-utils": "^6.5.0", "jsonwebtoken": "^9.0.2", + "kysely": "^0.28.11", "moment-timezone": "^0.5.46", "mysql2": "^3.14.3", "node-geocoder": "^4.4.1", diff --git a/tools/dbScripts/attendance/01_tables.sql b/tools/dbScripts/attendance/01_tables.sql deleted file mode 100644 index c400397..0000000 --- a/tools/dbScripts/attendance/01_tables.sql +++ /dev/null @@ -1,126 +0,0 @@ --- Attendance module database schema --- Attendance tracking and reporting tables - --- Campuses table - physical locations for services -CREATE TABLE IF NOT EXISTS campuses ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - name varchar(50) NOT NULL, - address1 varchar(100), - address2 varchar(100), - city varchar(50), - state varchar(50), - zip varchar(20), - country varchar(50) DEFAULT 'US', - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_campuses_church (churchId) -); - --- Services table - different service types (Sunday morning, evening, etc.) -CREATE TABLE IF NOT EXISTS services ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - campusId varchar(36) NOT NULL, - name varchar(50) NOT NULL, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_services_church (churchId), - INDEX idx_services_campus (campusId) -); - --- Service times table - scheduled meeting times -CREATE TABLE IF NOT EXISTS serviceTimes ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - serviceId varchar(36) NOT NULL, - name varchar(50) NOT NULL, - longName varchar(100), - description TEXT, - duration int DEFAULT 60, - earlyMinutes int DEFAULT 15, - lateMinutes int DEFAULT 15, - chatBefore boolean DEFAULT true, - chatAfter boolean DEFAULT true, - callToAction varchar(255), - videoUrl varchar(255), - timezoneId varchar(50), - recurringPattern varchar(50), - startTime time, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_serviceTimes_church (churchId), - INDEX idx_serviceTimes_service (serviceId) -); - --- Sessions table - specific instances of service times -CREATE TABLE IF NOT EXISTS sessions ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - serviceTimeId varchar(36) NOT NULL, - groupId varchar(36), - sessionDate date NOT NULL, - displayName varchar(100), - serviceDate datetime, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_sessions_church (churchId), - INDEX idx_sessions_serviceTime (serviceTimeId), - INDEX idx_sessions_group (groupId), - INDEX idx_sessions_date (sessionDate) -); - --- Visits table - individual person attendance records -CREATE TABLE IF NOT EXISTS visits ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - personId varchar(36) NOT NULL, - serviceId varchar(36), - groupId varchar(36), - visitDate date NOT NULL, - visitSessions int DEFAULT 1, - checkinTime datetime, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_visits_church (churchId), - INDEX idx_visits_person (personId), - INDEX idx_visits_service (serviceId), - INDEX idx_visits_group (groupId), - INDEX idx_visits_date (visitDate) -); - --- Visit sessions table - detailed session attendance -CREATE TABLE IF NOT EXISTS visitSessions ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - visitId varchar(36) NOT NULL, - sessionId varchar(36) NOT NULL, - checkinTime datetime, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_visitSessions_church (churchId), - INDEX idx_visitSessions_visit (visitId), - INDEX idx_visitSessions_session (sessionId) -); - --- Attendance settings table - configuration per church -CREATE TABLE IF NOT EXISTS attendanceSettings ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - allowSelfCheckin boolean DEFAULT false, - requireGroupSelection boolean DEFAULT false, - showGroupSelection boolean DEFAULT true, - defaultServiceId varchar(36), - checkinMinutes int DEFAULT 15, - checkoutMinutes int DEFAULT 15, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_attendanceSettings_church (churchId) -); \ No newline at end of file diff --git a/tools/dbScripts/content/01_tables.sql b/tools/dbScripts/content/01_tables.sql deleted file mode 100644 index e6a7cf3..0000000 --- a/tools/dbScripts/content/01_tables.sql +++ /dev/null @@ -1,116 +0,0 @@ --- Content module database schema --- Content management and media tables - --- Pages table - website/app pages -CREATE TABLE IF NOT EXISTS pages ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - name varchar(50) NOT NULL, - path varchar(100), - title varchar(100), - description TEXT, - keywords varchar(255), - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_pages_church (churchId), - INDEX idx_pages_path (path) -); - --- Sections table - page sections/components -CREATE TABLE IF NOT EXISTS sections ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - pageId varchar(36) NOT NULL, - zone varchar(20), - sort int DEFAULT 0, - background varchar(50), - textColor varchar(20), - targetAudience varchar(50), - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_sections_church (churchId), - INDEX idx_sections_page (pageId) -); - --- Elements table - content elements within sections -CREATE TABLE IF NOT EXISTS elements ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - sectionId varchar(36) NOT NULL, - elementType varchar(50) NOT NULL, - sort int DEFAULT 0, - answers TEXT, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_elements_church (churchId), - INDEX idx_elements_section (sectionId) -); - --- Media files table -CREATE TABLE IF NOT EXISTS media ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - fileName varchar(255) NOT NULL, - contentType varchar(100), - contentPath varchar(255), - thumbPath varchar(255), - fileSize bigint, - seconds int, - dateCreated datetime DEFAULT CURRENT_TIMESTAMP, - removed boolean DEFAULT false, - INDEX idx_media_church (churchId) -); - --- Sermons table - sermon content and metadata -CREATE TABLE IF NOT EXISTS sermons ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - title varchar(100) NOT NULL, - description TEXT, - speaker varchar(100), - sermonDate date, - series varchar(100), - videoUrl varchar(255), - audioUrl varchar(255), - notes TEXT, - thumbnail varchar(255), - duration int, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_sermons_church (churchId), - INDEX idx_sermons_date (sermonDate), - INDEX idx_sermons_series (series) -); - --- Playlists table - media playlists -CREATE TABLE IF NOT EXISTS playlists ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - name varchar(100) NOT NULL, - description TEXT, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_playlists_church (churchId) -); - --- Playlist items table -CREATE TABLE IF NOT EXISTS playlistItems ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - playlistId varchar(36) NOT NULL, - mediaId varchar(36), - sermonId varchar(36), - sort int DEFAULT 0, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_playlistItems_church (churchId), - INDEX idx_playlistItems_playlist (playlistId), - INDEX idx_playlistItems_media (mediaId), - INDEX idx_playlistItems_sermon (sermonId) -); \ No newline at end of file diff --git a/tools/dbScripts/content/links_visibility_migration.sql b/tools/dbScripts/content/links_visibility_migration.sql deleted file mode 100644 index 5cd46bf..0000000 --- a/tools/dbScripts/content/links_visibility_migration.sql +++ /dev/null @@ -1,107 +0,0 @@ --- Migration: Add visibility and groupIds columns to links table --- Run this manually before deploying the new code - --- Add new columns -ALTER TABLE links ADD COLUMN visibility VARCHAR(45) DEFAULT 'everyone'; -ALTER TABLE links ADD COLUMN groupIds TEXT DEFAULT NULL; - --- Migrate existing b1Tab links to appropriate visibility based on linkType -UPDATE links SET visibility = 'everyone' - WHERE category = 'b1Tab' AND visibility IS NULL - AND linkType IN ('bible', 'votd', 'sermons', 'stream', 'donation', 'url', 'page', 'website', 'donationLanding'); - -UPDATE links SET visibility = 'visitors' - WHERE category = 'b1Tab' AND visibility IS NULL - AND linkType IN ('groups', 'lessons', 'checkin'); - -UPDATE links SET visibility = 'members' - WHERE category = 'b1Tab' AND visibility IS NULL - AND linkType = 'directory'; - -UPDATE links SET visibility = 'team' - WHERE category = 'b1Tab' AND visibility IS NULL - AND linkType = 'plans'; - --- Set remaining null visibility to 'everyone' as default -UPDATE links SET visibility = 'everyone' - WHERE visibility IS NULL; - --- ===================================================== --- INSERT links for churches that met old hardcoded rules --- (Only insert if church doesn't already have this tab) --- ===================================================== - --- Sermons tab: Churches with at least 1 sermon -INSERT INTO links (id, churchId, category, linkType, linkData, icon, text, sort, visibility) -SELECT UUID(), s.churchId, 'b1Tab', 'sermons', '', 'video_library', 'Sermons', 1, 'everyone' -FROM (SELECT DISTINCT churchId FROM sermons) s -WHERE NOT EXISTS ( - SELECT 1 FROM links l - WHERE l.churchId = s.churchId COLLATE utf8mb4_unicode_520_ci AND l.category = 'b1Tab' AND l.linkType = 'sermons' -); - --- Stream tab: Churches with at least 1 streaming service -INSERT INTO links (id, churchId, category, linkType, linkData, icon, text, sort, visibility) -SELECT UUID(), ss.churchId, 'b1Tab', 'stream', '', 'live_tv', 'Live Stream', 2, 'everyone' -FROM (SELECT DISTINCT churchId FROM streamingServices) ss -WHERE NOT EXISTS ( - SELECT 1 FROM links l - WHERE l.churchId = ss.churchId COLLATE utf8mb4_unicode_520_ci AND l.category = 'b1Tab' AND l.linkType = 'stream' -); - --- Donation tab: Churches with a gateway configured (cross-database query - run on giving DB) --- NOTE: This requires access to the giving database. Run this separately: --- INSERT INTO content.links (id, churchId, category, linkType, linkData, icon, text, sort, visibility) --- SELECT UUID(), g.churchId, 'b1Tab', 'donation', '', 'volunteer_activism', 'Give', 3, 'everyone' --- FROM (SELECT DISTINCT churchId FROM giving.gateways WHERE privateKey IS NOT NULL AND privateKey != '') g --- WHERE NOT EXISTS ( --- SELECT 1 FROM content.links l --- WHERE l.churchId = g.churchId COLLATE utf8mb4_unicode_520_ci AND l.category = 'b1Tab' AND l.linkType = 'donation' --- ); - --- Groups tab: All churches (this was available to all visitors with an account) --- Only add for churches that have at least one group -INSERT INTO links (id, churchId, category, linkType, linkData, icon, text, sort, visibility) -SELECT UUID(), c.id COLLATE utf8mb4_unicode_520_ci, 'b1Tab', 'groups', '', 'groups', 'Groups', 4, 'visitors' -FROM (SELECT DISTINCT id FROM membership.churches) c -WHERE NOT EXISTS ( - SELECT 1 FROM links l - WHERE l.churchId = c.id COLLATE utf8mb4_unicode_520_ci AND l.category = 'b1Tab' AND l.linkType = 'groups' -); - --- Directory tab: All churches (available to members) -INSERT INTO links (id, churchId, category, linkType, linkData, icon, text, sort, visibility) -SELECT UUID(), c.id COLLATE utf8mb4_unicode_520_ci, 'b1Tab', 'directory', '', 'contacts', 'Directory', 5, 'members' -FROM (SELECT DISTINCT id FROM membership.churches) c -WHERE NOT EXISTS ( - SELECT 1 FROM links l - WHERE l.churchId = c.id COLLATE utf8mb4_unicode_520_ci AND l.category = 'b1Tab' AND l.linkType = 'directory' -); - --- Plans tab: All churches (available to team members) -INSERT INTO links (id, churchId, category, linkType, linkData, icon, text, sort, visibility) -SELECT UUID(), c.id COLLATE utf8mb4_unicode_520_ci, 'b1Tab', 'plans', '', 'assignment', 'Plans', 6, 'team' -FROM (SELECT DISTINCT id FROM membership.churches) c -WHERE NOT EXISTS ( - SELECT 1 FROM links l - WHERE l.churchId = c.id COLLATE utf8mb4_unicode_520_ci AND l.category = 'b1Tab' AND l.linkType = 'plans' -); - --- Check-in tab: Churches with at least 1 campus --- NOTE: This requires access to the attendance database. Run this separately: --- INSERT INTO content.links (id, churchId, category, linkType, linkData, icon, text, sort, visibility) --- SELECT UUID(), ca.churchId, 'b1Tab', 'checkin', '', 'check_circle', 'Check-in', 7, 'visitors' --- FROM (SELECT DISTINCT churchId FROM attendance.campuses) ca --- WHERE NOT EXISTS ( --- SELECT 1 FROM content.links l --- WHERE l.churchId = ca.churchId COLLATE utf8mb4_unicode_520_ci AND l.category = 'b1Tab' AND l.linkType = 'checkin' --- ); - --- Lessons tab: All churches (available to visitors enrolled in classrooms) -INSERT INTO links (id, churchId, category, linkType, linkData, icon, text, sort, visibility) -SELECT UUID(), c.id COLLATE utf8mb4_unicode_520_ci, 'b1Tab', 'lessons', '', 'school', 'Lessons', 8, 'visitors' -FROM (SELECT DISTINCT id FROM membership.churches) c -WHERE NOT EXISTS ( - SELECT 1 FROM links l - WHERE l.churchId = c.id COLLATE utf8mb4_unicode_520_ci AND l.category = 'b1Tab' AND l.linkType = 'lessons' -); diff --git a/tools/dbScripts/doing/01_tables.sql b/tools/dbScripts/doing/01_tables.sql deleted file mode 100644 index a736f76..0000000 --- a/tools/dbScripts/doing/01_tables.sql +++ /dev/null @@ -1,106 +0,0 @@ --- Doing module database schema --- Task automation and workflow management tables - --- Tasks table - individual task items -CREATE TABLE IF NOT EXISTS tasks ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - assignedToId varchar(36), - title varchar(100) NOT NULL, - description TEXT, - dueDate date, - priority varchar(20) DEFAULT 'medium', - status varchar(20) DEFAULT 'pending', - category varchar(50), - estimatedHours decimal(4,2), - actualHours decimal(4,2), - completedDate datetime, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_tasks_church (churchId), - INDEX idx_tasks_assignedTo (assignedToId), - INDEX idx_tasks_status (status), - INDEX idx_tasks_dueDate (dueDate) -); - --- Workflows table - automation workflow definitions -CREATE TABLE IF NOT EXISTS workflows ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - name varchar(100) NOT NULL, - description TEXT, - triggerType varchar(50) NOT NULL, - triggerData TEXT, - isActive boolean DEFAULT true, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_workflows_church (churchId), - INDEX idx_workflows_triggerType (triggerType), - INDEX idx_workflows_active (isActive) -); - --- Workflow conditions table - conditional logic for workflows -CREATE TABLE IF NOT EXISTS workflowConditions ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - workflowId varchar(36) NOT NULL, - conditionType varchar(50) NOT NULL, - field varchar(100), - operator varchar(20), - value varchar(255), - sort int DEFAULT 0, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_workflowConditions_church (churchId), - INDEX idx_workflowConditions_workflow (workflowId) -); - --- Workflow actions table - actions to execute when conditions are met -CREATE TABLE IF NOT EXISTS workflowActions ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - workflowId varchar(36) NOT NULL, - actionType varchar(50) NOT NULL, - actionData TEXT, - sort int DEFAULT 0, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_workflowActions_church (churchId), - INDEX idx_workflowActions_workflow (workflowId) -); - --- Workflow executions table - log of workflow runs -CREATE TABLE IF NOT EXISTS workflowExecutions ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - workflowId varchar(36) NOT NULL, - triggeredBy varchar(100), - triggerData TEXT, - status varchar(20) DEFAULT 'running', - executionTime datetime DEFAULT CURRENT_TIMESTAMP, - completedTime datetime, - errorMessage TEXT, - removed boolean DEFAULT false, - INDEX idx_workflowExecutions_church (churchId), - INDEX idx_workflowExecutions_workflow (workflowId), - INDEX idx_workflowExecutions_status (status), - INDEX idx_workflowExecutions_time (executionTime) -); - --- Task assignments table - linking tasks to people/groups -CREATE TABLE IF NOT EXISTS taskAssignments ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - taskId varchar(36) NOT NULL, - assigneeType varchar(20) NOT NULL, - assigneeId varchar(36) NOT NULL, - assignedDate datetime DEFAULT CURRENT_TIMESTAMP, - removed boolean DEFAULT false, - INDEX idx_taskAssignments_church (churchId), - INDEX idx_taskAssignments_task (taskId), - INDEX idx_taskAssignments_assignee (assigneeType, assigneeId) -); \ No newline at end of file diff --git a/tools/dbScripts/giving/01_tables.sql b/tools/dbScripts/giving/01_tables.sql deleted file mode 100644 index eab5c68..0000000 --- a/tools/dbScripts/giving/01_tables.sql +++ /dev/null @@ -1,124 +0,0 @@ --- Giving module database schema --- Donation processing and financial management tables - --- Funds table - donation categories -CREATE TABLE IF NOT EXISTS funds ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - name varchar(50) NOT NULL, - description TEXT, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_funds_church (churchId) -); - --- Donations table - individual donation records -CREATE TABLE IF NOT EXISTS donations ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - batchId varchar(36), - personId varchar(36), - paymentMethodId varchar(36), - amount decimal(10,2) NOT NULL, - fee decimal(10,2) DEFAULT 0, - donationDate date NOT NULL, - method varchar(20), - methodDetails TEXT, - notes TEXT, - anonymous boolean DEFAULT false, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_donations_church (churchId), - INDEX idx_donations_person (personId), - INDEX idx_donations_batch (batchId), - INDEX idx_donations_date (donationDate) -); - --- Donation funds table - allocation of donations to funds -CREATE TABLE IF NOT EXISTS donationFunds ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - donationId varchar(36) NOT NULL, - fundId varchar(36) NOT NULL, - amount decimal(10,2) NOT NULL, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_donationFunds_church (churchId), - INDEX idx_donationFunds_donation (donationId), - INDEX idx_donationFunds_fund (fundId) -); - --- Payment methods table - stored payment information -CREATE TABLE IF NOT EXISTS paymentMethods ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - personId varchar(36) NOT NULL, - name varchar(50), - type varchar(20), - last4 varchar(4), - expirationMonth int, - expirationYear int, - stripeCustomerId varchar(255), - stripePaymentMethodId varchar(255), - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_paymentMethods_church (churchId), - INDEX idx_paymentMethods_person (personId) -); - --- Batches table - grouping donations for processing -CREATE TABLE IF NOT EXISTS batches ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - name varchar(100) NOT NULL, - batchDate date NOT NULL, - totalAmount decimal(10,2) DEFAULT 0, - donationCount int DEFAULT 0, - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_batches_church (churchId), - INDEX idx_batches_date (batchDate) -); - --- Subscriptions table - recurring donations -CREATE TABLE IF NOT EXISTS subscriptions ( - id varchar(36) NOT NULL PRIMARY KEY, - churchId varchar(36) NOT NULL, - personId varchar(36) NOT NULL, - paymentMethodId varchar(36) NOT NULL, - amount decimal(10,2) NOT NULL, - frequency varchar(20) NOT NULL, - interval_count int DEFAULT 1, - startDate date NOT NULL, - endDate date, - status varchar(20) DEFAULT 'active', - stripeSubscriptionId varchar(255), - removed boolean DEFAULT false, - createdDate datetime DEFAULT CURRENT_TIMESTAMP, - modifiedDate datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_subscriptions_church (churchId), - INDEX idx_subscriptions_person (personId), - INDEX idx_subscriptions_status (status) -); - --- Gateway payment methods - unified vault storage across providers -CREATE TABLE IF NOT EXISTS gatewayPaymentMethods ( - id char(11) NOT NULL PRIMARY KEY, - churchId char(11) NOT NULL, - gatewayId char(11) NOT NULL, - customerId varchar(255) NOT NULL, - externalId varchar(255) NOT NULL, - methodType varchar(50), - displayName varchar(255), - metadata json, - createdAt datetime DEFAULT CURRENT_TIMESTAMP, - updatedAt datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY ux_gateway_payment_methods_external (gatewayId, externalId), - INDEX idx_gateway_payment_methods_church (churchId), - INDEX idx_gateway_payment_methods_customer (customerId) -); diff --git a/tools/dbScripts/giving/eventLogs_migration.sql b/tools/dbScripts/giving/eventLogs_migration.sql deleted file mode 100644 index 832a373..0000000 --- a/tools/dbScripts/giving/eventLogs_migration.sql +++ /dev/null @@ -1,41 +0,0 @@ --- Migration script to update eventLogs table structure --- This migrates from varchar(255) id with provider IDs to char(11) id with separate providerId field - --- Step 1: Add the new providerId column -ALTER TABLE `eventLogs` -ADD COLUMN `providerId` varchar(255) DEFAULT NULL, -ADD KEY `idx_provider_id` (`providerId`); - --- Step 2: Update existing records to move id values to providerId and generate new char(11) IDs --- We'll use a temporary variable approach to generate sequential IDs -SET @counter = 0; -UPDATE `eventLogs` -SET - `providerId` = `id`, - `id` = CONCAT( - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1), - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1), - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1), - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1), - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1), - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1), - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1), - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1), - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1), - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1), - SUBSTRING('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', FLOOR(1 + (RAND() * 36)), 1) - ) -WHERE `providerId` IS NULL; - --- Step 3: Alter the id column to char(11) --- Note: This will require dropping and recreating the primary key -ALTER TABLE `eventLogs` DROP PRIMARY KEY; -ALTER TABLE `eventLogs` MODIFY COLUMN `id` char(11) NOT NULL; -ALTER TABLE `eventLogs` ADD PRIMARY KEY (`id`); - --- Verify the migration -SELECT 'Migration completed. Verify results:' as Status; -SELECT COUNT(*) as TotalRecords, - COUNT(CASE WHEN LENGTH(id) = 11 THEN 1 END) as ValidIdCount, - COUNT(CASE WHEN providerId IS NOT NULL THEN 1 END) as RecordsWithProviderId -FROM eventLogs; \ No newline at end of file diff --git a/tools/dbScripts/giving/migrations/phase1-multigateway.ts b/tools/dbScripts/giving/migrations/phase1-multigateway.ts deleted file mode 100644 index 3d718b3..0000000 --- a/tools/dbScripts/giving/migrations/phase1-multigateway.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { Environment } from "../../../../src/shared/helpers/Environment"; -import { MultiDatabasePool } from "../../../../src/shared/infrastructure/MultiDatabasePool"; - -interface MigrationOptions { - dryRun: boolean; - environment: string; -} - -async function getDatabaseName(): Promise { - const config = Environment.getDatabaseConfig("giving"); - if (!config || !config.database) { - throw new Error("Giving database configuration is missing. Ensure ENV variables or config files are set."); - } - return config.database; -} - -async function columnExists(database: string, table: string, column: string): Promise { - const sql = - "SELECT COUNT(*) as count FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND COLUMN_NAME=?"; - const rows: any[] = await MultiDatabasePool.query("giving", sql, [database, table, column]); - return rows.length > 0 && rows[0].count > 0; -} - -async function addColumnIfMissing(options: MigrationOptions, table: string, column: string, definition: string) { - const database = await getDatabaseName(); - const exists = await columnExists(database, table, column); - if (exists) { - console.log(`ā„¹ļø ${table}.${column} already exists. Skipping.`); - return; - } - - const sql = `ALTER TABLE ${table} ADD COLUMN ${definition}`; - if (options.dryRun) { - console.log(`šŸ“ [dry-run] Would execute: ${sql}`); - return; - } - console.log(`āš™ļø Executing: ${sql}`); - await MultiDatabasePool.query("giving", sql); -} - -async function tableExists(database: string, table: string): Promise { - const sql = "SELECT COUNT(*) as count FROM information_schema.TABLES WHERE TABLE_SCHEMA=? AND TABLE_NAME=?"; - const rows: any[] = await MultiDatabasePool.query("giving", sql, [database, table]); - return rows.length > 0 && rows[0].count > 0; -} - -async function createGatewayPaymentMethodsTable(options: MigrationOptions) { - const database = await getDatabaseName(); - const exists = await tableExists(database, "gatewayPaymentMethods"); - if (exists) { - console.log("ā„¹ļø gatewayPaymentMethods table already exists. Skipping creation."); - return; - } - - const sql = ` - CREATE TABLE gatewayPaymentMethods ( - id char(11) NOT NULL, - churchId char(11) NOT NULL, - gatewayId char(11) NOT NULL, - customerId varchar(255) NOT NULL, - externalId varchar(255) NOT NULL, - methodType varchar(50) DEFAULT NULL, - displayName varchar(255) DEFAULT NULL, - metadata json DEFAULT NULL, - createdAt datetime DEFAULT CURRENT_TIMESTAMP, - updatedAt datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - UNIQUE KEY ux_gateway_payment_methods_external (gatewayId, externalId), - INDEX idx_gateway_payment_methods_church (churchId), - INDEX idx_gateway_payment_methods_customer (customerId) - ) ENGINE=InnoDB; - `; - - if (options.dryRun) { - console.log("šŸ“ [dry-run] Would create gatewayPaymentMethods table."); - return; - } - console.log("āš™ļø Creating gatewayPaymentMethods table..."); - await MultiDatabasePool.executeDDL("giving", sql); -} - -async function backfillCustomerProviders(options: MigrationOptions) { - const sql = ` - UPDATE customers c - LEFT JOIN gateways g ON g.churchId = c.churchId - SET c.provider = COALESCE(g.provider, 'stripe') - WHERE c.provider IS NULL OR c.provider = '' - `; - - if (options.dryRun) { - console.log("šŸ“ [dry-run] Would backfill customer providers from gateways."); - return; - } - - console.log("āš™ļø Backfilling customer providers using current gateway provider..."); - const result: any = await MultiDatabasePool.query("giving", sql); - console.log(` āœ… Updated rows: ${result.affectedRows ?? 0}`); -} - -async function validateGatewayUniqueness() { - const sql = "SELECT churchId, COUNT(*) as gatewayCount FROM gateways GROUP BY churchId HAVING COUNT(*) > 1"; - const rows: any[] = await MultiDatabasePool.query("giving", sql); - if (rows.length === 0) { - console.log("āœ… Validation passed: all churches have at most one gateway row."); - return; - } - - console.log("āŒ Validation failed: Multiple gateways found for some churches."); - rows.forEach((row) => { - console.log(` - churchId ${row.churchId} has ${row.gatewayCount} gateways`); - }); - throw new Error("Gateway uniqueness validation failed. Resolve duplicates before rerunning migration."); -} - -async function runMigration(options: MigrationOptions) { - await Environment.init(options.environment); - console.log(`šŸš€ Running Multi-Gateway Phase 1 migration (dryRun=${options.dryRun})`); - - await addColumnIfMissing(options, "gateways", "settings", "settings json DEFAULT NULL AFTER payFees"); - await addColumnIfMissing(options, "gateways", "environment", "environment varchar(50) DEFAULT NULL AFTER settings"); - await addColumnIfMissing(options, "gateways", "createdAt", "createdAt datetime DEFAULT CURRENT_TIMESTAMP AFTER environment"); - await addColumnIfMissing( - options, - "gateways", - "updatedAt", - "updatedAt datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER createdAt" - ); - - await addColumnIfMissing(options, "customers", "provider", "provider varchar(50) DEFAULT NULL AFTER personId"); - await addColumnIfMissing(options, "customers", "metadata", "metadata json DEFAULT NULL AFTER provider"); - - await createGatewayPaymentMethodsTable(options); - - await backfillCustomerProviders(options); - await validateGatewayUniqueness(); - - console.log("šŸŽ‰ Phase 1 migration complete!"); -} - -function parseOptions(): MigrationOptions { - const args = process.argv.slice(2); - const dryRun = args.includes("--dry-run"); - const envArg = args.find((arg) => arg.startsWith("--env=")); - const environment = envArg ? envArg.split("=")[1] : process.env.ENVIRONMENT || "dev"; - return { dryRun, environment }; -} - -(async () => { - const options = parseOptions(); - try { - await runMigration(options); - } catch (error) { - console.error("āŒ Migration failed:", error instanceof Error ? error.message : error); - process.exitCode = 1; - } finally { - await MultiDatabasePool.closeAll(); - } -})(); diff --git a/tools/dbScripts/giving/migrations/phase2-ach-payment-intents.ts b/tools/dbScripts/giving/migrations/phase2-ach-payment-intents.ts deleted file mode 100644 index b4e3a8a..0000000 --- a/tools/dbScripts/giving/migrations/phase2-ach-payment-intents.ts +++ /dev/null @@ -1,395 +0,0 @@ -/** - * Phase 2 Migration: ACH Payment Methods - Sources to PaymentMethods API - * - * This migration converts existing Stripe bank account Sources (ba_xxx) to PaymentMethods (pm_xxx) - * and updates subscriptions to use default_payment_method instead of default_source. - * - * This is required because Stripe is deprecating the Charges API for ACH Direct Debits - * on May 15, 2026. After this date, all ACH payments must use the Payment Intents API. - * - * Run with: - * npx ts-node tools/dbScripts/giving/migrations/phase2-ach-payment-intents.ts --env=prod - * npx ts-node tools/dbScripts/giving/migrations/phase2-ach-payment-intents.ts --env=prod --dry-run - */ - -import Stripe from "stripe"; -import { Environment } from "../../../../src/shared/helpers/Environment"; -import { MultiDatabasePool } from "../../../../src/shared/infrastructure/MultiDatabasePool"; -import { EncryptionHelper } from "@churchapps/apihelper"; - -interface MigrationOptions { - dryRun: boolean; - environment: string; -} - -interface Gateway { - id: string; - churchId: string; - provider: string; - privateKey: string; -} - -interface Customer { - id: string; - churchId: string; - personId: string; - provider: string; -} - -interface MigrationResult { - customerId: string; - churchId: string; - sourceId: string; - newPaymentMethodId?: string; - status: "migrated" | "skipped" | "error"; - error?: string; -} - -interface SubscriptionMigrationResult { - subscriptionId: string; - churchId: string; - oldSource?: string; - newPaymentMethod?: string; - status: "migrated" | "skipped" | "error"; - error?: string; -} - -async function getStripeGateways(): Promise { - const sql = ` - SELECT id, churchId, provider, privateKey - FROM gateways - WHERE provider = 'stripe' OR provider = 'Stripe' - `; - return await MultiDatabasePool.query("giving", sql); -} - -async function getCustomersForChurch(churchId: string): Promise { - const sql = ` - SELECT id, churchId, personId, provider - FROM customers - WHERE churchId = ? AND (provider = 'stripe' OR provider = 'Stripe' OR provider IS NULL) - `; - return await MultiDatabasePool.query("giving", sql, [churchId]); -} - -async function getSubscriptionsForChurch(churchId: string): Promise<{ id: string; churchId: string; customerId: string }[]> { - const sql = ` - SELECT id, churchId, customerId - FROM subscriptions - WHERE churchId = ? - `; - return await MultiDatabasePool.query("giving", sql, [churchId]); -} - -async function migrateBankAccountsForGateway( - gateway: Gateway, - options: MigrationOptions -): Promise { - const results: MigrationResult[] = []; - - const secretKey = EncryptionHelper.decrypt(gateway.privateKey); - const stripe = new Stripe(secretKey, { apiVersion: "2025-02-24.acacia" }); - - const customers = await getCustomersForChurch(gateway.churchId); - console.log(` Found ${customers.length} customers for church ${gateway.churchId}`); - - for (const customer of customers) { - try { - // List bank account sources for this customer - const sources = await stripe.customers.listSources(customer.id, { - object: "bank_account", - limit: 100 - }); - - if (sources.data.length === 0) { - continue; // No bank accounts to migrate - } - - console.log(` Customer ${customer.id} has ${sources.data.length} bank account(s)`); - - for (const source of sources.data) { - const bankSource = source as Stripe.BankAccount; - - if (options.dryRun) { - console.log(` šŸ“ [dry-run] Would migrate source ${bankSource.id} (****${bankSource.last4})`); - results.push({ - customerId: customer.id, - churchId: gateway.churchId, - sourceId: bankSource.id, - status: "skipped", - error: "Dry run - no changes made" - }); - continue; - } - - try { - // Create a SetupIntent to establish a mandate for the bank account - // Note: For production migration, Stripe recommends using their Tokens API migration - // which can backfill mandates. This script demonstrates the approach. - // - // The proper migration flow per Stripe docs: - // 1. Use stripe.paymentMethods.create() with the bank account details - // 2. Attach to customer - // 3. Create mandate via SetupIntent - - // For existing verified bank accounts, we need to create a new PaymentMethod - // and attach it to the customer. The bank will be instantly verified if the - // original source was already verified. - - const paymentMethod = await stripe.paymentMethods.create({ - type: "us_bank_account", - us_bank_account: { - account_holder_type: bankSource.account_holder_type as "individual" | "company", - account_type: (bankSource as any).account_type || "checking", - // Note: We can't access the actual account/routing numbers from the source - // This requires Stripe's internal migration or customer re-authentication - }, - billing_details: { - name: bankSource.account_holder_name || undefined - }, - metadata: { - migrated_from_source: bankSource.id, - migration_date: new Date().toISOString() - } - }); - - // Attach the PaymentMethod to the customer - await stripe.paymentMethods.attach(paymentMethod.id, { - customer: customer.id - }); - - console.log(` āœ… Migrated ${bankSource.id} → ${paymentMethod.id}`); - - results.push({ - customerId: customer.id, - churchId: gateway.churchId, - sourceId: bankSource.id, - newPaymentMethodId: paymentMethod.id, - status: "migrated" - }); - } catch (error: any) { - // The above approach may fail because we can't create a us_bank_account - // PaymentMethod directly without the account details. In production, - // use Stripe's recommended migration path or have customers re-link. - console.log(` āš ļø Could not auto-migrate ${bankSource.id}: ${error.message}`); - console.log(` Customer will need to re-link their bank account via Financial Connections`); - - results.push({ - customerId: customer.id, - churchId: gateway.churchId, - sourceId: bankSource.id, - status: "error", - error: `Auto-migration not possible: ${error.message}. Customer must re-link via Financial Connections.` - }); - } - } - } catch (error: any) { - console.log(` āŒ Error processing customer ${customer.id}: ${error.message}`); - results.push({ - customerId: customer.id, - churchId: gateway.churchId, - sourceId: "unknown", - status: "error", - error: error.message - }); - } - } - - return results; -} - -async function migrateSubscriptionsForGateway( - gateway: Gateway, - options: MigrationOptions -): Promise { - const results: SubscriptionMigrationResult[] = []; - - const secretKey = EncryptionHelper.decrypt(gateway.privateKey); - const stripe = new Stripe(secretKey, { apiVersion: "2025-02-24.acacia" }); - - const subscriptions = await getSubscriptionsForChurch(gateway.churchId); - console.log(` Found ${subscriptions.length} subscriptions for church ${gateway.churchId}`); - - for (const sub of subscriptions) { - try { - // Get the Stripe subscription - const stripeSub = await stripe.subscriptions.retrieve(sub.id); - - // Check if this subscription uses a bank account source - if (!stripeSub.default_source || typeof stripeSub.default_source !== "string") { - continue; // No source or already using PaymentMethod - } - - // Check if the source is a bank account - if (!stripeSub.default_source.startsWith("ba_")) { - continue; // Not a bank account source - } - - console.log(` Subscription ${sub.id} uses bank source ${stripeSub.default_source}`); - - if (options.dryRun) { - console.log(` šŸ“ [dry-run] Would need to migrate subscription ${sub.id}`); - results.push({ - subscriptionId: sub.id, - churchId: gateway.churchId, - oldSource: stripeSub.default_source, - status: "skipped", - error: "Dry run - customer must re-link bank account first" - }); - continue; - } - - // For production, we would: - // 1. Find the migrated PaymentMethod for this source - // 2. Update the subscription to use default_payment_method - - // Since direct migration may not be possible, log for manual review - results.push({ - subscriptionId: sub.id, - churchId: gateway.churchId, - oldSource: stripeSub.default_source, - status: "skipped", - error: "Requires customer to re-link bank account via Financial Connections" - }); - } catch (error: any) { - console.log(` āŒ Error processing subscription ${sub.id}: ${error.message}`); - results.push({ - subscriptionId: sub.id, - churchId: gateway.churchId, - status: "error", - error: error.message - }); - } - } - - return results; -} - -async function generateMigrationReport( - bankResults: MigrationResult[], - subscriptionResults: SubscriptionMigrationResult[] -) { - console.log("\n" + "=".repeat(80)); - console.log("MIGRATION REPORT"); - console.log("=".repeat(80)); - - // Bank account migration summary - const bankMigrated = bankResults.filter(r => r.status === "migrated").length; - const bankSkipped = bankResults.filter(r => r.status === "skipped").length; - const bankErrors = bankResults.filter(r => r.status === "error").length; - - console.log("\nBank Account Migration:"); - console.log(` āœ… Migrated: ${bankMigrated}`); - console.log(` ā­ļø Skipped: ${bankSkipped}`); - console.log(` āŒ Errors: ${bankErrors}`); - - // Subscription migration summary - const subMigrated = subscriptionResults.filter(r => r.status === "migrated").length; - const subSkipped = subscriptionResults.filter(r => r.status === "skipped").length; - const subErrors = subscriptionResults.filter(r => r.status === "error").length; - - console.log("\nSubscription Migration:"); - console.log(` āœ… Migrated: ${subMigrated}`); - console.log(` ā­ļø Skipped: ${subSkipped}`); - console.log(` āŒ Errors: ${subErrors}`); - - // List items requiring manual intervention - const manualIntervention = [ - ...bankResults.filter(r => r.status === "error"), - ...subscriptionResults.filter(r => r.status === "skipped" || r.status === "error") - ]; - - if (manualIntervention.length > 0) { - console.log("\nāš ļø ITEMS REQUIRING MANUAL INTERVENTION:"); - console.log("-".repeat(80)); - - for (const item of bankResults.filter(r => r.status === "error")) { - console.log(` Bank Account: ${item.sourceId}`); - console.log(` Customer: ${item.customerId}`); - console.log(` Church: ${item.churchId}`); - console.log(` Error: ${item.error}`); - console.log(""); - } - - for (const item of subscriptionResults.filter(r => r.status !== "migrated")) { - console.log(` Subscription: ${item.subscriptionId}`); - console.log(` Church: ${item.churchId}`); - console.log(` Old Source: ${item.oldSource || "N/A"}`); - console.log(` Status: ${item.status}`); - console.log(` Note: ${item.error}`); - console.log(""); - } - } - - console.log("\n" + "=".repeat(80)); - console.log("RECOMMENDED ACTIONS:"); - console.log("=".repeat(80)); - console.log(` -1. For bank accounts that could not be auto-migrated: - - Contact affected customers - - Ask them to re-link their bank account using Financial Connections - - The new flow provides instant verification for most banks - -2. For subscriptions using legacy bank sources: - - These will continue to work until May 15, 2026 - - After migration deadline, customers must re-link their bank accounts - - Consider proactive outreach to avoid payment failures - -3. Monitor the webhook logs for any payment failures after migration - -4. Run this script periodically to track migration progress -`); -} - -async function runMigration(options: MigrationOptions) { - await Environment.init(options.environment); - console.log(`šŸš€ Running ACH Payment Intents Migration (dryRun=${options.dryRun})`); - console.log(` Environment: ${options.environment}`); - console.log(""); - - const allBankResults: MigrationResult[] = []; - const allSubResults: SubscriptionMigrationResult[] = []; - - // Get all Stripe gateways - const gateways = await getStripeGateways(); - console.log(`Found ${gateways.length} Stripe gateway(s)\n`); - - for (const gateway of gateways) { - console.log(`Processing gateway ${gateway.id} (church: ${gateway.churchId})`); - - // Migrate bank accounts - const bankResults = await migrateBankAccountsForGateway(gateway, options); - allBankResults.push(...bankResults); - - // Migrate subscriptions - const subResults = await migrateSubscriptionsForGateway(gateway, options); - allSubResults.push(...subResults); - - console.log(""); - } - - // Generate report - await generateMigrationReport(allBankResults, allSubResults); - - console.log("šŸŽ‰ Migration script complete!"); -} - -function parseOptions(): MigrationOptions { - const args = process.argv.slice(2); - const dryRun = args.includes("--dry-run"); - const envArg = args.find((arg) => arg.startsWith("--env=")); - const environment = envArg ? envArg.split("=")[1] : process.env.ENVIRONMENT || "dev"; - return { dryRun, environment }; -} - -(async () => { - const options = parseOptions(); - try { - await runMigration(options); - } catch (error) { - console.error("āŒ Migration failed:", error instanceof Error ? error.message : error); - process.exitCode = 1; - } finally { - await MultiDatabasePool.closeAll(); - } -})(); diff --git a/tools/initdb.ts b/tools/initdb.ts index 5ca1ae2..a24bf53 100644 --- a/tools/initdb.ts +++ b/tools/initdb.ts @@ -1,3 +1,12 @@ +/** + * @deprecated Use `npm run migrate` instead. This script will be removed in a future release. + * See tools/migrate.ts for the Kysely-based migration system. + */ +console.warn( + "\nāš ļø DEPRECATED: initdb.ts is deprecated. Use 'npm run migrate' instead.\n" + + " See tools/migrate.ts for the Kysely-based migration system.\n" +); + import { Environment } from "../src/shared/helpers/Environment.js"; import { ConnectionManager } from "../src/shared/infrastructure/ConnectionManager.js"; import { MultiDatabasePool } from "../src/shared/infrastructure/MultiDatabasePool.js"; diff --git a/tools/migrate-baseline.ts b/tools/migrate-baseline.ts new file mode 100644 index 0000000..e519326 --- /dev/null +++ b/tools/migrate-baseline.ts @@ -0,0 +1,97 @@ +import { + MODULE_NAMES, + type ModuleName, + ensureEnvironment, + createKyselyForModule, +} from "./migrations/kysely-config.js"; +import { sql } from "kysely"; + +interface BaselineOptions { + module?: ModuleName; +} + +function parseArguments(): BaselineOptions { + const args = process.argv.slice(2); + const options: BaselineOptions = {}; + + for (const arg of args) { + if (arg.startsWith("--module=")) { + const mod = arg.split("=")[1] as ModuleName; + if (!MODULE_NAMES.includes(mod)) { + console.error(`Invalid module: ${mod}`); + console.error(`Valid modules: ${MODULE_NAMES.join(", ")}`); + process.exit(1); + } + options.module = mod; + } + } + + return options; +} + +async function baselineModule(moduleName: ModuleName) { + const db = createKyselyForModule(moduleName); + + try { + // Create the kysely_migration table if it doesn't exist + await sql` + CREATE TABLE IF NOT EXISTS \`kysely_migration\` ( + \`name\` varchar(255) NOT NULL, + \`timestamp\` varchar(255) NOT NULL, + PRIMARY KEY (\`name\`) + ) + `.execute(db); + + // Create the kysely_migration_lock table if it doesn't exist + await sql` + CREATE TABLE IF NOT EXISTS \`kysely_migration_lock\` ( + \`id\` varchar(255) NOT NULL, + \`is_locked\` integer NOT NULL DEFAULT 0, + PRIMARY KEY (\`id\`) + ) + `.execute(db); + + // Check if baseline already applied + const existing = await sql<{ name: string }>` + SELECT name FROM kysely_migration WHERE name = '2026-02-06_initial_schema' + `.execute(db); + + if (existing.rows.length > 0) { + console.log(` [${moduleName}] Baseline already applied.`); + return; + } + + // Insert baseline record + await sql` + INSERT INTO kysely_migration (name, timestamp) + VALUES ('2026-02-06_initial_schema', ${new Date().toISOString()}) + `.execute(db); + + console.log(` [${moduleName}] Baseline applied — 2026-02-06_initial_schema marked as executed.`); + } finally { + await db.destroy(); + } +} + +async function main() { + const options = parseArguments(); + + try { + await ensureEnvironment(); + + const modules = options.module ? [options.module] : [...MODULE_NAMES]; + + console.log("Applying baseline to existing databases...\n"); + + for (const moduleName of modules) { + await baselineModule(moduleName); + } + + console.log("\nDone. Future migrations will run normally."); + } catch (error) { + console.error("Baseline failed:", error); + process.exit(1); + } +} + +main(); diff --git a/tools/migrate-create.ts b/tools/migrate-create.ts new file mode 100644 index 0000000..b2b5f0b --- /dev/null +++ b/tools/migrate-create.ts @@ -0,0 +1,69 @@ +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import { MODULE_NAMES, type ModuleName } from "./migrations/kysely-config.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function parseArguments(): { module: ModuleName; name: string } { + const args = process.argv.slice(2); + let module: string | undefined; + let name: string | undefined; + + for (const arg of args) { + if (arg.startsWith("--module=")) { + module = arg.split("=")[1]; + } else if (arg.startsWith("--name=")) { + name = arg.split("=")[1]; + } + } + + if (!module || !MODULE_NAMES.includes(module as ModuleName)) { + console.error(`--module is required. Valid modules: ${MODULE_NAMES.join(", ")}`); + process.exit(1); + } + + if (!name) { + console.error("--name is required (e.g. --name=add_email_verified)"); + process.exit(1); + } + + return { module: module as ModuleName, name }; +} + +function getDatePrefix(): string { + const now = new Date(); + const y = now.getFullYear(); + const m = String(now.getMonth() + 1).padStart(2, "0"); + const d = String(now.getDate()).padStart(2, "0"); + return `${y}-${m}-${d}`; +} + +const boilerplate = (name: string) => `import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // TODO: implement ${name} migration +} + +export async function down(db: Kysely): Promise { + // TODO: implement ${name} rollback +} +`; + +function main() { + const { module: moduleName, name } = parseArguments(); + const migrationsDir = path.join(__dirname, "migrations", moduleName); + const prefix = getDatePrefix(); + const fileName = `${prefix}_${name}.ts`; + const filePath = path.join(migrationsDir, fileName); + + if (!fs.existsSync(migrationsDir)) { + fs.mkdirSync(migrationsDir, { recursive: true }); + } + + fs.writeFileSync(filePath, boilerplate(name)); + console.log(`Created: tools/migrations/${moduleName}/${fileName}`); +} + +main(); diff --git a/tools/migrate.ts b/tools/migrate.ts new file mode 100644 index 0000000..af391b6 --- /dev/null +++ b/tools/migrate.ts @@ -0,0 +1,176 @@ +import * as path from "path"; +import { fileURLToPath } from "url"; +import { Migrator, FileMigrationProvider } from "kysely"; +import * as fs from "fs"; +import { + MODULE_NAMES, + type ModuleName, + ensureEnvironment, + createKyselyForModule, + ensureDatabaseExists, +} from "./migrations/kysely-config.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface MigrateOptions { + module?: ModuleName; + action: "up" | "down" | "status"; +} + +function parseArguments(): MigrateOptions { + const args = process.argv.slice(2); + const options: MigrateOptions = { action: "up" }; + + for (const arg of args) { + if (arg.startsWith("--module=")) { + const mod = arg.split("=")[1] as ModuleName; + if (!MODULE_NAMES.includes(mod)) { + console.error(`Invalid module: ${mod}`); + console.error(`Valid modules: ${MODULE_NAMES.join(", ")}`); + process.exit(1); + } + options.module = mod; + } else if (arg.startsWith("--action=")) { + const action = arg.split("=")[1]; + if (!["up", "down", "status"].includes(action)) { + console.error(`Invalid action: ${action}. Use up, down, or status.`); + process.exit(1); + } + options.action = action as "up" | "down" | "status"; + } + } + + return options; +} + +async function getMigrator(moduleName: ModuleName) { + const migrationsPath = path.join(__dirname, "migrations", moduleName); + + if (!fs.existsSync(migrationsPath)) { + console.log(` No migrations directory for ${moduleName}, skipping...`); + return null; + } + + const db = createKyselyForModule(moduleName); + const migrator = new Migrator({ + db, + provider: new FileMigrationProvider({ fs: fs.promises, path, migrationFolder: migrationsPath }), + }); + + return { db, migrator }; +} + +async function migrateUp(moduleName: ModuleName) { + console.log(`\n[${moduleName}] Ensuring database exists...`); + await ensureDatabaseExists(moduleName); + + const result = await getMigrator(moduleName); + if (!result) return; + const { db, migrator } = result; + + try { + console.log(`[${moduleName}] Running migrations...`); + const { error, results } = await migrator.migrateToLatest(); + + results?.forEach((r) => { + if (r.status === "Success") { + console.log(` [${moduleName}] Applied: ${r.migrationName}`); + } else if (r.status === "Error") { + console.error(` [${moduleName}] Failed: ${r.migrationName}`); + } + }); + + if (error) { + console.error(`[${moduleName}] Migration failed:`, error); + throw error; + } + + if (!results?.length) { + console.log(` [${moduleName}] Already up to date.`); + } + } finally { + await db.destroy(); + } +} + +async function migrateDown(moduleName: ModuleName) { + const result = await getMigrator(moduleName); + if (!result) return; + const { db, migrator } = result; + + try { + console.log(`[${moduleName}] Rolling back last migration...`); + const { error, results } = await migrator.migrateDown(); + + results?.forEach((r) => { + if (r.status === "Success") { + console.log(` [${moduleName}] Reverted: ${r.migrationName}`); + } else if (r.status === "Error") { + console.error(` [${moduleName}] Revert failed: ${r.migrationName}`); + } + }); + + if (error) { + console.error(`[${moduleName}] Rollback failed:`, error); + throw error; + } + + if (!results?.length) { + console.log(` [${moduleName}] No migrations to revert.`); + } + } finally { + await db.destroy(); + } +} + +async function migrateStatus(moduleName: ModuleName) { + const result = await getMigrator(moduleName); + if (!result) return; + const { db, migrator } = result; + + try { + const migrations = await migrator.getMigrations(); + console.log(`\n[${moduleName}] Migration status:`); + for (const m of migrations) { + const status = m.executedAt ? `Applied (${m.executedAt.toISOString()})` : "Pending"; + console.log(` ${m.name}: ${status}`); + } + if (migrations.length === 0) { + console.log(` No migrations found.`); + } + } finally { + await db.destroy(); + } +} + +async function main() { + const options = parseArguments(); + + try { + await ensureEnvironment(); + + const modules = options.module ? [options.module] : [...MODULE_NAMES]; + + for (const moduleName of modules) { + switch (options.action) { + case "up": + await migrateUp(moduleName); + break; + case "down": + await migrateDown(moduleName); + break; + case "status": + await migrateStatus(moduleName); + break; + } + } + + console.log("\nDone."); + } catch (error) { + console.error("Migration failed:", error); + process.exit(1); + } +} + +main(); diff --git a/tools/migrations/attendance/2026-02-06_initial_schema.ts b/tools/migrations/attendance/2026-02-06_initial_schema.ts new file mode 100644 index 0000000..2fc6f12 --- /dev/null +++ b/tools/migrations/attendance/2026-02-06_initial_schema.ts @@ -0,0 +1,264 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("campuses") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("name", sql`varchar(255)`) + .addColumn("address1", sql`varchar(50)`) + .addColumn("address2", sql`varchar(50)`) + .addColumn("city", sql`varchar(50)`) + .addColumn("state", sql`varchar(10)`) + .addColumn("zip", sql`varchar(10)`) + .addColumn("removed", sql`bit(1)`) + .addUniqueConstraint("campuses_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("campuses_churchId") + .on("campuses") + .column("churchId") + .execute(); + + await db.schema + .createTable("services") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("campusId", sql`char(11)`) + .addColumn("name", sql`varchar(50)`) + .addColumn("removed", sql`bit(1)`) + .addUniqueConstraint("services_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("services_churchId") + .on("services") + .column("churchId") + .execute(); + + await db.schema + .createIndex("services_campusId") + .on("services") + .column("campusId") + .execute(); + + await db.schema + .createTable("serviceTimes") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("serviceId", sql`char(11)`) + .addColumn("name", sql`varchar(50)`) + .addColumn("removed", sql`bit(1)`) + .addUniqueConstraint("serviceTimes_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("serviceTimes_churchId") + .on("serviceTimes") + .column("churchId") + .execute(); + + await db.schema + .createIndex("serviceTimes_serviceId") + .on("serviceTimes") + .column("serviceId") + .execute(); + + await db.schema + .createIndex("serviceTimes_idx_church_service_removed") + .on("serviceTimes") + .columns(["churchId", "serviceId", "removed"]) + .execute(); + + await db.schema + .createTable("groupServiceTimes") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("groupId", sql`char(11)`) + .addColumn("serviceTimeId", sql`char(11)`) + .addUniqueConstraint("groupServiceTimes_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("groupServiceTimes_churchId") + .on("groupServiceTimes") + .column("churchId") + .execute(); + + await db.schema + .createIndex("groupServiceTimes_groupId") + .on("groupServiceTimes") + .column("groupId") + .execute(); + + await db.schema + .createIndex("groupServiceTimes_serviceTimeId") + .on("groupServiceTimes") + .column("serviceTimeId") + .execute(); + + await db.schema + .createTable("sessions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("groupId", sql`char(11)`) + .addColumn("serviceTimeId", sql`char(11)`) + .addColumn("sessionDate", "datetime") + .addUniqueConstraint("sessions_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("sessions_churchId") + .on("sessions") + .column("churchId") + .execute(); + + await db.schema + .createIndex("sessions_groupId") + .on("sessions") + .column("groupId") + .execute(); + + await db.schema + .createIndex("sessions_serviceTimeId") + .on("sessions") + .column("serviceTimeId") + .execute(); + + await db.schema + .createIndex("sessions_idx_church_session_date") + .on("sessions") + .columns(["churchId", "sessionDate"]) + .execute(); + + await db.schema + .createIndex("sessions_idx_church_group_service") + .on("sessions") + .columns(["churchId", "groupId", "serviceTimeId"]) + .execute(); + + await db.schema + .createTable("settings") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("keyName", sql`varchar(255)`) + .addColumn("value", sql`varchar(255)`) + .addUniqueConstraint("settings_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("settings_churchId") + .on("settings") + .column("churchId") + .execute(); + + await db.schema + .createTable("visits") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("serviceId", sql`char(11)`) + .addColumn("groupId", sql`char(11)`) + .addColumn("visitDate", "datetime") + .addColumn("checkinTime", "datetime") + .addColumn("addedBy", sql`char(11)`) + .addUniqueConstraint("visits_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("visits_churchId") + .on("visits") + .column("churchId") + .execute(); + + await db.schema + .createIndex("visits_personId") + .on("visits") + .column("personId") + .execute(); + + await db.schema + .createIndex("visits_serviceId") + .on("visits") + .column("serviceId") + .execute(); + + await db.schema + .createIndex("visits_groupId") + .on("visits") + .column("groupId") + .execute(); + + await db.schema + .createIndex("visits_idx_church_visit_date") + .on("visits") + .columns(["churchId", "visitDate"]) + .execute(); + + await db.schema + .createIndex("visits_idx_church_person") + .on("visits") + .columns(["churchId", "personId"]) + .execute(); + + await db.schema + .createTable("visitSessions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("visitId", sql`char(11)`) + .addColumn("sessionId", sql`char(11)`) + .addUniqueConstraint("visitSessions_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("visitSessions_churchId") + .on("visitSessions") + .column("churchId") + .execute(); + + await db.schema + .createIndex("visitSessions_visitId") + .on("visitSessions") + .column("visitId") + .execute(); + + await db.schema + .createIndex("visitSessions_sessionId") + .on("visitSessions") + .column("sessionId") + .execute(); +} + +export async function down(db: Kysely): Promise { + const tables = [ + "visitSessions", + "visits", + "settings", + "sessions", + "groupServiceTimes", + "serviceTimes", + "services", + "campuses", + ]; + + for (const table of tables) { + await db.schema.dropTable(table).ifExists().execute(); + } +} diff --git a/tools/migrations/content/2026-02-06_initial_schema.ts b/tools/migrations/content/2026-02-06_initial_schema.ts new file mode 100644 index 0000000..fbde441 --- /dev/null +++ b/tools/migrations/content/2026-02-06_initial_schema.ts @@ -0,0 +1,525 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // === Events === + + await db.schema + .createTable("events") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("groupId", sql`char(11)`) + .addColumn("allDay", sql`bit(1)`) + .addColumn("start", "datetime") + .addColumn("end", "datetime") + .addColumn("title", sql`varchar(255)`) + .addColumn("description", sql`mediumtext`) + .addColumn("visibility", sql`varchar(45)`) + .addColumn("recurrenceRule", sql`varchar(255)`) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createIndex("events_ix_churchId_groupId") + .on("events") + .columns(["churchId", "groupId"]) + .execute(); + + await db.schema + .createTable("eventExceptions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("eventId", sql`char(11)`) + .addColumn("exceptionDate", "datetime") + .addColumn("recurrenceDate", "datetime") + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createTable("curatedCalendars") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("name", sql`varchar(45)`) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createTable("curatedEvents") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("curatedCalendarId", sql`char(11)`) + .addColumn("groupId", sql`char(11)`) + .addColumn("eventId", sql`char(11)`) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createIndex("curatedEvents_ix_churchId_curatedCalendarId") + .on("curatedEvents") + .columns(["churchId", "curatedCalendarId"]) + .execute(); + + // === Streaming === + + await db.schema + .createTable("playlists") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("title", sql`varchar(255)`) + .addColumn("description", "text") + .addColumn("publishDate", "datetime") + .addColumn("thumbnail", sql`varchar(1024)`) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createTable("sermons") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("playlistId", sql`char(11)`) + .addColumn("videoType", sql`varchar(45)`) + .addColumn("videoData", sql`varchar(255)`) + .addColumn("videoUrl", sql`varchar(1024)`) + .addColumn("title", sql`varchar(255)`) + .addColumn("description", "text") + .addColumn("publishDate", "datetime") + .addColumn("thumbnail", sql`varchar(1024)`) + .addColumn("duration", sql`int(11)`) + .addColumn("permanentUrl", sql`bit(1)`) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createTable("streamingServices") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("serviceTime", "datetime") + .addColumn("earlyStart", sql`int(11)`) + .addColumn("chatBefore", sql`int(11)`) + .addColumn("chatAfter", sql`int(11)`) + .addColumn("provider", sql`varchar(45)`) + .addColumn("providerKey", sql`varchar(255)`) + .addColumn("videoUrl", sql`varchar(5000)`) + .addColumn("timezoneOffset", sql`int(11)`) + .addColumn("recurring", sql`tinyint(4)`) + .addColumn("label", sql`varchar(255)`) + .addColumn("sermonId", sql`char(11)`) + .addUniqueConstraint("streamingServices_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + // === Content === + + await db.schema + .createTable("blocks") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("blockType", sql`varchar(45)`) + .addColumn("name", sql`varchar(45)`) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createTable("elements") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("sectionId", sql`char(11)`) + .addColumn("blockId", sql`char(11)`) + .addColumn("elementType", sql`varchar(45)`) + .addColumn("sort", sql`float`) + .addColumn("parentId", sql`char(11)`) + .addColumn("answersJSON", sql`mediumtext`) + .addColumn("stylesJSON", sql`mediumtext`) + .addColumn("animationsJSON", sql`mediumtext`) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createIndex("elements_ix_churchId_blockId_sort") + .on("elements") + .columns(["churchId", "blockId", "sort"]) + .execute(); + + await db.schema + .createTable("globalStyles") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("fonts", "text") + .addColumn("palette", "text") + .addColumn("typography", "text") + .addColumn("spacing", "text") + .addColumn("borderRadius", "text") + .addColumn("customCss", "text") + .addColumn("customJS", "text") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("pages") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("url", sql`varchar(255)`) + .addColumn("title", sql`varchar(255)`) + .addColumn("layout", sql`varchar(45)`) + .addUniqueConstraint("pages_uq_churchId_url", ["churchId", "url"]) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createTable("sections") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("pageId", sql`char(11)`) + .addColumn("blockId", sql`char(11)`) + .addColumn("zone", sql`varchar(45)`) + .addColumn("background", sql`varchar(255)`) + .addColumn("textColor", sql`varchar(45)`) + .addColumn("headingColor", sql`varchar(45)`) + .addColumn("linkColor", sql`varchar(45)`) + .addColumn("sort", sql`float`) + .addColumn("targetBlockId", sql`char(11)`) + .addColumn("answersJSON", sql`mediumtext`) + .addColumn("stylesJSON", sql`mediumtext`) + .addColumn("animationsJSON", sql`mediumtext`) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createIndex("sections_ix_churchId_pageId_sort") + .on("sections") + .columns(["churchId", "pageId", "sort"]) + .execute(); + + await db.schema + .createIndex("sections_ix_churchId_blockId_sort") + .on("sections") + .columns(["churchId", "blockId", "sort"]) + .execute(); + + await db.schema + .createTable("links") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("category", sql`varchar(45)`) + .addColumn("url", sql`varchar(255)`) + .addColumn("linkType", sql`varchar(45)`) + .addColumn("linkData", sql`varchar(255)`) + .addColumn("icon", sql`varchar(45)`) + .addColumn("text", sql`varchar(255)`) + .addColumn("sort", sql`float`) + .addColumn("photo", sql`varchar(255)`) + .addColumn("parentId", sql`char(11)`) + .addColumn("visibility", sql`varchar(45)`, (col) => col.defaultTo("everyone")) + .addColumn("groupIds", "text") + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci`) + .execute(); + + await db.schema + .createIndex("links_churchId") + .on("links") + .column("churchId") + .execute(); + + await db.schema + .createTable("files") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("contentType", sql`varchar(45)`) + .addColumn("contentId", sql`char(11)`) + .addColumn("fileName", sql`varchar(255)`) + .addColumn("contentPath", sql`varchar(1024)`) + .addColumn("fileType", sql`varchar(45)`) + .addColumn("size", sql`int(11)`) + .addColumn("dateModified", "datetime") + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createIndex("files_ix_churchId_id") + .on("files") + .columns(["churchId", "id"]) + .execute(); + + await db.schema + .createTable("settings") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("userId", sql`char(11)`) + .addColumn("keyName", sql`varchar(255)`) + .addColumn("value", sql`mediumtext`) + .addColumn("public", sql`bit(1)`) + .addUniqueConstraint("settings_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci`) + .execute(); + + await db.schema + .createIndex("settings_churchId") + .on("settings") + .column("churchId") + .execute(); + + await db.schema + .createIndex("settings_ix_churchId_keyName_userId") + .on("settings") + .columns(["churchId", "keyName", "userId"]) + .execute(); + + await db.schema + .createTable("pageHistory") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("pageId", sql`char(11)`) + .addColumn("blockId", sql`char(11)`) + .addColumn("snapshotJSON", sql`longtext`) + .addColumn("description", sql`varchar(200)`) + .addColumn("userId", sql`char(11)`) + .addColumn("createdDate", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) + .execute(); + + await db.schema + .createIndex("pageHistory_ix_pageId") + .on("pageHistory") + .columns(["pageId", "createdDate"]) + .execute(); + + await db.schema + .createIndex("pageHistory_ix_blockId") + .on("pageHistory") + .columns(["blockId", "createdDate"]) + .execute(); + + // === Bible === + + await db.schema + .createTable("bibleTranslations") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("abbreviation", sql`varchar(10)`) + .addColumn("name", sql`varchar(255)`) + .addColumn("nameLocal", sql`varchar(255)`) + .addColumn("description", sql`varchar(1000)`) + .addColumn("source", sql`varchar(45)`) + .addColumn("sourceKey", sql`varchar(45)`) + .addColumn("language", sql`varchar(45)`) + .addColumn("countries", sql`varchar(255)`) + .addColumn("copyright", sql`varchar(1000)`) + .addColumn("attributionRequired", sql`bit`) + .addColumn("attributionString", sql`varchar(1000)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("bibleBooks") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("translationKey", sql`varchar(45)`) + .addColumn("keyName", sql`varchar(45)`) + .addColumn("abbreviation", sql`varchar(45)`) + .addColumn("name", sql`varchar(45)`) + .addColumn("sort", sql`int(11)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("bibleBooks_ix_translationKey") + .on("bibleBooks") + .column("translationKey") + .execute(); + + await db.schema + .createTable("bibleChapters") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("translationKey", sql`varchar(45)`) + .addColumn("bookKey", sql`varchar(45)`) + .addColumn("keyName", sql`varchar(45)`) + .addColumn("number", sql`int(11)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("bibleChapters_ix_translationKey_bookKey") + .on("bibleChapters") + .columns(["translationKey", "bookKey"]) + .execute(); + + await db.schema + .createTable("bibleVerses") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("translationKey", sql`varchar(45)`) + .addColumn("chapterKey", sql`varchar(45)`) + .addColumn("keyName", sql`varchar(45)`) + .addColumn("number", sql`int(11)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("bibleVerses_ix_translationKey_chapterKey") + .on("bibleVerses") + .columns(["translationKey", "chapterKey"]) + .execute(); + + await db.schema + .createTable("bibleVerseTexts") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("translationKey", sql`varchar(45)`) + .addColumn("verseKey", sql`varchar(45)`) + .addColumn("bookKey", sql`varchar(45)`) + .addColumn("chapterNumber", "integer") + .addColumn("verseNumber", "integer") + .addColumn("content", sql`varchar(1000)`) + .addColumn("newParagraph", sql`bit`) + .addUniqueConstraint("bibleVerseTexts_uq_translationKey_verseKey", ["translationKey", "verseKey"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("bibleVerseTexts_ix_translationKey_verseKey") + .on("bibleVerseTexts") + .columns(["translationKey", "verseKey"]) + .execute(); + + await db.schema + .createTable("bibleLookups") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("translationKey", sql`varchar(45)`) + .addColumn("lookupTime", "datetime") + .addColumn("ipAddress", sql`varchar(45)`) + .addColumn("startVerseKey", sql`varchar(15)`) + .addColumn("endVerseKey", sql`varchar(15)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + // === Songs === + + await db.schema + .createTable("arrangements") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("songId", sql`char(11)`) + .addColumn("songDetailId", sql`char(11)`) + .addColumn("name", sql`varchar(45)`) + .addColumn("lyrics", "text") + .addColumn("freeShowId", sql`varchar(45)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("arrangements_ix_churchId_songId") + .on("arrangements") + .columns(["churchId", "songId"]) + .execute(); + + await db.schema + .createTable("arrangementKeys") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("arrangementId", sql`char(11)`) + .addColumn("keySignature", sql`varchar(10)`) + .addColumn("shortDescription", sql`varchar(45)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("songDetailLinks") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("songDetailId", sql`char(11)`) + .addColumn("service", sql`varchar(45)`) + .addColumn("serviceKey", sql`varchar(255)`) + .addColumn("url", sql`varchar(255)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("songDetails") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("praiseChartsId", sql`varchar(45)`) + .addColumn("musicBrainzId", sql`varchar(45)`) + .addColumn("title", sql`varchar(45)`) + .addColumn("artist", sql`varchar(45)`) + .addColumn("album", sql`varchar(45)`) + .addColumn("language", sql`varchar(5)`) + .addColumn("thumbnail", sql`varchar(255)`) + .addColumn("releaseDate", "date") + .addColumn("bpm", sql`int(11)`) + .addColumn("keySignature", sql`varchar(5)`) + .addColumn("seconds", sql`int(11)`) + .addColumn("meter", sql`varchar(10)`) + .addColumn("tones", sql`varchar(45)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("songs") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("name", sql`varchar(45)`) + .addColumn("dateAdded", "date") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("songs_ix_churchId_name") + .on("songs") + .columns(["churchId", "name"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + const tables = [ + "songs", + "songDetails", + "songDetailLinks", + "arrangementKeys", + "arrangements", + "bibleLookups", + "bibleVerseTexts", + "bibleVerses", + "bibleChapters", + "bibleBooks", + "bibleTranslations", + "pageHistory", + "settings", + "files", + "links", + "sections", + "pages", + "globalStyles", + "elements", + "blocks", + "streamingServices", + "sermons", + "playlists", + "curatedEvents", + "curatedCalendars", + "eventExceptions", + "events", + ]; + + for (const table of tables) { + await db.schema.dropTable(table).ifExists().execute(); + } +} diff --git a/tools/migrations/doing/2026-02-06_initial_schema.ts b/tools/migrations/doing/2026-02-06_initial_schema.ts new file mode 100644 index 0000000..ddada07 --- /dev/null +++ b/tools/migrations/doing/2026-02-06_initial_schema.ts @@ -0,0 +1,303 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // === Tasks === + + await db.schema + .createTable("actions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("automationId", sql`char(11)`) + .addColumn("actionType", sql`varchar(45)`) + .addColumn("actionData", sql`mediumtext`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("automations") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("title", sql`varchar(45)`) + .addColumn("recurs", sql`varchar(45)`) + .addColumn("active", sql`bit(1)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("tasks") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("taskNumber", sql`int(11)`) + .addColumn("taskType", sql`varchar(45)`) + .addColumn("dateCreated", "datetime") + .addColumn("dateClosed", "datetime") + .addColumn("associatedWithType", sql`varchar(45)`) + .addColumn("associatedWithId", sql`char(11)`) + .addColumn("associatedWithLabel", sql`varchar(45)`) + .addColumn("createdByType", sql`varchar(45)`) + .addColumn("createdById", sql`char(11)`) + .addColumn("createdByLabel", sql`varchar(45)`) + .addColumn("assignedToType", sql`varchar(45)`) + .addColumn("assignedToId", sql`char(11)`) + .addColumn("assignedToLabel", sql`varchar(45)`) + .addColumn("title", sql`varchar(255)`) + .addColumn("status", sql`varchar(45)`) + .addColumn("automationId", sql`char(11)`) + .addColumn("conversationId", sql`char(11)`) + .addColumn("data", "text") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("tasks_idx_church_status") + .on("tasks") + .columns(["churchId", "status"]) + .execute(); + + await db.schema + .createIndex("tasks_idx_automation") + .on("tasks") + .columns(["churchId", "automationId"]) + .execute(); + + await db.schema + .createIndex("tasks_idx_assigned") + .on("tasks") + .columns(["churchId", "assignedToType", "assignedToId"]) + .execute(); + + await db.schema + .createIndex("tasks_idx_created") + .on("tasks") + .columns(["churchId", "createdByType", "createdById"]) + .execute(); + + await db.schema + .createTable("conditions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("conjunctionId", sql`char(11)`) + .addColumn("field", sql`varchar(45)`) + .addColumn("fieldData", sql`mediumtext`) + .addColumn("operator", sql`varchar(45)`) + .addColumn("value", sql`varchar(45)`) + .addColumn("label", sql`varchar(255)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("conjunctions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("automationId", sql`char(11)`) + .addColumn("parentId", sql`char(11)`) + .addColumn("groupType", sql`varchar(45)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + // === Scheduling === + + await db.schema + .createTable("assignments") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("positionId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("status", sql`varchar(45)`) + .addColumn("notified", "datetime") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("assignments_idx_church_person") + .on("assignments") + .columns(["churchId", "personId"]) + .execute(); + + await db.schema + .createIndex("assignments_idx_position") + .on("assignments") + .column("positionId") + .execute(); + + await db.schema + .createTable("blockoutDates") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("startDate", "date") + .addColumn("endDate", "date") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("notes") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("contentType", sql`varchar(50)`) + .addColumn("contentId", sql`char(11)`) + .addColumn("noteType", sql`varchar(50)`) + .addColumn("addedBy", sql`char(11)`) + .addColumn("createdAt", "datetime") + .addColumn("updatedAt", "datetime") + .addColumn("contents", "text") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("notes_churchId") + .on("notes") + .column("churchId") + .execute(); + + await db.schema + .createTable("plans") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("ministryId", sql`char(11)`) + .addColumn("planTypeId", sql`char(11)`) + .addColumn("name", sql`varchar(45)`) + .addColumn("serviceDate", "date") + .addColumn("notes", sql`mediumtext`) + .addColumn("serviceOrder", sql`bit(1)`) + .addColumn("contentType", sql`varchar(50)`) + .addColumn("contentId", sql`char(11)`) + .addColumn("providerId", sql`varchar(50)`) + .addColumn("providerPlanId", sql`varchar(100)`) + .addColumn("providerPlanName", sql`varchar(255)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("planItems") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("planId", sql`char(11)`) + .addColumn("parentId", sql`char(11)`) + .addColumn("sort", sql`float`) + .addColumn("itemType", sql`varchar(45)`) + .addColumn("relatedId", sql`char(11)`) + .addColumn("label", sql`varchar(45)`) + .addColumn("description", sql`varchar(1000)`) + .addColumn("seconds", sql`int(11)`) + .addColumn("link", sql`varchar(1000)`) + .addColumn("providerId", sql`varchar(50)`) + .addColumn("providerPath", sql`varchar(500)`) + .addColumn("providerContentPath", sql`varchar(50)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("planItems_idx_church_plan") + .on("planItems") + .columns(["churchId", "planId"]) + .execute(); + + await db.schema + .createIndex("planItems_idx_parent") + .on("planItems") + .column("parentId") + .execute(); + + await db.schema + .createTable("planTypes") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("ministryId", sql`char(11)`) + .addColumn("name", sql`varchar(255)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("positions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("planId", sql`char(11)`) + .addColumn("categoryName", sql`varchar(45)`) + .addColumn("name", sql`varchar(45)`) + .addColumn("count", sql`int(11)`) + .addColumn("groupId", sql`char(11)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("positions_idx_church_plan") + .on("positions") + .columns(["churchId", "planId"]) + .execute(); + + await db.schema + .createIndex("positions_idx_group") + .on("positions") + .column("groupId") + .execute(); + + await db.schema + .createTable("times") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("planId", sql`char(11)`) + .addColumn("displayName", sql`varchar(45)`) + .addColumn("startTime", "datetime") + .addColumn("endTime", "datetime") + .addColumn("teams", sql`varchar(1000)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("contentProviderAuths") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("ministryId", sql`char(11)`) + .addColumn("providerId", sql`varchar(50)`) + .addColumn("accessToken", "text") + .addColumn("refreshToken", "text") + .addColumn("tokenType", sql`varchar(50)`) + .addColumn("expiresAt", "datetime") + .addColumn("scope", sql`varchar(255)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("contentProviderAuths_idx_ministry_provider") + .on("contentProviderAuths") + .columns(["churchId", "ministryId", "providerId"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + const tables = [ + "contentProviderAuths", + "times", + "positions", + "planTypes", + "planItems", + "plans", + "notes", + "blockoutDates", + "assignments", + "conjunctions", + "conditions", + "tasks", + "automations", + "actions", + ]; + + for (const table of tables) { + await db.schema.dropTable(table).ifExists().execute(); + } +} diff --git a/tools/migrations/giving/2026-02-06_initial_schema.ts b/tools/migrations/giving/2026-02-06_initial_schema.ts new file mode 100644 index 0000000..747f26a --- /dev/null +++ b/tools/migrations/giving/2026-02-06_initial_schema.ts @@ -0,0 +1,284 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("funds") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("name", sql`varchar(50)`) + .addColumn("removed", sql`bit(1)`) + .addColumn("productId", sql`varchar(50)`) + .addColumn("taxDeductible", sql`bit(1)`) + .addUniqueConstraint("funds_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("funds_idx_church_removed") + .on("funds") + .columns(["churchId", "removed"]) + .execute(); + + await db.schema + .createTable("donations") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("batchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("donationDate", "datetime") + .addColumn("amount", "double precision") + .addColumn("method", sql`varchar(50)`) + .addColumn("methodDetails", sql`varchar(255)`) + .addColumn("notes", "text") + .addColumn("entryTime", "datetime") + .addColumn("status", sql`varchar(20)`, (col) => col.defaultTo("complete")) + .addColumn("transactionId", sql`varchar(255)`) + .addUniqueConstraint("donations_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("donations_idx_church_donation_date") + .on("donations") + .columns(["churchId", "donationDate"]) + .execute(); + + await db.schema + .createIndex("donations_idx_church_person") + .on("donations") + .columns(["churchId", "personId"]) + .execute(); + + await db.schema + .createIndex("donations_idx_church_batch") + .on("donations") + .columns(["churchId", "batchId"]) + .execute(); + + await db.schema + .createIndex("donations_idx_church_method") + .on("donations") + .columns(["churchId", "method", "methodDetails"]) + .execute(); + + await db.schema + .createIndex("donations_idx_church_status") + .on("donations") + .columns(["churchId", "status"]) + .execute(); + + await db.schema + .createIndex("donations_idx_transaction") + .on("donations") + .column("transactionId") + .execute(); + + await db.schema + .createTable("fundDonations") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("donationId", sql`char(11)`) + .addColumn("fundId", sql`char(11)`) + .addColumn("amount", "double precision") + .addUniqueConstraint("fundDonations_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("fundDonations_idx_church_donation") + .on("fundDonations") + .columns(["churchId", "donationId"]) + .execute(); + + await db.schema + .createIndex("fundDonations_idx_church_fund") + .on("fundDonations") + .columns(["churchId", "fundId"]) + .execute(); + + await db.schema + .createTable("donationBatches") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("name", sql`varchar(50)`) + .addColumn("batchDate", "datetime") + .addUniqueConstraint("donationBatches_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("donationBatches_idx_church_id") + .on("donationBatches") + .column("churchId") + .execute(); + + await db.schema + .createTable("gateways") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("provider", sql`varchar(50)`) + .addColumn("publicKey", sql`varchar(255)`) + .addColumn("privateKey", sql`varchar(255)`) + .addColumn("webhookKey", sql`varchar(255)`) + .addColumn("productId", sql`varchar(255)`) + .addColumn("payFees", sql`bit(1)`) + .addColumn("currency", sql`varchar(10)`) + .addColumn("settings", "json") + .addColumn("environment", sql`varchar(50)`) + .addColumn("createdAt", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) + .addColumn("updatedAt", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`)) + .addUniqueConstraint("gateways_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("customers") + .ifNotExists() + .addColumn("id", sql`varchar(255)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("provider", sql`varchar(50)`) + .addColumn("metadata", "json") + .addUniqueConstraint("customers_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("gatewayPaymentMethods") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`, (col) => col.notNull()) + .addColumn("gatewayId", sql`char(11)`, (col) => col.notNull()) + .addColumn("customerId", sql`varchar(255)`, (col) => col.notNull()) + .addColumn("externalId", sql`varchar(255)`, (col) => col.notNull()) + .addColumn("methodType", sql`varchar(50)`) + .addColumn("displayName", sql`varchar(255)`) + .addColumn("metadata", "json") + .addColumn("createdAt", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) + .addColumn("updatedAt", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`)) + .addUniqueConstraint("ux_gateway_payment_methods_external", ["gatewayId", "externalId"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("idx_gateway_payment_methods_church") + .on("gatewayPaymentMethods") + .column("churchId") + .execute(); + + await db.schema + .createIndex("idx_gateway_payment_methods_customer") + .on("gatewayPaymentMethods") + .column("customerId") + .execute(); + + await db.schema + .createTable("eventLogs") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("customerId", sql`varchar(255)`) + .addColumn("provider", sql`varchar(50)`) + .addColumn("providerId", sql`varchar(255)`) + .addColumn("status", sql`varchar(50)`) + .addColumn("eventType", sql`varchar(50)`) + .addColumn("message", "text") + .addColumn("created", "datetime") + .addColumn("resolved", sql`tinyint(1)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("eventLogs_idx_church_status_created") + .on("eventLogs") + .columns(["churchId", "status", "created"]) + .execute(); + + await db.schema + .createIndex("eventLogs_idx_customer") + .on("eventLogs") + .column("customerId") + .execute(); + + await db.schema + .createIndex("eventLogs_idx_provider_id") + .on("eventLogs") + .column("providerId") + .execute(); + + await db.schema + .createTable("settings") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("keyName", sql`varchar(255)`) + .addColumn("value", sql`mediumtext`) + .addColumn("public", sql`bit(1)`) + .addUniqueConstraint("settings_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("settings_churchId") + .on("settings") + .column("churchId") + .execute(); + + await db.schema + .createTable("subscriptions") + .ifNotExists() + .addColumn("id", sql`varchar(255)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("customerId", sql`varchar(255)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("subscriptionFunds") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`varchar(11)`, (col) => col.notNull()) + .addColumn("subscriptionId", sql`varchar(255)`) + .addColumn("fundId", sql`char(11)`) + .addColumn("amount", "double precision") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("subscriptionFunds_idx_church_subscription") + .on("subscriptionFunds") + .columns(["churchId", "subscriptionId"]) + .execute(); + + await db.schema + .createIndex("subscriptionFunds_idx_church_fund") + .on("subscriptionFunds") + .columns(["churchId", "fundId"]) + .execute(); +} + +export async function down(db: Kysely): Promise { + const tables = [ + "subscriptionFunds", + "subscriptions", + "settings", + "eventLogs", + "gatewayPaymentMethods", + "customers", + "gateways", + "donationBatches", + "fundDonations", + "donations", + "funds", + ]; + + for (const table of tables) { + await db.schema.dropTable(table).ifExists().execute(); + } +} diff --git a/tools/migrations/kysely-config.ts b/tools/migrations/kysely-config.ts new file mode 100644 index 0000000..f3d50e9 --- /dev/null +++ b/tools/migrations/kysely-config.ts @@ -0,0 +1,80 @@ +import { Kysely, MysqlDialect } from "kysely"; +import mysql from "mysql2"; +import { Environment } from "../../src/shared/helpers/Environment.js"; + +export const MODULE_NAMES = [ + "membership", + "attendance", + "content", + "giving", + "messaging", + "doing", +] as const; + +export type ModuleName = (typeof MODULE_NAMES)[number]; + +let initialized = false; + +export async function ensureEnvironment() { + if (!initialized) { + const env = process.env.ENVIRONMENT || "dev"; + await Environment.init(env); + initialized = true; + } +} + +export function createKyselyForModule(moduleName: ModuleName): Kysely { + const dbConfig = Environment.getDatabaseConfig(moduleName); + if (!dbConfig) { + throw new Error( + `No database configuration found for module: ${moduleName}` + ); + } + + const dialect = new MysqlDialect({ + pool: mysql.createPool({ + host: dbConfig.host, + user: dbConfig.user, + password: dbConfig.password, + database: dbConfig.database, + port: dbConfig.port || 3306, + connectionLimit: dbConfig.connectionLimit || 5, + typeCast(field, next) { + if (field.type === "BIT" && field.length === 1) { + const bytes = field.buffer(); + return bytes ? bytes[0] === 1 : null; + } + return next(); + }, + }), + }); + + return new Kysely({ dialect }); +} + +export async function ensureDatabaseExists(moduleName: ModuleName) { + const dbConfig = Environment.getDatabaseConfig(moduleName); + if (!dbConfig) { + throw new Error( + `No database configuration found for module: ${moduleName}` + ); + } + + const connection = await mysql + .createPool({ + host: dbConfig.host, + user: dbConfig.user, + password: dbConfig.password, + port: dbConfig.port || 3306, + connectionLimit: 1, + }) + .promise(); + + try { + await connection.execute( + `CREATE DATABASE IF NOT EXISTS \`${dbConfig.database}\`` + ); + } finally { + await connection.end(); + } +} diff --git a/tools/migrations/membership/2026-02-06_initial_schema.ts b/tools/migrations/membership/2026-02-06_initial_schema.ts new file mode 100644 index 0000000..9a047ca --- /dev/null +++ b/tools/migrations/membership/2026-02-06_initial_schema.ts @@ -0,0 +1,672 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + // === Access === + + await db.schema + .createTable("accessLogs") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("userId", sql`char(11)`) + .addColumn("churchId", sql`char(11)`) + .addColumn("appName", sql`varchar(45)`) + .addColumn("loginTime", "datetime") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("churches") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("name", sql`varchar(255)`) + .addColumn("subDomain", sql`varchar(45)`) + .addColumn("registrationDate", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) + .addColumn("address1", sql`varchar(255)`) + .addColumn("address2", sql`varchar(255)`) + .addColumn("city", sql`varchar(255)`) + .addColumn("state", sql`varchar(45)`) + .addColumn("zip", sql`varchar(45)`) + .addColumn("country", sql`varchar(45)`) + .addColumn("archivedDate", "datetime") + .addColumn("latitude", sql`float`) + .addColumn("longitude", sql`float`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("domains") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("domainName", sql`varchar(255)`) + .addColumn("lastChecked", "datetime") + .addColumn("isStale", sql`tinyint(1)`, (col) => col.defaultTo(0)) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("roles") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("name", sql`varchar(255)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("roleMembers") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("roleId", sql`char(11)`) + .addColumn("userId", sql`char(11)`) + .addColumn("dateAdded", "datetime") + .addColumn("addedBy", sql`char(11)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("userId_INDEX") + .on("roleMembers") + .column("userId") + .execute(); + + await db.schema + .createIndex("roleMembers_userId_churchId") + .on("roleMembers") + .columns(["userId", "churchId"]) + .execute(); + + await db.schema + .createIndex("roleMembers_roleId_churchId") + .on("roleMembers") + .columns(["roleId", "churchId"]) + .execute(); + + await db.schema + .createTable("rolePermissions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("roleId", sql`char(11)`) + .addColumn("apiName", sql`varchar(45)`) + .addColumn("contentType", sql`varchar(45)`) + .addColumn("contentId", sql`char(11)`) + .addColumn("action", sql`varchar(45)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("rolePermissions_roleId_churchId_INDEX") + .on("rolePermissions") + .columns(["roleId", "churchId"]) + .execute(); + + await db.schema + .createTable("users") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("email", sql`varchar(191)`) + .addColumn("password", sql`varchar(255)`) + .addColumn("authGuid", sql`varchar(255)`) + .addColumn("displayName", sql`varchar(255)`) + .addColumn("registrationDate", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) + .addColumn("lastLogin", "datetime") + .addColumn("firstName", sql`varchar(45)`) + .addColumn("lastName", sql`varchar(45)`) + .addUniqueConstraint("id_UNIQUE", ["id"]) + .addUniqueConstraint("email_UNIQUE", ["email"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("authGuid_INDEX") + .on("users") + .column("authGuid") + .execute(); + + await db.schema + .createTable("userChurches") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("userId", sql`char(11)`) + .addColumn("churchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("lastAccessed", "datetime") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("userChurches_userId") + .on("userChurches") + .column("userId") + .execute(); + + await db.schema + .createIndex("userChurches_churchId") + .on("userChurches") + .column("churchId") + .execute(); + + // === Forms === + + await db.schema + .createTable("answers") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("formSubmissionId", sql`char(11)`) + .addColumn("questionId", sql`char(11)`) + .addColumn("value", sql`varchar(4000)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("answers_churchId") + .on("answers") + .column("churchId") + .execute(); + + await db.schema + .createIndex("answers_formSubmissionId") + .on("answers") + .column("formSubmissionId") + .execute(); + + await db.schema + .createIndex("answers_questionId") + .on("answers") + .column("questionId") + .execute(); + + await db.schema + .createTable("forms") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("name", sql`varchar(255)`) + .addColumn("contentType", sql`varchar(50)`) + .addColumn("createdTime", "datetime") + .addColumn("modifiedTime", "datetime") + .addColumn("accessStartTime", "datetime") + .addColumn("accessEndTime", "datetime") + .addColumn("restricted", sql`bit(1)`) + .addColumn("archived", sql`bit(1)`) + .addColumn("removed", sql`bit(1)`) + .addColumn("thankYouMessage", "text") + .addUniqueConstraint("forms_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("forms_churchId") + .on("forms") + .column("churchId") + .execute(); + + await db.schema + .createIndex("forms_churchId_removed_archived") + .on("forms") + .columns(["churchId", "removed", "archived"]) + .execute(); + + await db.schema + .createIndex("forms_churchId_id") + .on("forms") + .columns(["churchId", "id"]) + .execute(); + + await db.schema + .createTable("formSubmissions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("formId", sql`char(11)`) + .addColumn("contentType", sql`varchar(50)`) + .addColumn("contentId", sql`char(11)`) + .addColumn("submissionDate", "datetime") + .addColumn("submittedBy", sql`char(11)`) + .addColumn("revisionDate", "datetime") + .addColumn("revisedBy", sql`char(11)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("formSubmissions_churchId") + .on("formSubmissions") + .column("churchId") + .execute(); + + await db.schema + .createIndex("formSubmissions_formId") + .on("formSubmissions") + .column("formId") + .execute(); + + await db.schema + .createTable("questions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("formId", sql`char(11)`) + .addColumn("parentId", sql`char(11)`) + .addColumn("title", sql`varchar(255)`) + .addColumn("description", sql`varchar(255)`) + .addColumn("fieldType", sql`varchar(50)`) + .addColumn("placeholder", sql`varchar(50)`) + .addColumn("sort", sql`int(11)`) + .addColumn("choices", "text") + .addColumn("removed", sql`bit(1)`) + .addColumn("required", sql`bit(1)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("questions_churchId") + .on("questions") + .column("churchId") + .execute(); + + await db.schema + .createIndex("questions_formId") + .on("questions") + .column("formId") + .execute(); + + // === People === + + await db.schema + .createTable("households") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("name", sql`varchar(50)`) + .addUniqueConstraint("households_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("households_churchId") + .on("households") + .column("churchId") + .execute(); + + await db.schema + .createTable("people") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("userId", sql`char(11)`) + .addColumn("displayName", sql`varchar(100)`) + .addColumn("firstName", sql`varchar(50)`) + .addColumn("middleName", sql`varchar(50)`) + .addColumn("lastName", sql`varchar(50)`) + .addColumn("nickName", sql`varchar(50)`) + .addColumn("prefix", sql`varchar(10)`) + .addColumn("suffix", sql`varchar(10)`) + .addColumn("birthDate", "datetime") + .addColumn("gender", sql`varchar(11)`) + .addColumn("maritalStatus", sql`varchar(10)`) + .addColumn("anniversary", "datetime") + .addColumn("membershipStatus", sql`varchar(50)`) + .addColumn("homePhone", sql`varchar(21)`) + .addColumn("mobilePhone", sql`varchar(21)`) + .addColumn("workPhone", sql`varchar(21)`) + .addColumn("email", sql`varchar(100)`) + .addColumn("address1", sql`varchar(50)`) + .addColumn("address2", sql`varchar(50)`) + .addColumn("city", sql`varchar(30)`) + .addColumn("state", sql`varchar(10)`) + .addColumn("zip", sql`varchar(10)`) + .addColumn("photoUpdated", "datetime") + .addColumn("householdId", sql`char(11)`) + .addColumn("householdRole", sql`varchar(10)`) + .addColumn("removed", sql`bit(1)`) + .addColumn("conversationId", sql`char(11)`) + .addColumn("optedOut", sql`bit(1)`) + .addColumn("nametagNotes", sql`varchar(20)`) + .addColumn("donorNumber", sql`varchar(20)`) + .addUniqueConstraint("people_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("people_churchId") + .on("people") + .column("churchId") + .execute(); + + await db.schema + .createIndex("people_userId") + .on("people") + .column("userId") + .execute(); + + await db.schema + .createIndex("people_householdId") + .on("people") + .column("householdId") + .execute(); + + await db.schema + .createIndex("people_id_INDEX") + .on("people") + .column("id") + .execute(); + + await db.schema + .createTable("memberPermissions") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("memberId", sql`char(11)`) + .addColumn("contentType", sql`varchar(45)`) + .addColumn("contentId", sql`char(11)`) + .addColumn("action", sql`varchar(45)`) + .addColumn("emailNotification", sql`bit(1)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("memberPermissions_churchId_contentId_memberId") + .on("memberPermissions") + .columns(["churchId", "contentId", "memberId"]) + .execute(); + + await db.schema + .createTable("notes") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("contentType", sql`varchar(50)`) + .addColumn("contentId", sql`char(11)`) + .addColumn("noteType", sql`varchar(50)`) + .addColumn("addedBy", sql`char(11)`) + .addColumn("createdAt", "datetime") + .addColumn("contents", "text") + .addColumn("updatedAt", "datetime") + .addUniqueConstraint("notes_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("notes_churchId") + .on("notes") + .column("churchId") + .execute(); + + await db.schema + .createTable("visibilityPreferences") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("address", sql`varchar(50)`) + .addColumn("phoneNumber", sql`varchar(50)`) + .addColumn("email", sql`varchar(50)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + // === Groups === + + await db.schema + .createTable("groups") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("categoryName", sql`varchar(50)`) + .addColumn("name", sql`varchar(50)`) + .addColumn("trackAttendance", sql`bit(1)`) + .addColumn("parentPickup", sql`bit(1)`) + .addColumn("printNametag", sql`bit(1)`) + .addColumn("about", "text") + .addColumn("photoUrl", sql`varchar(255)`) + .addColumn("removed", sql`bit(1)`) + .addColumn("tags", sql`varchar(45)`) + .addColumn("meetingTime", sql`varchar(45)`) + .addColumn("meetingLocation", sql`varchar(45)`) + .addColumn("labels", sql`varchar(500)`) + .addColumn("slug", sql`varchar(45)`) + .addUniqueConstraint("groups_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("groups_churchId") + .on("groups") + .column("churchId") + .execute(); + + await db.schema + .createIndex("groups_churchId_removed_tags") + .on("groups") + .columns(["churchId", "removed", "tags"]) + .execute(); + + await db.schema + .createIndex("groups_churchId_removed_labels") + .on("groups") + .columns(["churchId", "removed", "labels"]) + .execute(); + + await db.schema + .createTable("groupMembers") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("groupId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("joinDate", "datetime") + .addColumn("leader", sql`bit(1)`) + .addUniqueConstraint("groupMembers_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("groupMembers_churchId") + .on("groupMembers") + .column("churchId") + .execute(); + + await db.schema + .createIndex("groupMembers_groupId") + .on("groupMembers") + .column("groupId") + .execute(); + + await db.schema + .createIndex("groupMembers_personId") + .on("groupMembers") + .column("personId") + .execute(); + + await db.schema + .createIndex("groupMembers_churchId_groupId_personId") + .on("groupMembers") + .columns(["churchId", "groupId", "personId"]) + .execute(); + + await db.schema + .createIndex("groupMembers_personId_churchId") + .on("groupMembers") + .columns(["personId", "churchId"]) + .execute(); + + // === OAuth === + + await db.schema + .createTable("oAuthClients") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("name", sql`varchar(45)`) + .addColumn("clientId", sql`varchar(45)`) + .addColumn("clientSecret", sql`varchar(45)`) + .addColumn("redirectUris", sql`varchar(255)`) + .addColumn("scopes", sql`varchar(255)`) + .addColumn("createdAt", "datetime") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("oAuthCodes") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("userChurchId", sql`char(11)`) + .addColumn("clientId", sql`char(11)`) + .addColumn("code", sql`varchar(45)`) + .addColumn("redirectUri", sql`varchar(255)`) + .addColumn("scopes", sql`varchar(255)`) + .addColumn("expiresAt", "datetime") + .addColumn("createdAt", "datetime") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("oAuthDeviceCodes") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("deviceCode", sql`varchar(64)`, (col) => col.notNull()) + .addColumn("userCode", sql`varchar(16)`, (col) => col.notNull()) + .addColumn("clientId", sql`varchar(45)`, (col) => col.notNull()) + .addColumn("scopes", sql`varchar(255)`) + .addColumn("expiresAt", "datetime", (col) => col.notNull()) + .addColumn("pollInterval", "integer", (col) => col.defaultTo(5)) + .addColumn("status", sql`enum('pending','approved','denied','expired')`, (col) => col.defaultTo("pending")) + .addColumn("approvedByUserId", sql`char(11)`) + .addColumn("userChurchId", sql`char(11)`) + .addColumn("churchId", sql`char(11)`) + .addColumn("createdAt", "datetime", (col) => col.defaultTo(sql`CURRENT_TIMESTAMP`)) + .addUniqueConstraint("oAuthDeviceCodes_deviceCode", ["deviceCode"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("oAuthDeviceCodes_userCode_status") + .on("oAuthDeviceCodes") + .columns(["userCode", "status"]) + .execute(); + + await db.schema + .createIndex("oAuthDeviceCodes_status_expiresAt") + .on("oAuthDeviceCodes") + .columns(["status", "expiresAt"]) + .execute(); + + await db.schema + .createTable("oAuthTokens") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("clientId", sql`char(11)`) + .addColumn("userChurchId", sql`char(11)`) + .addColumn("accessToken", sql`varchar(1000)`) + .addColumn("refreshToken", sql`varchar(45)`) + .addColumn("scopes", sql`varchar(45)`) + .addColumn("expiresAt", "datetime") + .addColumn("createdAt", "datetime") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + // === Misc === + + await db.schema + .createTable("clientErrors") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("application", sql`varchar(45)`) + .addColumn("errorTime", "datetime") + .addColumn("userId", sql`char(11)`) + .addColumn("churchId", sql`char(11)`) + .addColumn("originUrl", sql`varchar(255)`) + .addColumn("errorType", sql`varchar(45)`) + .addColumn("message", sql`varchar(255)`) + .addColumn("details", "text") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("settings") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("userId", sql`char(11)`) + .addColumn("keyName", sql`varchar(255)`) + .addColumn("value", sql`mediumtext`) + .addColumn("public", sql`bit(1)`) + .addUniqueConstraint("settings_id_UNIQUE", ["id"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("settings_churchId") + .on("settings") + .column("churchId") + .execute(); + + await db.schema + .createTable("usageTrends") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("year", sql`int(11)`) + .addColumn("week", sql`int(11)`) + .addColumn("b1Users", sql`int(11)`) + .addColumn("b1Churches", sql`int(11)`) + .addColumn("b1Devices", sql`int(11)`) + .addColumn("chumsUsers", sql`int(11)`) + .addColumn("chumsChurches", sql`int(11)`) + .addColumn("lessonsUsers", sql`int(11)`) + .addColumn("lessonsChurches", sql`int(11)`) + .addColumn("lessonsDevices", sql`int(11)`) + .addColumn("freeShowDevices", sql`int(11)`) + .addUniqueConstraint("usageTrends_year_week", ["year", "week"]) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + // === Seed: Server Admins role + permissions === + + await sql`INSERT IGNORE INTO roles (id, churchId, name) VALUES ('r1', 0, 'Server Admins')`.execute(db); + await sql`INSERT IGNORE INTO rolePermissions (id, churchId, roleId, apiName, contentType, action) SELECT 'rp1', 0, id, 'MembershipApi', 'Server', 'Admin' FROM roles WHERE name='Server Admins'`.execute(db); + await sql`INSERT IGNORE INTO rolePermissions (id, churchId, roleId, apiName, contentType, action) SELECT 'rp2', 0, id, 'MembershipApi', 'Roles', 'Edit' FROM roles WHERE name='Server Admins'`.execute(db); + await sql`INSERT IGNORE INTO rolePermissions (id, churchId, roleId, apiName, contentType, action) SELECT 'rp3', 0, id, 'MembershipApi', 'Roles', 'View' FROM roles WHERE name='Server Admins'`.execute(db); + await sql`INSERT IGNORE INTO rolePermissions (id, churchId, roleId, apiName, contentType, action) SELECT 'rp4', 0, id, 'MembershipApi', 'RoleMembers', 'Edit' FROM roles WHERE name='Server Admins'`.execute(db); + await sql`INSERT IGNORE INTO rolePermissions (id, churchId, roleId, apiName, contentType, action) SELECT 'rp5', 0, id, 'MembershipApi', 'RoleMembers', 'View' FROM roles WHERE name='Server Admins'`.execute(db); + await sql`INSERT IGNORE INTO rolePermissions (id, churchId, roleId, apiName, contentType, action) SELECT 'rp6', 0, id, 'MembershipApi', 'RolePermissions', 'Edit' FROM roles WHERE name='Server Admins'`.execute(db); + await sql`INSERT IGNORE INTO rolePermissions (id, churchId, roleId, apiName, contentType, action) SELECT 'rp7', 0, id, 'MembershipApi', 'RolePermissions', 'View' FROM roles WHERE name='Server Admins'`.execute(db); + await sql`INSERT IGNORE INTO rolePermissions (id, churchId, roleId, apiName, contentType, action) SELECT 'rp8', 0, id, 'MembershipApi', 'Users', 'Edit' FROM roles WHERE name='Server Admins'`.execute(db); + await sql`INSERT IGNORE INTO rolePermissions (id, churchId, roleId, apiName, contentType, action) SELECT 'rp9', 0, id, 'MembershipApi', 'Users', 'View' FROM roles WHERE name='Server Admins'`.execute(db); +} + +export async function down(db: Kysely): Promise { + // Drop in reverse dependency order + const tables = [ + "usageTrends", + "settings", + "clientErrors", + "oAuthTokens", + "oAuthDeviceCodes", + "oAuthCodes", + "oAuthClients", + "groupMembers", + "groups", + "visibilityPreferences", + "notes", + "memberPermissions", + "people", + "households", + "questions", + "formSubmissions", + "forms", + "answers", + "userChurches", + "users", + "rolePermissions", + "roleMembers", + "roles", + "domains", + "churches", + "accessLogs", + ]; + + for (const table of tables) { + await db.schema.dropTable(table).ifExists().execute(); + } +} diff --git a/tools/migrations/messaging/2026-02-06_initial_schema.ts b/tools/migrations/messaging/2026-02-06_initial_schema.ts new file mode 100644 index 0000000..32e1a85 --- /dev/null +++ b/tools/migrations/messaging/2026-02-06_initial_schema.ts @@ -0,0 +1,318 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable("connections") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("conversationId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("displayName", sql`varchar(45)`) + .addColumn("timeJoined", "datetime") + .addColumn("socketId", sql`varchar(45)`) + .addColumn("ipAddress", sql`varchar(45)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("connections_ix_churchId") + .on("connections") + .columns(["churchId", "conversationId"]) + .execute(); + + await db.schema + .createTable("conversations") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("contentType", sql`varchar(45)`) + .addColumn("contentId", sql`varchar(255)`) + .addColumn("title", sql`varchar(255)`) + .addColumn("dateCreated", "datetime") + .addColumn("groupId", sql`char(11)`) + .addColumn("visibility", sql`varchar(45)`) + .addColumn("firstPostId", sql`char(11)`) + .addColumn("lastPostId", sql`char(11)`) + .addColumn("postCount", sql`int(11)`) + .addColumn("allowAnonymousPosts", sql`bit(1)`) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createIndex("conversations_ix_churchId") + .on("conversations") + .columns(["churchId", "contentType", "contentId"]) + .execute(); + + await db.schema + .createTable("devices") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("appName", sql`varchar(20)`) + .addColumn("deviceId", sql`varchar(45)`) + .addColumn("churchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("fcmToken", sql`varchar(255)`) + .addColumn("label", sql`varchar(45)`) + .addColumn("registrationDate", "datetime") + .addColumn("lastActiveDate", "datetime") + .addColumn("deviceInfo", "text") + .addColumn("admId", sql`varchar(255)`) + .addColumn("pairingCode", sql`varchar(45)`) + .addColumn("ipAddress", sql`varchar(45)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("devices_appName_deviceId") + .on("devices") + .columns(["appName", "deviceId"]) + .execute(); + + await db.schema + .createIndex("devices_personId_lastActiveDate") + .on("devices") + .columns(["personId", "lastActiveDate"]) + .execute(); + + await db.schema + .createIndex("devices_fcmToken") + .on("devices") + .column("fcmToken") + .execute(); + + await db.schema + .createIndex("devices_pairingCode") + .on("devices") + .column("pairingCode") + .execute(); + + await db.schema + .createTable("deviceContents") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("deviceId", sql`char(11)`) + .addColumn("contentType", sql`varchar(45)`) + .addColumn("contentId", sql`char(11)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("messages") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("conversationId", sql`char(11)`) + .addColumn("displayName", sql`varchar(45)`) + .addColumn("timeSent", "datetime") + .addColumn("messageType", sql`varchar(45)`) + .addColumn("content", "text") + .addColumn("personId", sql`char(11)`) + .addColumn("timeUpdated", "datetime") + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createIndex("messages_ix_churchId") + .on("messages") + .columns(["churchId", "conversationId"]) + .execute(); + + await db.schema + .createIndex("messages_ix_timeSent") + .on("messages") + .column("timeSent") + .execute(); + + await db.schema + .createIndex("messages_ix_personId") + .on("messages") + .column("personId") + .execute(); + + await db.schema + .createTable("notifications") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("contentType", sql`varchar(45)`) + .addColumn("contentId", sql`char(11)`) + .addColumn("timeSent", "datetime") + .addColumn("isNew", sql`bit(1)`) + .addColumn("message", "text") + .addColumn("link", sql`varchar(100)`) + .addColumn("deliveryMethod", sql`varchar(10)`) + .addColumn("triggeredByPersonId", sql`char(11)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("notifications_churchId_personId_timeSent") + .on("notifications") + .columns(["churchId", "personId", "timeSent"]) + .execute(); + + await db.schema + .createIndex("notifications_isNew") + .on("notifications") + .column("isNew") + .execute(); + + await db.schema + .createTable("notificationPreferences") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("allowPush", sql`bit(1)`) + .addColumn("emailFrequency", sql`varchar(10)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("privateMessages") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("fromPersonId", sql`char(11)`) + .addColumn("toPersonId", sql`char(11)`) + .addColumn("conversationId", sql`char(11)`) + .addColumn("notifyPersonId", sql`char(11)`) + .addColumn("deliveryMethod", sql`varchar(10)`) + .modifyEnd(sql`ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`) + .execute(); + + await db.schema + .createIndex("privateMessages_IX_churchFrom") + .on("privateMessages") + .columns(["churchId", "fromPersonId"]) + .execute(); + + await db.schema + .createIndex("privateMessages_IX_churchTo") + .on("privateMessages") + .columns(["churchId", "toPersonId"]) + .execute(); + + await db.schema + .createIndex("privateMessages_IX_notifyPersonId") + .on("privateMessages") + .columns(["churchId", "notifyPersonId"]) + .execute(); + + await db.schema + .createIndex("privateMessages_IX_conversationId") + .on("privateMessages") + .column("conversationId") + .execute(); + + await db.schema + .createTable("blockedIps") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("conversationId", sql`char(11)`) + .addColumn("serviceId", sql`char(11)`) + .addColumn("ipAddress", sql`varchar(45)`) + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createTable("deliveryLogs") + .ifNotExists() + .addColumn("id", sql`char(11)`, (col) => col.notNull().primaryKey()) + .addColumn("churchId", sql`char(11)`) + .addColumn("personId", sql`char(11)`) + .addColumn("contentType", sql`varchar(20)`) + .addColumn("contentId", sql`char(11)`) + .addColumn("deliveryMethod", sql`varchar(10)`) + .addColumn("success", sql`bit(1)`) + .addColumn("errorMessage", sql`varchar(500)`) + .addColumn("deliveryAddress", sql`varchar(255)`) + .addColumn("attemptTime", "datetime") + .modifyEnd(sql`ENGINE=InnoDB`) + .execute(); + + await db.schema + .createIndex("deliveryLogs_ix_content") + .on("deliveryLogs") + .columns(["contentType", "contentId"]) + .execute(); + + await db.schema + .createIndex("deliveryLogs_ix_personId") + .on("deliveryLogs") + .columns(["personId", "attemptTime"]) + .execute(); + + await db.schema + .createIndex("deliveryLogs_ix_churchId_time") + .on("deliveryLogs") + .columns(["churchId", "attemptTime"]) + .execute(); + + // === Stored Procedures === + + await sql`DROP PROCEDURE IF EXISTS \`cleanup\``.execute(db); + await sql` + CREATE PROCEDURE \`cleanup\`() + BEGIN + DELETE FROM conversations WHERE allowAnonymousPosts=1 AND dateCreated): Promise { + // Drop stored procedures first + await sql`DROP PROCEDURE IF EXISTS \`deleteForChurch\``.execute(db); + await sql`DROP PROCEDURE IF EXISTS \`updateConversationStats\``.execute(db); + await sql`DROP PROCEDURE IF EXISTS \`cleanup\``.execute(db); + + const tables = [ + "deliveryLogs", + "blockedIps", + "privateMessages", + "notificationPreferences", + "notifications", + "messages", + "deviceContents", + "devices", + "conversations", + "connections", + ]; + + for (const table of tables) { + await db.schema.dropTable(table).ifExists().execute(); + } +} diff --git a/tools/seed.ts b/tools/seed.ts new file mode 100644 index 0000000..ee27280 --- /dev/null +++ b/tools/seed.ts @@ -0,0 +1,123 @@ +import * as fs from "fs"; +import * as path from "path"; +import { fileURLToPath } from "url"; +import { + MODULE_NAMES, + type ModuleName, + ensureEnvironment, + createKyselyForModule, +} from "./migrations/kysely-config.js"; +import { sql } from "kysely"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +interface SeedOptions { + module?: ModuleName; + reset: boolean; +} + +function parseArguments(): SeedOptions { + const args = process.argv.slice(2); + const options: SeedOptions = { reset: false }; + + for (const arg of args) { + if (arg.startsWith("--module=")) { + const mod = arg.split("=")[1] as ModuleName; + if (!MODULE_NAMES.includes(mod)) { + console.error(`Invalid module: ${mod}`); + console.error(`Valid modules: ${MODULE_NAMES.join(", ")}`); + process.exit(1); + } + options.module = mod; + } else if (arg === "--reset") { + options.reset = true; + } + } + + return options; +} + +function splitSqlStatements(sqlContent: string): string[] { + const statements: string[] = []; + const lines = sqlContent.split("\n"); + let current = ""; + + for (const line of lines) { + if (line.trim() === "" || line.trim().startsWith("--")) continue; + + current += line + "\n"; + if (line.trim().endsWith(";")) { + if (current.trim()) { + statements.push(current.trim()); + current = ""; + } + } + } + + if (current.trim()) { + statements.push(current.trim()); + } + + return statements.filter((s) => s.length > 0); +} + +async function seedModule(moduleName: ModuleName, reset: boolean) { + const demoPath = path.join(__dirname, "dbScripts", moduleName, "demo.sql"); + + if (!fs.existsSync(demoPath)) { + console.log(` [${moduleName}] No demo.sql found, skipping...`); + return; + } + + const db = createKyselyForModule(moduleName); + + try { + if (reset) { + console.log(` [${moduleName}] Reset requested — demo data will overwrite via INSERT IGNORE / REPLACE`); + } + + console.log(` [${moduleName}] Loading demo data from demo.sql...`); + const sqlContent = fs.readFileSync(demoPath, "utf8"); + + if (sqlContent.trim().length < 50 || sqlContent.includes("-- This file will be populated")) { + console.log(` [${moduleName}] demo.sql is a placeholder, skipping...`); + return; + } + + const statements = splitSqlStatements(sqlContent); + let executed = 0; + + for (const stmt of statements) { + await sql.raw(stmt).execute(db); + executed++; + } + + console.log(` [${moduleName}] Executed ${executed} statements.`); + } finally { + await db.destroy(); + } +} + +async function main() { + const options = parseArguments(); + + try { + await ensureEnvironment(); + + const modules = options.module ? [options.module] : [...MODULE_NAMES]; + + console.log("Seeding demo data...\n"); + + for (const moduleName of modules) { + await seedModule(moduleName, options.reset); + } + + console.log("\nDone."); + } catch (error) { + console.error("Seed failed:", error); + process.exit(1); + } +} + +main();