Skip to content

1002 feature request add creation timestamp to the clan data - #1004

Merged
tickBit merged 6 commits into
devfrom
1002-feature-request-add-creation-timestamp-to-the-clan-data
Sep 25, 2026
Merged

tickBit merged 6 commits into
devfrom
1002-feature-request-add-creation-timestamp-to-the-clan-data

Conversation

@constf03

Copy link
Copy Markdown
Contributor

Brief description

This adds creation date timestamp for Clans. Mongoose now sets createdAt field automatically to clans upon creation.

Change list

  • Set timestamps in clan schema to true (left out updatedAt since it wasnt mentioned in the issue)
  • Exposed createdAt in ClanDto
  • Wrote a migration file that adds createdAt field for existing clans with default timestamp value 01.09.2026 00:00:00. Run the migration in the production db with npm run migrate:up.

@codecov-alt

codecov-alt Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.77778% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/clan/clanTimestampsStartupRefresh.service.ts 97.77% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/clan/clanTimestampsStartupRefresh.service.ts 97.77% <97.77%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tickBit tickBit left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clear and good work 👍 .

Running the migration in the production server is beyond my scope in this project, though.

That is why I propose a similar temporal refresh system for clans, that we have for the daily tasks. It would be less work and easier to implement.

The flow could be:

  1. New temporal file src/clan/clanTimestampsStartupRefresh.service.ts:
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<void> {
    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<boolean> {
    const clan = await this.connection.db
      .collection(ModelName.CLAN)
      .findOne({
        $or: [
          { createdAt: { $exists: false } },
          { updatedAt: { $exists: false } },
        ],
      });

    return clan !== null;
  }

  private async initializeMissingTimestamps(): Promise<void> {
    const clans = this.connection.db.collection(ModelName.CLAN);

    /*
     * These are deliberately two separate updates:
     * an existing timestamp must never be overwritten merely because
     * the other timestamp is missing.
     *
     * Using the native collection also prevents Mongoose timestamps
     * middleware from replacing updatedAt with the current time.
     */
    const createdAtResult = await clans.updateMany(
      { createdAt: { $exists: false } },
      {
        $set: {
          createdAt: INITIAL_CLAN_TIMESTAMP,
        },
      },
    );

    const updatedAtResult = await clans.updateMany(
      { updatedAt: { $exists: false } },
      {
        $set: {
          updatedAt: INITIAL_CLAN_TIMESTAMP,
        },
      },
    );

    this.logger.log(
      [
        'Clan timestamp initialization completed.',
        `createdAt initialized for ${createdAtResult.modifiedCount} clans.`,
        `updatedAt initialized for ${updatedAtResult.modifiedCount} clans.`,
      ].join(' '),
    );
  }

  private async tryAcquireLock(): Promise<boolean> {
    const now = new Date();
    const expiresAt = new Date(now.getTime() + LOCK_TTL_MS);

    try {
      const lock = await this.connection.db
        .collection<MaintenanceLock>('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<void> {
    await this.connection.db
      .collection<MaintenanceLock>('MaintenanceLock')
      .deleteOne({
        _id: LOCK_ID,
        ownerId: this.ownerId,
      });
  }
}
  1. Register the service in src/clan/clan.module.ts:

Import:

import { ClanTimestampsStartupRefreshService } from './clanTimestampsStartupRefresh.service';

Provider:

providers: [
  ClanService,
  isClanExists,
  PlayerCounterFactory,
  JoinService,
  ClanHelperService,
  ClanRoleService,
  ClanRoleVotingProcessor,
  PasswordGenerator,
  ClanTimestampsStartupRefreshService,
],

Registering the service as a provider is all it takes for Nest to create the service and call the onApplicationBootstrap() method.

For the Startup service, you should test at least the following:

  1. Both are missing → both are initialized.
  2. Only createdAt is missing → the existing updatedAt is retained.
  3. Only updatedAt is missing → the existing createdAt is retained.
  4. Both are present → the document is not modified.
  5. Re-run → nothing changes.
  6. Lock cannot be obtained → the update is not executed.
  7. The lock is released after a successful run.
  8. The lock is also released if the update fails.
  9. A new clan receives the current timestamps, not a fixed backfill date.
  10. A normal clan update changes the updatedAt value but not the createdAt value.
  11. The API response includes both fields.

It’s a good idea to keep the startup service running at least until we’re certain that all production environments have been upgraded to the new version. After that, the service and any MaintenanceLock document can be removed in a separate cleanup PR.

When we know, that there won't be any new daily tasks or any of the currently available is not be removed, we could clean the code by removing the daily tasks refresh service, too.

@constf03

Copy link
Copy Markdown
Contributor Author

Seems like a good idea if we cant run a migration, I can implement it in this PR

@constf03

Copy link
Copy Markdown
Contributor Author

Is it necessary to have updatedAt field if the issue only requires data of creation date timestamp?

@tickBit

tickBit commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Is it necessary to have updatedAt field if the issue only requires data of creation date timestamp?

Good point 👍 ! I don't think it's necessary. We were asked to add createdAt only 👍

@CapoMK25 CapoMK25 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved, solid work.

@tickBit tickBit left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good work 👍 ! Approved.

@tickBit
tickBit merged commit a214bbd into dev Sep 25, 2026
5 checks passed
@tickBit
tickBit deleted the 1002-feature-request-add-creation-timestamp-to-the-clan-data branch September 25, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Feature request: Add creation timestamp to the clan data.

3 participants