diff --git a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js index a74b0dfd40b..a4599cf262e 100644 --- a/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comment-reports-importer.js @@ -49,9 +49,7 @@ class CommentReportsImporter extends TableImporter { return null; } - const commentCreatedAt = new Date(this.model.created_at); - const now = new Date(); - const reportTime = faker.date.between(commentCreatedAt, now); + const reportTime = dateToDatabaseString.randomBetween(this.model.created_at, new Date()); const reporter = this.possibleReporters[faker.datatype.number(this.possibleReporters.length - 1)]; diff --git a/ghost/core/core/server/data/seeders/importers/comments-importer.js b/ghost/core/core/server/data/seeders/importers/comments-importer.js index 5a47a98d9b1..1054e813bcd 100644 --- a/ghost/core/core/server/data/seeders/importers/comments-importer.js +++ b/ghost/core/core/server/data/seeders/importers/comments-importer.js @@ -24,6 +24,7 @@ class CommentsImporter extends TableImporter { setReferencedModel(model) { this.model = model; + const publishedAt = dateToDatabaseString.parse(model.published_at); this.commentIds = []; // Store [id, parent_id, timestamp] tuples for reply-to-reply @@ -32,11 +33,11 @@ class CommentsImporter extends TableImporter { trend: 'negative', // Use commentsPerPost as a baseline with some variance (+/- 20%) total: Math.round(this.commentsPerPost * faker.datatype.float({min: 0.8, max: 1.2})), - startTime: new Date(model.published_at), + startTime: publishedAt, endTime: new Date() }).sort((a, b) => a.getTime() - b.getTime()); // Sort chronologically so replies always come after their targets - this.possibleMembers = this.members.filter(member => new Date(member.created_at) < new Date(model.published_at)); + this.possibleMembers = this.members.filter(member => dateToDatabaseString.parse(member.created_at) < publishedAt); } generate() { diff --git a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js index 8a84c352e98..cfc29dbfac3 100644 --- a/ghost/core/core/server/data/seeders/importers/email-batches-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-batches-importer.js @@ -20,8 +20,8 @@ class EmailBatchesImporter extends TableImporter { } generate() { - const emailSentDate = new Date(this.model.created_at); - const latestUpdatedDate = new Date(this.model.created_at); + const emailSentDate = dateToDatabaseString.parse(this.model.created_at); + const latestUpdatedDate = dateToDatabaseString.parse(this.model.created_at); latestUpdatedDate.setHours(latestUpdatedDate.getHours() + 1); return { @@ -30,7 +30,7 @@ class EmailBatchesImporter extends TableImporter { provider_id: `${new Date().toISOString().split('.')[0].replace(/[^0-9]/g, '')}.${faker.datatype.hexadecimal({length: 16, prefix: '', case: 'lower'})}@m.example.com`, status: 'submitted', // TODO: introduce failures created_at: this.model.created_at, - updated_at: dateToDatabaseString(faker.date.between(emailSentDate, latestUpdatedDate)) + updated_at: dateToDatabaseString(dateToDatabaseString.randomBetween(emailSentDate, latestUpdatedDate)) }; } } diff --git a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js index 7f36a49c973..88020253b55 100644 --- a/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js +++ b/ghost/core/core/server/data/seeders/importers/email-recipients-importer.js @@ -111,7 +111,7 @@ class EmailRecipientsImporter extends TableImporter { if (!(memberSubscribeEvent.created_at instanceof Date)) { // SQLite fix - memberSubscribeEvent.created_at = new Date(memberSubscribeEvent.created_at); + memberSubscribeEvent.created_at = dateToDatabaseString.parse(memberSubscribeEvent.created_at); } this.membersSubscribeEventsCreatedAtsByNewsletterId.get(memberSubscribeEvent.newsletter_id).push(memberSubscribeEvent.created_at.getTime()); } @@ -125,8 +125,8 @@ class EmailRecipientsImporter extends TableImporter { this.batchIndex = this.batch.index; // Shallow clone members list so we can shuffle and modify it - const earliestOpenTime = new Date(this.batch.updated_at); - const latestOpenTime = new Date(this.batch.updated_at); + const earliestOpenTime = dateToDatabaseString.parse(this.batch.updated_at); + const latestOpenTime = dateToDatabaseString.parse(this.batch.updated_at); latestOpenTime.setDate(latestOpenTime.getDate() + 14); // Get all members that were subscribed to this newsletter BEFORE the batch was sent @@ -164,7 +164,7 @@ class EmailRecipientsImporter extends TableImporter { } // The events are generated for a different time, so we need to move them to the batch time - timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + new Date(this.batch.updated_at).getTime()); + timestamp = new Date(timestamp.getTime() - this.eventStartTimeUsed.getTime() + dateToDatabaseString.parse(this.batch.updated_at).getTime()); if (timestamp > new Date()) { timestamp = new Date(); @@ -187,9 +187,9 @@ class EmailRecipientsImporter extends TableImporter { let deliveredTime; if (status === emailStatus.opened) { - const startDate = this.batch.updated_at; + const startDate = dateToDatabaseString.parse(this.batch.updated_at); const endDate = timestamp; - deliveredTime = faker.date.between(startDate, endDate); + deliveredTime = dateToDatabaseString.randomBetween(startDate, endDate); } return { diff --git a/ghost/core/core/server/data/seeders/importers/emails-importer.js b/ghost/core/core/server/data/seeders/importers/emails-importer.js index e795ba3f78f..b07055cbbe4 100644 --- a/ghost/core/core/server/data/seeders/importers/emails-importer.js +++ b/ghost/core/core/server/data/seeders/importers/emails-importer.js @@ -42,19 +42,20 @@ class EmailsImporter extends TableImporter { : this.newsletters[1]; } + const publishedAt = dateToDatabaseString.parse(this.model.published_at); const timestamp = luck(60) - ? new Date(this.model.published_at) + ? publishedAt : generateEvents({ shape: 'ease-out', trend: 'negative', total: 1, - startTime: new Date(this.model.published_at), + startTime: publishedAt, endTime: new Date() })[0]; const recipientCount = this.membersSubscribeEvents .filter(entry => entry.newsletter_id === newsletter.id) - .filter(entry => new Date(entry.created_at) < timestamp).length; + .filter(entry => dateToDatabaseString.parse(entry.created_at) < timestamp).length; const deliveredCount = Math.ceil(recipientCount * faker.datatype.float({ max: 1, min: 0.9, diff --git a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js index cc29c3d4fff..e96ff922538 100644 --- a/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-click-events-importer.js @@ -50,7 +50,7 @@ class MembersClickEventsImporter extends TableImporter { } this.amount -= 1; - const openedAt = new Date(this.model.opened_at); + const openedAt = dateToDatabaseString.parse(this.model.opened_at); const laterOn = new Date(openedAt.getTime() + 1000 * 60 * 15); const clickTime = faker.date.between(openedAt.getTime(), laterOn.getTime()); //added getTime here because it threw random errors diff --git a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js index 5eb3b503c26..d6980e1e04a 100644 --- a/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-created-events-importer.js @@ -47,7 +47,8 @@ class MembersCreatedEventsImporter extends TableImporter { }; if (source === 'member' && luck(30)) { - const post = this.posts.find(p => p.visibility === 'public' && new Date(p.published_at) < new Date(this.model.created_at)); + const memberCreatedAt = dateToDatabaseString.parse(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && dateToDatabaseString.parse(p.published_at) < memberCreatedAt); if (post) { attribution = { attribution_id: post.id, diff --git a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js index 2a75ab2e129..82f0f86f22c 100644 --- a/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-feedback-importer.js @@ -1,5 +1,4 @@ const TableImporter = require('./table-importer'); -const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); const dateToDatabaseString = require('../utils/database-date'); @@ -25,10 +24,10 @@ class MembersFeedbackImporter extends TableImporter { return null; } - const openedAt = new Date(this.model.opened_at); - const laterOn = new Date(this.model.opened_at); + const openedAt = dateToDatabaseString.parse(this.model.opened_at); + const laterOn = dateToDatabaseString.parse(this.model.opened_at); laterOn.setMinutes(laterOn.getMinutes() + 60); - const feedbackTime = faker.date.between(openedAt, laterOn); + const feedbackTime = dateToDatabaseString.randomBetween(openedAt, laterOn); const postId = this.emails.find(email => email.id === this.model.email_id).post_id; return { diff --git a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js index 7882761465b..ea3f0d888b7 100644 --- a/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-login-events-importer.js @@ -35,9 +35,10 @@ class MembersLoginEventsImporter extends TableImporter { setReferencedModel(model) { this.model = model; + const memberCreatedAt = dateToDatabaseString.parse(model.created_at); const endDate = new Date(); - const daysBetween = Math.ceil((endDate.valueOf() - new Date(model.created_at).valueOf()) / (1000 * 60 * 60 * 24)); + const daysBetween = Math.ceil((endDate.valueOf() - memberCreatedAt.valueOf()) / (1000 * 60 * 60 * 24)); // Assuming most people either subscribe and lose interest, or maintain steady readership const shape = luck(40) ? 'ease-out' : 'flat'; @@ -47,7 +48,7 @@ class MembersLoginEventsImporter extends TableImporter { // Steady readers login more, readers who lose interest read less overall. // ceil because members will all have logged in at least once total: Math.min(5, shape === 'flat' ? Math.ceil(daysBetween / 3) : Math.ceil(daysBetween / 7)), - startTime: new Date(model.created_at), + startTime: memberCreatedAt, endTime: endDate }); } diff --git a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js index 4b0aa3c8506..58eeccee29e 100644 --- a/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-status-events-importer.js @@ -1,5 +1,4 @@ const TableImporter = require('./table-importer'); -const {faker} = require('@faker-js/faker'); const dateToDatabaseString = require('../utils/database-date'); class MembersStatusEventsImporter extends TableImporter { @@ -41,7 +40,7 @@ class MembersStatusEventsImporter extends TableImporter { member_id: model.id, from_status: 'free', to_status: model.status, - created_at: dateToDatabaseString(faker.date.between(new Date(model.created_at), new Date())) + created_at: dateToDatabaseString(dateToDatabaseString.randomBetween(model.created_at, new Date())) }); } } diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js index 66671083f17..21e048c1376 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-importer.js @@ -1,5 +1,6 @@ const {faker} = require('@faker-js/faker'); const TableImporter = require('./table-importer'); +const dateToDatabaseString = require('../utils/database-date'); class MembersStripeCustomersImporter extends TableImporter { static table = 'members_stripe_customers'; @@ -35,7 +36,7 @@ class MembersStripeCustomersImporter extends TableImporter { // Only 30% of free members should have a stripe customer = have had a subscription in the past or tried to subscribe // The number should increase the older the member is - const daysSinceMemberCreated = Math.floor((new Date() - new Date(this.model.created_at)) / (1000 * 60 * 60 * 24)); + const daysSinceMemberCreated = Math.floor((new Date() - dateToDatabaseString.parse(this.model.created_at)) / (1000 * 60 * 60 * 24)); const shouldHaveStripeCustomer = faker.datatype.number({min: 0, max: 100}) < Math.max(Math.min(daysSinceMemberCreated / 60, 15), 2); if (!shouldHaveStripeCustomer) { diff --git a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js index 78034d97b2c..eb4945adcce 100644 --- a/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-stripe-customers-subscriptions-importer.js @@ -30,6 +30,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { this.members = await this.transaction.select('id', 'status', 'created_at').from('members').whereIn('id', membersStripeCustomers.map(m => m.member_id)); if (this.members.length === 0) { + offset += limit; continue; } @@ -95,6 +96,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { (isMonthly ? price.interval === 'month' : price.interval === 'year'); }); const mrr = createValid ? (isMonthly ? stripePrice.amount : Math.floor(stripePrice.amount / 12)) : 0; + const memberCreatedAt = dateToDatabaseString.parse(member.created_at); const referenceEndDate = this.lastSubscriptionStart ?? new Date(); @@ -106,7 +108,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { } } - if (referenceEndDate < member.created_at) { + if (referenceEndDate < memberCreatedAt) { // Not possible to create an invalid subscription here return; } @@ -114,7 +116,7 @@ class MembersStripeCustomersSubscriptionsImporter extends TableImporter { const [startDate] = generateEvents({ total: 1, trend: 'negative', - startTime: new Date(member.created_at), + startTime: memberCreatedAt, endTime: referenceEndDate, shape: 'ease-out' }); diff --git a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js index a6e8c4edbb0..5c8c3a93ea3 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscribe-events-importer.js @@ -1,5 +1,4 @@ const TableImporter = require('./table-importer'); -const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); const dateToDatabaseString = require('../utils/database-date'); @@ -52,7 +51,7 @@ class MembersSubscribeEventsImporter extends TableImporter { return null; } - const createdAt = dateToDatabaseString(faker.date.between(new Date(this.model.created_at), new Date())); + const createdAt = dateToDatabaseString(dateToDatabaseString.randomBetween(this.model.created_at, new Date())); const newsletterId = this.newsletters[count % this.newsletters.length].id; return { diff --git a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js index 2d3d8126de4..32e9cacfee8 100644 --- a/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js +++ b/ghost/core/core/server/data/seeders/importers/members-subscription-created-events-importer.js @@ -1,6 +1,7 @@ const TableImporter = require('./table-importer'); const {faker} = require('@faker-js/faker'); const {luck} = require('../utils/random'); +const dateToDatabaseString = require('../utils/database-date'); class MembersSubscriptionCreatedEventsImporter extends TableImporter { static table = 'members_subscription_created_events'; @@ -48,7 +49,8 @@ class MembersSubscriptionCreatedEventsImporter extends TableImporter { }; if (luck(30)) { - const post = this.posts.find(p => p.visibility === 'public' && new Date(p.published_at) < new Date(this.model.created_at)); + const createdAt = dateToDatabaseString.parse(this.model.created_at); + const post = this.posts.find(p => p.visibility === 'public' && dateToDatabaseString.parse(p.published_at) < createdAt); if (post) { attribution = { attribution_id: post.id, diff --git a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js index 83dc30e955a..d7939e0b2f1 100644 --- a/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js +++ b/ghost/core/core/server/data/seeders/importers/offer-redemptions-importer.js @@ -53,7 +53,7 @@ class OfferRedemptionsImporter extends TableImporter { this.subscriptionPool.push({ memberId, subscriptionId: subscription.id, - subscriptionCreatedAt: new Date(subscription.created_at), + subscriptionCreatedAt: dateToDatabaseString.parse(subscription.created_at), redemptionEndAt: this.getRedemptionEndDate(subscription.current_period_end), availableOffers: [...matchingOffers], lastRedeemedAt: null @@ -78,7 +78,7 @@ class OfferRedemptionsImporter extends TableImporter { getRedemptionEndDate(currentPeriodEnd) { const now = new Date(); - const endDate = currentPeriodEnd ? new Date(currentPeriodEnd) : now; + const endDate = currentPeriodEnd ? dateToDatabaseString.parse(currentPeriodEnd) : now; return endDate > now ? now : endDate; } @@ -86,7 +86,7 @@ class OfferRedemptionsImporter extends TableImporter { getCreatedAt(subscriptionState, offer) { const candidateEarliest = new Date(Math.max( subscriptionState.subscriptionCreatedAt.valueOf(), - new Date(offer.created_at).valueOf(), + dateToDatabaseString.parse(offer.created_at).valueOf(), subscriptionState.lastRedeemedAt ? subscriptionState.lastRedeemedAt.valueOf() + 1000 : 0 )); const earliest = new Date(Math.min( diff --git a/ghost/core/core/server/data/seeders/utils/database-date.js b/ghost/core/core/server/data/seeders/utils/database-date.js index 01da9ee611d..1a48d79aed5 100644 --- a/ghost/core/core/server/data/seeders/utils/database-date.js +++ b/ghost/core/core/server/data/seeders/utils/database-date.js @@ -1,7 +1,32 @@ -module.exports = function dateToDatabaseString(date) { +const {faker} = require('@faker-js/faker'); + +const databaseDatePattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/; + +function dateToDatabaseString(date) { if (typeof date === 'string') { // SQLite fix when reusing other dates from the db return date; } return date.toISOString().replace('Z','').replace('T', ' '); +} + +dateToDatabaseString.parse = function parseDatabaseDate(date) { + if (date instanceof Date) { + return new Date(date); + } + + if (typeof date === 'string' && databaseDatePattern.test(date)) { + return new Date(date.replace(' ', 'T') + 'Z'); + } + + return new Date(date); +}; + +dateToDatabaseString.randomBetween = function randomBetween(start, end) { + const earliest = dateToDatabaseString.parse(start); + const latest = dateToDatabaseString.parse(end); + + return latest > earliest ? faker.date.between(earliest, latest) : earliest; }; + +module.exports = dateToDatabaseString; diff --git a/ghost/core/test/unit/server/data/seeders/data-generator.test.js b/ghost/core/test/unit/server/data/seeders/data-generator.test.js index 1dcc9696ae4..7c853863e9a 100644 --- a/ghost/core/test/unit/server/data/seeders/data-generator.test.js +++ b/ghost/core/test/unit/server/data/seeders/data-generator.test.js @@ -1,4 +1,5 @@ const assert = require('node:assert/strict'); +const {spawnSync} = require('node:child_process'); const knex = require('knex').default; @@ -9,6 +10,7 @@ const StripeProductsImporter = importers.find(i => i.table === 'stripe_products' const StripePricesImporter = importers.find(i => i.table === 'stripe_prices'); const generateEvents = require('../../../../../core/server/data/seeders/utils/event-generator'); +const dateToDatabaseString = require('../../../../../core/server/data/seeders/utils/database-date'); const DataGenerator = require('../../../../../core/server/data/seeders/data-generator'); @@ -253,6 +255,30 @@ describe('Importer', function () { }); describe('Events Generator', function () { + it('Parses database timestamps as UTC in non-UTC timezones', function () { + const script = ` + const dateToDatabaseString = require(${JSON.stringify(require.resolve('../../../../../core/server/data/seeders/utils/database-date'))}); + process.stdout.write(dateToDatabaseString.parse('2026-03-26 11:50:00.000').toISOString()); + `; + const result = spawnSync(process.execPath, ['-e', script], { + env: { + ...process.env, + TZ: 'America/New_York' + }, + encoding: 'utf8' + }); + + assert.equal(result.status, 0); + assert.equal(result.stdout, '2026-03-26T11:50:00.000Z'); + }); + + it('Returns the start date when a range is inverted', function () { + const startDate = new Date('2026-03-26T11:50:00.000Z'); + const endDate = new Date('2026-03-26T10:00:00.000Z'); + + assert.equal(dateToDatabaseString.randomBetween(startDate, endDate).toISOString(), startDate.toISOString()); + }); + it('Generates a set of timestamps which meet the criteria', function () { const startTime = new Date(); startTime.setDate(startTime.getDate() - 30);