From 4873b67ecc1f5f7ebe9e0f14712ba3375b858cb9 Mon Sep 17 00:00:00 2001 From: utahkanz-ops <272119084+utahkanz-ops@users.noreply.github.com> Date: Sun, 31 May 2026 07:11:25 +0100 Subject: [PATCH] fix: standardize creator timestamps --- .../creator-list-projection.constants.ts | 4 ++++ ...r-detail-cache-headers.integration.test.ts | 2 ++ .../creator/creator-profile.schemas.ts | 2 ++ .../creator/creator-profile.service.test.ts | 2 ++ .../creator/creator-profile.service.ts | 5 +++++ .../creators/creator-list-item.mapper.test.ts | 20 +++++++++++++++++-- .../creators/creator-list-item.mapper.ts | 5 +++++ ...tor-route-content-type.integration.test.ts | 2 ++ src/modules/creators/creators.serializers.ts | 3 ++- src/utils/iso-timestamp.utils.test.ts | 17 ++++++++++++++++ src/utils/iso-timestamp.utils.ts | 14 +++++++++++++ 11 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 src/utils/iso-timestamp.utils.test.ts create mode 100644 src/utils/iso-timestamp.utils.ts diff --git a/src/constants/creator-list-projection.constants.ts b/src/constants/creator-list-projection.constants.ts index 63858a1..03e88b6 100644 --- a/src/constants/creator-list-projection.constants.ts +++ b/src/constants/creator-list-projection.constants.ts @@ -17,6 +17,8 @@ * - displayName: Creator's display name * - avatarUrl: URL to creator's avatar image * - isVerified: Verification status badge + * - createdAt: Creator registration timestamp + * - updatedAt: Creator profile update timestamp */ export const CREATOR_LIST_DEFAULT_SELECT = { id: true, @@ -24,6 +26,8 @@ export const CREATOR_LIST_DEFAULT_SELECT = { displayName: true, avatarUrl: true, isVerified: true, + createdAt: true, + updatedAt: true, } as const; export type CreatorListSelectKeys = keyof typeof CREATOR_LIST_DEFAULT_SELECT; diff --git a/src/modules/creator/creator-detail-cache-headers.integration.test.ts b/src/modules/creator/creator-detail-cache-headers.integration.test.ts index f29aee7..156b18a 100644 --- a/src/modules/creator/creator-detail-cache-headers.integration.test.ts +++ b/src/modules/creator/creator-detail-cache-headers.integration.test.ts @@ -36,6 +36,8 @@ const FIXTURE_PROFILE = { displayName: 'Test Creator', bio: 'A bio', avatarUrl: 'https://example.com/avatar.png', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', perks: [], links: [], metadata: { source: 'database' as const, isProfileComplete: true }, diff --git a/src/modules/creator/creator-profile.schemas.ts b/src/modules/creator/creator-profile.schemas.ts index c7b5fcf..cad7f62 100644 --- a/src/modules/creator/creator-profile.schemas.ts +++ b/src/modules/creator/creator-profile.schemas.ts @@ -43,6 +43,8 @@ export const CreatorProfileReadResponseSchema = z.object({ displayName: z.string().nullable(), bio: z.string().nullable(), avatarUrl: z.string().url().nullable(), + createdAt: z.string().datetime().nullable(), + updatedAt: z.string().datetime().nullable(), perks: z.array(CreatorPerkSchema).optional(), links: z.array(z.object({ label: z.string(), url: z.string().url() })), metadata: z.object({ diff --git a/src/modules/creator/creator-profile.service.test.ts b/src/modules/creator/creator-profile.service.test.ts index d88eeb0..66dc075 100644 --- a/src/modules/creator/creator-profile.service.test.ts +++ b/src/modules/creator/creator-profile.service.test.ts @@ -13,6 +13,8 @@ describe('getCreatorProfile', () => { displayName: null, bio: null, avatarUrl: null, + createdAt: null, + updatedAt: null, links: [], metadata: { source: 'placeholder', diff --git a/src/modules/creator/creator-profile.service.ts b/src/modules/creator/creator-profile.service.ts index 2f5d7be..63eb1b7 100644 --- a/src/modules/creator/creator-profile.service.ts +++ b/src/modules/creator/creator-profile.service.ts @@ -5,6 +5,7 @@ import { UpsertCreatorProfileBody, } from './creator-profile.schemas'; import { CREATOR_DETAIL_DEFAULT_SELECT } from '../../constants/creator-detail-include.constants'; +import { formatIsoTimestamp } from '../../utils/iso-timestamp.utils'; import { normalizeSocialLinkUrl } from './creator-social-link-url.utils'; function normalizeProfileLinks( @@ -59,6 +60,8 @@ export async function getCreatorProfile( displayName: null, bio: null, avatarUrl: null, + createdAt: null, + updatedAt: null, perks: [], links: [], metadata: { @@ -73,6 +76,8 @@ export async function getCreatorProfile( displayName: profile.displayName, bio: profile.bio, avatarUrl: profile.avatarUrl, + createdAt: formatIsoTimestamp(profile.createdAt), + updatedAt: formatIsoTimestamp(profile.updatedAt), perks: (profile.perks as any) || [], links: [], // Links are not yet in the Prisma model, keeping as part of contract metadata: { diff --git a/src/modules/creators/creator-list-item.mapper.test.ts b/src/modules/creators/creator-list-item.mapper.test.ts index b6ec0a3..634be82 100644 --- a/src/modules/creators/creator-list-item.mapper.test.ts +++ b/src/modules/creators/creator-list-item.mapper.test.ts @@ -14,7 +14,13 @@ beforeEach(() => { describe('mapCreatorListItem()', () => { it('maps the public creator list item shape', () => { - const input = { id: '1', displayName: 'John', avatarUrl: null } as any; + const input = { + id: '1', + displayName: 'John', + avatarUrl: null, + createdAt: new Date('2024-01-02T03:04:05.678Z'), + updatedAt: new Date('2024-01-03T03:04:05.678Z'), + } as any; const result = mapCreatorListItem(input); @@ -23,12 +29,20 @@ describe('mapCreatorListItem()', () => { name: 'John', avatar: null, followers: 0, + createdAt: '2024-01-02T03:04:05.678Z', + updatedAt: '2024-01-03T03:04:05.678Z', }); expect(warnMock).not.toHaveBeenCalled(); }); it('warns when a schema-required creator field is unexpectedly null', () => { - const input = { id: 'creator-1', displayName: null, avatarUrl: null } as any; + const input = { + id: 'creator-1', + displayName: null, + avatarUrl: null, + createdAt: new Date('2024-01-02T03:04:05.678Z'), + updatedAt: new Date('2024-01-03T03:04:05.678Z'), + } as any; const result = requestContextStorage.run( { path: '/api/v1/creators', method: 'GET', requestId: 'req-333' }, @@ -40,6 +54,8 @@ describe('mapCreatorListItem()', () => { name: null, avatar: null, followers: 0, + createdAt: '2024-01-02T03:04:05.678Z', + updatedAt: '2024-01-03T03:04:05.678Z', }); expect(warnMock).toHaveBeenCalledWith({ msg: 'Unexpected null creator field in database result', diff --git a/src/modules/creators/creator-list-item.mapper.ts b/src/modules/creators/creator-list-item.mapper.ts index 31ca4b5..a3682e7 100644 --- a/src/modules/creators/creator-list-item.mapper.ts +++ b/src/modules/creators/creator-list-item.mapper.ts @@ -1,5 +1,6 @@ import { CreatorProfile } from '../../types/profile.types'; import { requestContextStorage } from '../../utils/als.utils'; +import { formatIsoTimestamp } from '../../utils/iso-timestamp.utils'; import { logger } from '../../utils/logger.utils'; import { safeRead } from '../../utils/safe-nested-read.utils'; @@ -12,6 +13,8 @@ export type CreatorListItem = { name: string | null; avatar: string | null; followers: number; + createdAt: string; + updatedAt: string; }; function warnIfUnexpectedNullCreatorField( @@ -46,5 +49,7 @@ export const mapCreatorListItem = ( name: safeRead(creator, 'displayName', null), avatar: safeRead(creator, 'avatarUrl', null), followers: 0, + createdAt: formatIsoTimestamp(creator.createdAt), + updatedAt: formatIsoTimestamp(creator.updatedAt), }; }; diff --git a/src/modules/creators/creator-route-content-type.integration.test.ts b/src/modules/creators/creator-route-content-type.integration.test.ts index 8e6c58d..eeacef7 100644 --- a/src/modules/creators/creator-route-content-type.integration.test.ts +++ b/src/modules/creators/creator-route-content-type.integration.test.ts @@ -56,6 +56,8 @@ const FIXTURE_PROFILE = { displayName: 'Test Creator', bio: 'Test bio', avatarUrl: 'https://example.com/avatar.png', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', perks: [], links: [], metadata: { source: 'database' as const, isProfileComplete: true }, diff --git a/src/modules/creators/creators.serializers.ts b/src/modules/creators/creators.serializers.ts index 126032a..e24419e 100644 --- a/src/modules/creators/creators.serializers.ts +++ b/src/modules/creators/creators.serializers.ts @@ -17,7 +17,7 @@ * * | Field | When empty / unknown | * |-------|----------------------| - * | `id`, `followers` | Always present (`followers` is a number, currently `0`). | + * | `id`, `followers`, `createdAt`, `updatedAt` | Always present (`followers` is a number, currently `0`; timestamps use ISO 8601 UTC strings). | * | `name`, `avatar` | **Always present**; use JSON **`null`** when display name or avatar URL is missing (`?? null` in the mapper). | * * List envelope (`items`, `meta`) always includes both keys. Offset `meta` always @@ -39,6 +39,7 @@ * | Field | When empty / unknown | * |-------|----------------------| * | `creatorId`, `metadata` | Always present. | + * | `createdAt`, `updatedAt` | Always present; ISO 8601 UTC strings for database records, JSON **`null`** for placeholder responses. | * | `displayName`, `bio`, `avatarUrl` | **Always present**; JSON **`null`** when unset (Zod `.nullable()`; placeholder fallback uses explicit `null`). | * | `links` | **Always present** as an array; use **`[]`** when there are no links (never `null`, never omitted). | * | `perks` | **Always present** as an array in current handlers (`[]` when none). The read schema marks `perks` optional, but the service always includes the key. | diff --git a/src/utils/iso-timestamp.utils.test.ts b/src/utils/iso-timestamp.utils.test.ts new file mode 100644 index 0000000..92b18a1 --- /dev/null +++ b/src/utils/iso-timestamp.utils.test.ts @@ -0,0 +1,17 @@ +import { formatIsoTimestamp } from './iso-timestamp.utils'; + +describe('formatIsoTimestamp()', () => { + it('formats supported timestamp inputs with one ISO 8601 UTC representation', () => { + const expected = '2024-01-02T03:04:05.678Z'; + + expect(formatIsoTimestamp(new Date(expected))).toBe(expected); + expect(formatIsoTimestamp('2024-01-02T04:04:05.678+01:00')).toBe( + expected + ); + expect(formatIsoTimestamp(Date.parse(expected))).toBe(expected); + }); + + it('rejects invalid timestamp values', () => { + expect(() => formatIsoTimestamp('not-a-date')).toThrow(RangeError); + }); +}); diff --git a/src/utils/iso-timestamp.utils.ts b/src/utils/iso-timestamp.utils.ts new file mode 100644 index 0000000..fa6469a --- /dev/null +++ b/src/utils/iso-timestamp.utils.ts @@ -0,0 +1,14 @@ +export type TimestampInput = Date | string | number; + +/** + * Formats API response timestamps as UTC ISO 8601 strings with milliseconds. + */ +export function formatIsoTimestamp(value: TimestampInput): string { + const date = value instanceof Date ? value : new Date(value); + + if (Number.isNaN(date.getTime())) { + throw new RangeError('Invalid timestamp value'); + } + + return date.toISOString(); +}