harden media

This commit is contained in:
2025-12-31 15:39:28 +01:00
parent 92226800df
commit 8260bf7baf
413 changed files with 8361 additions and 1544 deletions

View File

@@ -1,6 +1,6 @@
import { Provider } from '@nestjs/common';
import { IMAGE_SERVICE_TOKEN, LOGGER_TOKEN, MEDIA_REPOSITORY_TOKEN } from './TeamTokens';
import { IMAGE_SERVICE_TOKEN, LOGGER_TOKEN, MEDIA_REPOSITORY_TOKEN, MEDIA_RESOLVER_TOKEN } from './TeamTokens';
export {
TEAM_REPOSITORY_TOKEN,
@@ -9,15 +9,18 @@ export {
IMAGE_SERVICE_TOKEN,
LOGGER_TOKEN,
MEDIA_REPOSITORY_TOKEN,
MEDIA_RESOLVER_TOKEN,
} from './TeamTokens';
// Import core interfaces
import type { Logger } from '@core/shared/application/Logger';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
// Import concrete in-memory implementations
// Import concrete implementations
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
// Import presenters
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
@@ -34,11 +37,36 @@ export const TeamProviders: Provider[] = [
},
{
provide: MEDIA_REPOSITORY_TOKEN,
useFactory: (logger: Logger) => new InMemoryMediaRepository(logger),
useFactory: (logger: Logger) => {
const mediaRepo = new InMemoryMediaRepository(logger);
// Override getTeamLogo to provide fallback URLs
const originalGetTeamLogo = mediaRepo.getTeamLogo.bind(mediaRepo);
mediaRepo.getTeamLogo = async (teamId: string): Promise<string | null> => {
const logo = await originalGetTeamLogo(teamId);
if (logo) return logo;
// Fallback: generate deterministic team logo URL
// Use API port (3001) for media generation
const baseUrl = process.env.NODE_ENV === 'development' ? 'http://localhost:3001' : 'https://api.gridpilot.io';
return `${baseUrl}/media/teams/${teamId}/logo`;
};
return mediaRepo;
},
inject: [LOGGER_TOKEN],
},
{
provide: AllTeamsPresenter,
useFactory: () => new AllTeamsPresenter(),
provide: MEDIA_RESOLVER_TOKEN,
useFactory: () => new MediaResolverAdapter({}),
},
];
{
provide: AllTeamsPresenter,
useFactory: (mediaResolver: MediaResolverPort) => {
const presenter = new AllTeamsPresenter();
presenter.setMediaResolver(mediaResolver);
return presenter;
},
inject: [MEDIA_RESOLVER_TOKEN],
},
];

View File

@@ -23,6 +23,9 @@ type TeamEntityStub = {
ownerId: ValueObjectStub;
leagues: ValueObjectStub[];
createdAt: { toDate(): Date };
logoRef: any;
category: string | undefined;
isRecruiting: boolean;
update: Mock;
};
@@ -43,6 +46,9 @@ describe('TeamService', () => {
ownerId: makeValueObject('owner-1'),
leagues: [makeValueObject('league-1')],
createdAt: { toDate: () => new Date('2023-01-01T00:00:00.000Z') },
logoRef: { type: 'system-default', variant: 'logo' },
category: undefined,
isRecruiting: false,
};
const team: TeamEntityStub = {
@@ -95,7 +101,7 @@ describe('TeamService', () => {
countByTeamId: vi.fn(),
getActiveMembershipForDriver: vi.fn(),
getMembership: vi.fn(),
getTeamMembers: vi.fn(),
getTeamMembers: vi.fn().mockResolvedValue([]),
getJoinRequests: vi.fn(),
saveMembership: vi.fn(),
};
@@ -112,28 +118,38 @@ describe('TeamService', () => {
} as unknown as Logger;
const teamStatsRepository = {
getTeamStats: vi.fn(),
getTeamStats: vi.fn().mockResolvedValue(undefined),
saveTeamStats: vi.fn(),
getAllStats: vi.fn(),
clear: vi.fn(),
};
const mediaRepository = {
getTeamAvatar: vi.fn(),
saveTeamAvatar: vi.fn(),
getDriverAvatar: vi.fn(),
saveDriverAvatar: vi.fn(),
};
const resultRepository = {
findAll: vi.fn(),
findAll: vi.fn().mockResolvedValue([]),
};
// Mock presenter that stores result synchronously
const allTeamsPresenter = {
reset: vi.fn(),
present: vi.fn(),
getResponseModel: vi.fn(() => ({ teams: [], totalCount: 0 })),
present: vi.fn((result: any) => {
// Store immediately and synchronously
allTeamsPresenter.responseModel = {
teams: result.teams.map((t: any) => ({
id: t.id,
name: t.name,
tag: t.tag,
description: t.description,
memberCount: t.memberCount,
leagues: t.leagues,
logoUrl: t.logoUrl ?? null,
})),
totalCount: result.totalCount,
};
}),
getResponseModel: vi.fn(() => allTeamsPresenter.responseModel || { teams: [], totalCount: 0 }),
responseModel: { teams: [], totalCount: 0 },
setMediaResolver: vi.fn(),
setBaseUrl: vi.fn(),
};
service = new TeamService(
@@ -142,9 +158,8 @@ describe('TeamService', () => {
driverRepository as unknown as never,
logger,
teamStatsRepository as unknown as never,
mediaRepository as unknown as never,
resultRepository as unknown as never,
allTeamsPresenter as unknown as never
allTeamsPresenter as any
);
});
@@ -152,7 +167,9 @@ describe('TeamService', () => {
teamRepository.findAll.mockResolvedValue([makeTeam()]);
membershipRepository.countByTeamId.mockResolvedValue(3);
await expect(service.getAll()).resolves.toEqual({
const result = await service.getAll();
await expect(result).toEqual({
teams: [
{
id: 'team-1',
@@ -161,6 +178,7 @@ describe('TeamService', () => {
description: 'Desc',
memberCount: 3,
leagues: ['league-1'],
logoUrl: null,
},
],
totalCount: 1,
@@ -200,6 +218,8 @@ describe('TeamService', () => {
description: 'Desc',
ownerId: 'owner-1',
leagues: ['league-1'],
category: undefined,
isRecruiting: false,
createdAt: '2023-01-01T00:00:00.000Z',
},
membership: {
@@ -503,6 +523,8 @@ describe('TeamService', () => {
description: 'Desc',
ownerId: 'owner-1',
leagues: ['league-1'],
category: undefined,
isRecruiting: false,
createdAt: '2023-01-01T00:00:00.000Z',
},
membership: {

View File

@@ -37,9 +37,8 @@ import { CreateTeamPresenter } from './presenters/CreateTeamPresenter';
import { UpdateTeamPresenter } from './presenters/UpdateTeamPresenter';
// Tokens
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN, MEDIA_REPOSITORY_TOKEN, RESULT_REPOSITORY_TOKEN } from './TeamTokens';
import { TEAM_REPOSITORY_TOKEN, TEAM_MEMBERSHIP_REPOSITORY_TOKEN, DRIVER_REPOSITORY_TOKEN, LOGGER_TOKEN, TEAM_STATS_REPOSITORY_TOKEN, RESULT_REPOSITORY_TOKEN } from './TeamTokens';
import type { ITeamStatsRepository } from '@core/racing/domain/repositories/ITeamStatsRepository';
import type { IMediaRepository } from '@core/racing/domain/repositories/IMediaRepository';
import type { IResultRepository } from '@core/racing/domain/repositories/IResultRepository';
@Injectable()
@@ -50,7 +49,6 @@ export class TeamService {
@Inject(DRIVER_REPOSITORY_TOKEN) private readonly driverRepository: IDriverRepository,
@Inject(LOGGER_TOKEN) private readonly logger: Logger,
@Inject(TEAM_STATS_REPOSITORY_TOKEN) private readonly teamStatsRepository: ITeamStatsRepository,
@Inject(MEDIA_REPOSITORY_TOKEN) private readonly mediaRepository: IMediaRepository,
@Inject(RESULT_REPOSITORY_TOKEN) private readonly resultRepository: IResultRepository,
private readonly allTeamsPresenter: AllTeamsPresenter,
) {}
@@ -62,7 +60,6 @@ export class TeamService {
this.teamRepository,
this.membershipRepository,
this.teamStatsRepository,
this.mediaRepository,
this.resultRepository,
this.logger,
this.allTeamsPresenter

View File

@@ -5,4 +5,5 @@ export const IMAGE_SERVICE_TOKEN = 'IImageServicePort';
export const LOGGER_TOKEN = 'Logger';
export const TEAM_STATS_REPOSITORY_TOKEN = 'ITeamStatsRepository';
export const MEDIA_REPOSITORY_TOKEN = 'IMediaRepository';
export const MEDIA_RESOLVER_TOKEN = 'MediaResolverPort';
export const RESULT_REPOSITORY_TOKEN = 'IResultRepository';

View File

@@ -145,8 +145,8 @@ export class TeamMemberViewModel {
@ApiProperty()
isActive!: boolean;
@ApiProperty()
avatarUrl!: string;
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}
export class TeamMembersViewModel {
@@ -185,8 +185,8 @@ export class TeamJoinRequestViewModel {
@ApiProperty()
requestedAt!: string;
@ApiProperty()
avatarUrl!: string;
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}
export class TeamJoinRequestsViewModel {
@@ -339,4 +339,4 @@ export class TeamDTO {
@IsOptional()
@IsString()
createdAt?: string;
}
}

View File

@@ -13,13 +13,12 @@ export class TeamJoinRequestDTO {
@ApiProperty()
teamId!: string;
@ApiProperty({ enum: ['pending', 'approved', 'rejected'] })
@ApiProperty()
status!: 'pending' | 'approved' | 'rejected';
@ApiProperty()
requestedAt!: string;
@ApiProperty()
avatarUrl!: string;
}
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}

View File

@@ -40,13 +40,12 @@ export class TeamListItemDTO {
@ApiProperty({ required: false })
category?: string | undefined;
@ApiProperty({ required: false })
logoUrl?: string;
@ApiProperty({ nullable: true })
logoUrl: string | null = null;
@ApiProperty({ required: false })
rating?: number;
@ApiProperty()
isRecruiting!: boolean;
}
}

View File

@@ -7,7 +7,7 @@ export class TeamMemberDTO {
@ApiProperty()
driverName!: string;
@ApiProperty({ enum: ['owner', 'manager', 'member'] })
@ApiProperty()
role!: 'owner' | 'manager' | 'member';
@ApiProperty()
@@ -16,7 +16,6 @@ export class TeamMemberDTO {
@ApiProperty()
isActive!: boolean;
@ApiProperty()
avatarUrl!: string;
}
@ApiProperty({ nullable: true })
avatarUrl!: string | null;
}

View File

@@ -2,36 +2,53 @@ import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPo
import type { GetAllTeamsResult } from '@core/racing/application/use-cases/GetAllTeamsUseCase';
import { GetAllTeamsOutputDTO } from '../dtos/GetAllTeamsOutputDTO';
import { TeamListItemDTO } from '../dtos/TeamListItemDTO';
import type { MediaResolverPort } from '@core/ports/media/MediaResolverPort';
import { MediaReference } from '@core/domain/media/MediaReference';
export class AllTeamsPresenter implements UseCaseOutputPort<GetAllTeamsResult> {
private model: GetAllTeamsOutputDTO | null = null;
private mediaResolver?: MediaResolverPort;
setMediaResolver(resolver: MediaResolverPort): void {
this.mediaResolver = resolver;
}
reset(): void {
this.model = null;
}
present(result: GetAllTeamsResult): void {
const teams: TeamListItemDTO[] = result.teams.map(team => {
const dto = new TeamListItemDTO();
dto.id = team.id;
dto.name = team.name;
dto.tag = team.tag;
dto.description = team.description || '';
dto.memberCount = team.memberCount;
dto.leagues = team.leagues || [];
dto.totalWins = team.totalWins ?? 0;
dto.totalRaces = team.totalRaces ?? 0;
dto.performanceLevel = (team.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro') ?? 'intermediate';
dto.specialization = (team.specialization as 'endurance' | 'sprint' | 'mixed') ?? 'mixed';
dto.region = team.region ?? '';
dto.languages = team.languages ?? [];
// Return relative URL for proxying through Next.js rewrites
dto.logoUrl = `/api/media/teams/${team.id}/logo`;
dto.rating = team.rating ?? 0;
dto.category = team.category;
dto.isRecruiting = team.isRecruiting;
return dto;
});
async present(result: GetAllTeamsResult): Promise<void> {
const teams: TeamListItemDTO[] = await Promise.all(
result.teams.map(async (team) => {
const dto = new TeamListItemDTO();
dto.id = team.id;
dto.name = team.name;
dto.tag = team.tag;
dto.description = team.description || '';
dto.memberCount = team.memberCount;
dto.leagues = team.leagues || [];
dto.totalWins = team.totalWins ?? 0;
dto.totalRaces = team.totalRaces ?? 0;
dto.performanceLevel = (team.performanceLevel as 'beginner' | 'intermediate' | 'advanced' | 'pro') ?? 'intermediate';
dto.specialization = (team.specialization as 'endurance' | 'sprint' | 'mixed') ?? 'mixed';
dto.region = team.region ?? '';
dto.languages = team.languages ?? [];
// Resolve logo URL using MediaResolverPort if available
if (this.mediaResolver && team.logoRef) {
const ref = team.logoRef instanceof MediaReference ? team.logoRef : MediaReference.fromJSON(team.logoRef);
dto.logoUrl = await this.mediaResolver.resolve(ref);
} else {
// Fallback to existing logoUrl or null
dto.logoUrl = team.logoUrl ?? null;
}
dto.rating = team.rating ?? 0;
dto.category = team.category;
dto.isRecruiting = team.isRecruiting;
return dto;
})
);
this.model = {
teams,