Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/constants/creator-list-projection.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@
* - 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,
handle: true,
displayName: true,
avatarUrl: true,
isVerified: true,
createdAt: true,
updatedAt: true,
} as const;

export type CreatorListSelectKeys = keyof typeof CREATOR_LIST_DEFAULT_SELECT;
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
2 changes: 2 additions & 0 deletions src/modules/creator/creator-profile.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/modules/creator/creator-profile.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ describe('getCreatorProfile', () => {
displayName: null,
bio: null,
avatarUrl: null,
createdAt: null,
updatedAt: null,
links: [],
metadata: {
source: 'placeholder',
Expand Down
5 changes: 5 additions & 0 deletions src/modules/creator/creator-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -59,6 +60,8 @@ export async function getCreatorProfile(
displayName: null,
bio: null,
avatarUrl: null,
createdAt: null,
updatedAt: null,
perks: [],
links: [],
metadata: {
Expand All @@ -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: {
Expand Down
20 changes: 18 additions & 2 deletions src/modules/creators/creator-list-item.mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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' },
Expand All @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions src/modules/creators/creator-list-item.mapper.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -12,6 +13,8 @@ export type CreatorListItem = {
name: string | null;
avatar: string | null;
followers: number;
createdAt: string;
updatedAt: string;
};

function warnIfUnexpectedNullCreatorField(
Expand Down Expand Up @@ -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),
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
3 changes: 2 additions & 1 deletion src/modules/creators/creators.serializers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |
Expand Down
17 changes: 17 additions & 0 deletions src/utils/iso-timestamp.utils.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 14 additions & 0 deletions src/utils/iso-timestamp.utils.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading