From a514db22cc183db598c08b5ff43a88ba9817eb1d Mon Sep 17 00:00:00 2001 From: constf03 Date: Thu, 24 Sep 2026 22:07:15 +0300 Subject: [PATCH 1/5] add createdAt timestamp for clans --- src/clan/clan.schema.ts | 8 +++++++- src/clan/dto/clan.dto.ts | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/clan/clan.schema.ts b/src/clan/clan.schema.ts index 9371b3f5..8359691c 100644 --- a/src/clan/clan.schema.ts +++ b/src/clan/clan.schema.ts @@ -16,7 +16,11 @@ import { ClanRule } from './enum/clanRule.enum'; export type ClanDocument = HydratedDocument; -@Schema({ toJSON: { virtuals: true }, toObject: { virtuals: true } }) +@Schema({ + toJSON: { virtuals: true }, + toObject: { virtuals: true }, + timestamps: { createdAt: true, updatedAt: false }, +}) export class Clan { @Prop({ type: String, required: true, unique: true, maxlength: 20 }) name: string; @@ -107,6 +111,8 @@ export class Clan { }) rules: ClanRule[]; + createdAt?: Date; + @ExtractField() _id: string; } diff --git a/src/clan/dto/clan.dto.ts b/src/clan/dto/clan.dto.ts index 23cdda3a..083f2df0 100644 --- a/src/clan/dto/clan.dto.ts +++ b/src/clan/dto/clan.dto.ts @@ -222,4 +222,11 @@ export class ClanDto { */ @Expose() rules?: ClanRule[]; + + /** + * Date when the clan was created + * @example "2024-01-20T12:00:00Z" + */ + @Expose() + createdAt?: Date; } From 14fe99943d8b25d589428d8e43bcfd4c27c0866d Mon Sep 17 00:00:00 2001 From: constf03 Date: Thu, 24 Sep 2026 22:08:44 +0300 Subject: [PATCH 2/5] add migration template for clan timestamp creation --- .../20260924120000-add-clan-created-at.js | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 database/migrations/20260924120000-add-clan-created-at.js diff --git a/database/migrations/20260924120000-add-clan-created-at.js b/database/migrations/20260924120000-add-clan-created-at.js new file mode 100644 index 00000000..3850dae4 --- /dev/null +++ b/database/migrations/20260924120000-add-clan-created-at.js @@ -0,0 +1,60 @@ +/** + * Migration: Add createdAt field to clans + * + * Clans now have their creation timestamp field createdAt (issue #1002). + * Existing clans without the field are given a fixed date timestamp September 1 2026. + */ + +const DEFAULT_CREATED_AT = new Date('2026-09-01T00:00:00.000Z'); + +/** + * @param db {import('mongodb').Db} + * @param client {import('mongodb').MongoClient} + */ +module.exports.up = async (db, client) => { + const session = client.startSession(); + + try { + await session.withTransaction(async () => { + const clans = db.collection('Clan'); + + const result = await clans.updateMany( + { createdAt: { $exists: false } }, + { $set: { createdAt: DEFAULT_CREATED_AT } }, + { session }, + ); + + console.log( + `[migrate-mongo] Added createdAt to ${result.modifiedCount} clans (${result.matchedCount} matched)`, + ); + }); + } finally { + await session.endSession(); + } +}; + +/** + * @param db {import('mongodb').Db} + * @param client {import('mongodb').MongoClient} + */ +module.exports.down = async (db, client) => { + const session = client.startSession(); + + try { + await session.withTransaction(async () => { + const clans = db.collection('Clan'); + + const result = await clans.updateMany( + { createdAt: { $exists: true } }, + { $unset: { createdAt: '' } }, + { session }, + ); + + console.log( + `[migrate-mongo] Removed createdAt from ${result.modifiedCount} clans`, + ); + }); + } finally { + await session.endSession(); + } +}; From 68568bf4df00bc908ad9ca188ca9f968895ed5e7 Mon Sep 17 00:00:00 2001 From: constf03 Date: Fri, 25 Sep 2026 15:44:31 +0300 Subject: [PATCH 3/5] remove clan timestamp migration file --- .../20260924120000-add-clan-created-at.js | 60 ------------------- 1 file changed, 60 deletions(-) delete mode 100644 database/migrations/20260924120000-add-clan-created-at.js diff --git a/database/migrations/20260924120000-add-clan-created-at.js b/database/migrations/20260924120000-add-clan-created-at.js deleted file mode 100644 index 3850dae4..00000000 --- a/database/migrations/20260924120000-add-clan-created-at.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Migration: Add createdAt field to clans - * - * Clans now have their creation timestamp field createdAt (issue #1002). - * Existing clans without the field are given a fixed date timestamp September 1 2026. - */ - -const DEFAULT_CREATED_AT = new Date('2026-09-01T00:00:00.000Z'); - -/** - * @param db {import('mongodb').Db} - * @param client {import('mongodb').MongoClient} - */ -module.exports.up = async (db, client) => { - const session = client.startSession(); - - try { - await session.withTransaction(async () => { - const clans = db.collection('Clan'); - - const result = await clans.updateMany( - { createdAt: { $exists: false } }, - { $set: { createdAt: DEFAULT_CREATED_AT } }, - { session }, - ); - - console.log( - `[migrate-mongo] Added createdAt to ${result.modifiedCount} clans (${result.matchedCount} matched)`, - ); - }); - } finally { - await session.endSession(); - } -}; - -/** - * @param db {import('mongodb').Db} - * @param client {import('mongodb').MongoClient} - */ -module.exports.down = async (db, client) => { - const session = client.startSession(); - - try { - await session.withTransaction(async () => { - const clans = db.collection('Clan'); - - const result = await clans.updateMany( - { createdAt: { $exists: true } }, - { $unset: { createdAt: '' } }, - { session }, - ); - - console.log( - `[migrate-mongo] Removed createdAt from ${result.modifiedCount} clans`, - ); - }); - } finally { - await session.endSession(); - } -}; From 312784a59caba63b505da34d04f7f9512757447b Mon Sep 17 00:00:00 2001 From: constf03 Date: Fri, 25 Sep 2026 15:45:28 +0300 Subject: [PATCH 4/5] use refresh service for clan creation timestamps --- src/clan/clan.module.ts | 2 + .../clanTimestampsStartupRefresh.service.ts | 147 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/clan/clanTimestampsStartupRefresh.service.ts diff --git a/src/clan/clan.module.ts b/src/clan/clan.module.ts index b3f77620..4d6d8ef9 100644 --- a/src/clan/clan.module.ts +++ b/src/clan/clan.module.ts @@ -21,6 +21,7 @@ import { ChatModule } from '../chat/chat.module'; import { PasswordGenerator } from '../common/function/passwordGenerator'; import { EventEmitterCommonModule } from '../common/service/EventEmitterService/EventEmitterCommon.module'; import { DailyTasksModule } from '../dailyTasks/dailyTasks.module'; +import { ClanTimestampsStartupRefreshService } from './clanTimestampsStartupRefresh.service'; @Module({ imports: [ @@ -47,6 +48,7 @@ import { DailyTasksModule } from '../dailyTasks/dailyTasks.module'; ClanRoleService, ClanRoleVotingProcessor, PasswordGenerator, + ClanTimestampsStartupRefreshService, ], exports: [ ClanService, diff --git a/src/clan/clanTimestampsStartupRefresh.service.ts b/src/clan/clanTimestampsStartupRefresh.service.ts new file mode 100644 index 00000000..e08e0cde --- /dev/null +++ b/src/clan/clanTimestampsStartupRefresh.service.ts @@ -0,0 +1,147 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { InjectConnection } from '@nestjs/mongoose'; +import { Connection } from 'mongoose'; +import { randomUUID } from 'node:crypto'; +import { hostname } from 'node:os'; +import { ModelName } from '../common/enum/modelName.enum'; + +const LOCK_ID = 'clan-timestamps-startup-refresh'; +const LOCK_TTL_MS = 30 * 60 * 1000; + +/** + * This represents 1 September 2026 at 00:00 in Finland + * while daylight saving time is active. + * + * MongoDB stores it internally as 2026-08-31T21:00:00.000Z. + */ +const INITIAL_CLAN_TIMESTAMP = new Date('2026-09-01T00:00:00.000+03:00'); + +type MaintenanceLock = { + _id: string; + ownerId: string; + lockedAt: Date; + expiresAt: Date; +}; + +@Injectable() +export class ClanTimestampsStartupRefreshService + implements OnApplicationBootstrap +{ + private readonly logger = new Logger( + ClanTimestampsStartupRefreshService.name, + ); + + private readonly ownerId = `${hostname()}-${process.pid}-${randomUUID()}`; + + constructor( + @InjectConnection() + private readonly connection: Connection, + ) {} + + async onApplicationBootstrap(): Promise { + try { + if (!(await this.hasClansWithoutTimestamps())) { + return; + } + + const lockAcquired = await this.tryAcquireLock(); + + if (!lockAcquired) { + this.logger.log( + 'Clan timestamp initialization skipped; another instance is running it.', + ); + return; + } + + try { + // Another instance could have completed the operation + // between the first check and lock acquisition. + if (!(await this.hasClansWithoutTimestamps())) { + return; + } + + await this.initializeMissingTimestamps(); + } finally { + await this.releaseLock(); + } + } catch (error) { + this.logger.error( + 'Clan timestamp initialization failed', + error instanceof Error ? error.stack : String(error), + ); + + // Choose this if the API must not run without the timestamps: + throw error; + } + } + + private async hasClansWithoutTimestamps(): Promise { + const clan = await this.connection.db + .collection(ModelName.CLAN) + .findOne({ createdAt: { $exists: false } }); + + return clan !== null; + } + + private async initializeMissingTimestamps(): Promise { + const clans = this.connection.db.collection(ModelName.CLAN); + + const result = await clans.updateMany( + { createdAt: { $exists: false } }, + { + $set: { + createdAt: INITIAL_CLAN_TIMESTAMP, + }, + }, + ); + + this.logger.log( + `Clan timestamp initialization completed. createdAt initialized for ${result.modifiedCount} clans.`, + ); + } + + private async tryAcquireLock(): Promise { + const now = new Date(); + const expiresAt = new Date(now.getTime() + LOCK_TTL_MS); + + try { + const lock = await this.connection.db + .collection('MaintenanceLock') + .findOneAndUpdate( + { + _id: LOCK_ID, + $or: [ + { expiresAt: { $lte: now } }, + { expiresAt: { $exists: false } }, + ], + }, + { + $set: { + ownerId: this.ownerId, + lockedAt: now, + expiresAt, + }, + }, + { + upsert: true, + returnDocument: 'after', + }, + ); + + return lock?.ownerId === this.ownerId; + } catch { + // Concurrent upserts can cause a duplicate-key error. + // In that situation another instance owns the lock. + return false; + } + } + + private async releaseLock(): Promise { + await this.connection.db + .collection('MaintenanceLock') + .deleteOne({ + _id: LOCK_ID, + ownerId: this.ownerId, + }); + } +} From 4125ab43167269de3ae8e002e114776efebe3e96 Mon Sep 17 00:00:00 2001 From: constf03 Date: Fri, 25 Sep 2026 16:08:02 +0300 Subject: [PATCH 5/5] add unit test for clan timestamp Refresh service --- .../startupRefresh.test.ts | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 src/__tests__/clan/ClanTimestampsStartupRefreshService/startupRefresh.test.ts diff --git a/src/__tests__/clan/ClanTimestampsStartupRefreshService/startupRefresh.test.ts b/src/__tests__/clan/ClanTimestampsStartupRefreshService/startupRefresh.test.ts new file mode 100644 index 00000000..94adc3cd --- /dev/null +++ b/src/__tests__/clan/ClanTimestampsStartupRefreshService/startupRefresh.test.ts @@ -0,0 +1,217 @@ +import { getConnectionToken } from '@nestjs/mongoose'; +import { plainToInstance } from 'class-transformer'; +import { Connection } from 'mongoose'; +import { ModelName } from '../../../common/enum/modelName.enum'; +import { ClanDto } from '../../../clan/dto/clan.dto'; +import { ClanService } from '../../../clan/clan.service'; +import { ClanTimestampsStartupRefreshService } from '../../../clan/clanTimestampsStartupRefresh.service'; +import ClanBuilderFactory from '../data/clanBuilderFactory'; +import ClanModule from '../modules/clan.module'; +import ClanCommonModule from '../modules/clanCommon'; + +const LOCK_ID = 'clan-timestamps-startup-refresh'; +const INITIAL_CLAN_TIMESTAMP = new Date('2026-09-01T00:00:00.000+03:00'); + +describe('ClanTimestampsStartupRefreshService', () => { + const clanModel = ClanModule.getClanModel(); + const clanBuilder = ClanBuilderFactory.getBuilder('Clan'); + + let connection: Connection; + let clanService: ClanService; + let refreshService: ClanTimestampsStartupRefreshService; + let clanCounter = 0; + + const clanCollection = () => connection.db.collection(ModelName.CLAN); + const lockCollection = () => + connection.db.collection<{ + _id: string; + ownerId: string; + lockedAt: Date; + expiresAt: Date; + }>('MaintenanceLock'); + + const createClan = async () => { + clanCounter++; + const clan = await clanModel.create( + clanBuilder.setName(`clan${clanCounter}`).build(), + ); + return clan.toObject(); + }; + + /** + * Creates a clan the way it exists in the db before the createdAt field was added + */ + const createLegacyClan = async () => { + const clan = await createClan(); + await clanCollection().updateOne( + { _id: clan._id }, + { $unset: { createdAt: '' } }, + ); + return clan; + }; + + const createService = () => { + const refreshService = new ClanTimestampsStartupRefreshService(connection); + jest.spyOn((refreshService as any).logger, 'log').mockImplementation(); + jest.spyOn((refreshService as any).logger, 'error').mockImplementation(); + return refreshService; + }; + + beforeAll(async () => { + const module = await ClanCommonModule.getModule(); + connection = module.get(getConnectionToken()); + }); + + beforeEach(async () => { + clanService = await ClanModule.getClanService(); + refreshService = createService(); + await clanModel.deleteMany({}); + await lockCollection().deleteMany({ _id: LOCK_ID }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('Should initialize createdAt for clans missing it', async () => { + const legacyClan = await createLegacyClan(); + + await refreshService.onApplicationBootstrap(); + + const clanInDB = await clanCollection().findOne({ _id: legacyClan._id }); + expect(clanInDB.createdAt).toEqual(INITIAL_CLAN_TIMESTAMP); + }); + + it('Should not modify a clan that already has createdAt', async () => { + const existingClan = await createClan(); + await createLegacyClan(); + const clanBefore = await clanCollection().findOne({ + _id: existingClan._id, + }); + + await refreshService.onApplicationBootstrap(); + + const clanAfter = await clanCollection().findOne({ + _id: existingClan._id, + }); + expect(clanAfter).toEqual(clanBefore); + expect(clanAfter.createdAt).not.toEqual(INITIAL_CLAN_TIMESTAMP); + }); + + it('Should not change anything when run again', async () => { + await createLegacyClan(); + await createLegacyClan(); + await refreshService.onApplicationBootstrap(); + const clansAfterFirstRun = await clanCollection().find().toArray(); + const lockSpy = jest.spyOn(refreshService as any, 'tryAcquireLock'); + + await refreshService.onApplicationBootstrap(); + + const clansAfterSecondRun = await clanCollection().find().toArray(); + expect(clansAfterSecondRun).toEqual(clansAfterFirstRun); + expect(lockSpy).not.toHaveBeenCalled(); + }); + + it('Should not initialize createdAt if the lock cannot be obtained', async () => { + const legacyClan = await createLegacyClan(); + await lockCollection().insertOne({ + _id: LOCK_ID, + ownerId: 'another-instance', + lockedAt: new Date(), + expiresAt: new Date(Date.now() + 60 * 1000), + }); + + await refreshService.onApplicationBootstrap(); + + const clanInDB = await clanCollection().findOne({ _id: legacyClan._id }); + expect(clanInDB.createdAt).toBeUndefined(); + const lock = await lockCollection().findOne({ _id: LOCK_ID }); + expect(lock.ownerId).toBe('another-instance'); + }); + + it('Should release the lock after a successful run', async () => { + await createLegacyClan(); + + await refreshService.onApplicationBootstrap(); + + const lock = await lockCollection().findOne({ _id: LOCK_ID }); + expect(lock).toBeNull(); + }); + + it('Should release the lock if the update fails', async () => { + await createLegacyClan(); + const updateError = new Error('Update failed'); + const getCollection = connection.db.collection.bind(connection.db); + jest.spyOn(connection.db, 'collection').mockImplementation((( + name: string, + ) => { + const collection = getCollection(name); + if (name === ModelName.CLAN) + jest.spyOn(collection, 'updateMany').mockRejectedValue(updateError); + return collection; + }) as any); + + await expect(refreshService.onApplicationBootstrap()).rejects.toThrow( + updateError, + ); + + jest.restoreAllMocks(); + const lock = await lockCollection().findOne({ _id: LOCK_ID }); + expect(lock).toBeNull(); + }); + + it('Should give a new clan the current time as createdAt and not the backfill date', async () => { + const before = Date.now(); + const newClan = await createClan(); + const after = Date.now(); + + await refreshService.onApplicationBootstrap(); + + const clanInDB = await clanCollection().findOne({ _id: newClan._id }); + expect(clanInDB.createdAt).toEqual(newClan.createdAt); + expect(clanInDB.createdAt.getTime()).toBeGreaterThanOrEqual(before); + expect(clanInDB.createdAt.getTime()).toBeLessThanOrEqual(after); + }); + + it('Should not change createdAt when a clan is updated', async () => { + const newClan = await createClan(); + const legacyClan = await createLegacyClan(); + await refreshService.onApplicationBootstrap(); + + const [newClanUpdated] = await clanService.updateOneById({ + _id: newClan._id.toString(), + name: 'updatedClan1', + }); + const [legacyClanUpdated] = await clanService.updateOneById({ + _id: legacyClan._id.toString(), + name: 'updatedClan2', + }); + + expect(newClanUpdated).toBe(true); + expect(legacyClanUpdated).toBe(true); + const newClanInDB = await clanModel.findById(newClan._id); + const legacyClanInDB = await clanModel.findById(legacyClan._id); + expect(newClanInDB.name).toBe('updatedClan1'); + expect(newClanInDB.createdAt).toEqual(newClan.createdAt); + expect(legacyClanInDB.name).toBe('updatedClan2'); + expect(legacyClanInDB.createdAt).toEqual(INITIAL_CLAN_TIMESTAMP); + }); + + it('Should include createdAt in the clan API response data', async () => { + const newClan = await createClan(); + const legacyClan = await createLegacyClan(); + await refreshService.onApplicationBootstrap(); + + const [newClanResp] = await clanService.readOneById(newClan._id.toString()); + const [legacyClanResp] = await clanService.readOneById( + legacyClan._id.toString(), + ); + expect(newClanResp.createdAt).toEqual(newClan.createdAt); + expect(legacyClanResp.createdAt).toEqual(INITIAL_CLAN_TIMESTAMP); + + const serialized = plainToInstance(ClanDto, newClanResp, { + excludeExtraneousValues: true, + }); + expect(serialized.createdAt).toEqual(newClan.createdAt); + }); +});