harden media
This commit is contained in:
@@ -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],
|
||||
},
|
||||
];
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user