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
14 changes: 14 additions & 0 deletions src/module/gamification/persistence/gamification-dao.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,20 @@ export class GamificationDao {
.exec();
}

async updateBadgeImageUrl(
projectId: string,
badgeId: string,
imageUrl: string,
): Promise<GamificationTemplate | null> {
return this.gamificationModel
.findOneAndUpdate(
{ projectId, 'badges._id': badgeId },
{ $set: { 'badges.$.imageUrl': imageUrl } },
{ new: true },
)
.exec();
}

/**
* Moves a badge rule through the fading lifecycle, keeping the window
* fields consistent with the status so `effectiveBadgeStatus` never has
Expand Down
81 changes: 81 additions & 0 deletions src/module/project/project-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { LeaderboardService } from '../leaderboard/leaderboard.service';
import { BadgeRule } from '../gamification/entities/gamification.entity';
import { getModelToken } from '@nestjs/mongoose';
import { CheckInTemplate } from '../checkin/persistence/checkin.schema';
import { StorageService } from '../storage/storage.service';
import { GamificationDao } from '../gamification/persistence/gamification-dao.service';

const mockCheckInModel = {
find: jest.fn().mockReturnThis(),
Expand All @@ -37,6 +39,18 @@ const mockLeaderboardService = {
getLeaderboardFor: jest.fn(),
};

const mockStorageService = {
uploadFile: jest.fn(),
optimizeImage: jest.fn(),
getFile: jest.fn(),
};

const mockGamificationDao = {
getBadgesByProject: jest.fn(),
updateBadge: jest.fn(),
updateBadgeImageUrl: jest.fn(),
};

describe('ProjectService', () => {
let service: ProjectService;

Expand All @@ -47,6 +61,8 @@ describe('ProjectService', () => {
{ provide: ProjectDao, useValue: mockProjectDao },
{ provide: UserService, useValue: mockUserService },
{ provide: LeaderboardService, useValue: mockLeaderboardService },
{ provide: StorageService, useValue: mockStorageService },
{ provide: GamificationDao, useValue: mockGamificationDao },
{
provide: getModelToken(CheckInTemplate.collectionName()),
useValue: mockCheckInModel,
Expand Down Expand Up @@ -225,4 +241,69 @@ describe('ProjectService', () => {
expect(mockProjectDao.findOne).toHaveBeenCalledWith('p1');
});
});

describe('migrateImages', () => {
it('should migrate external project cover and badge images', async () => {
const project = ProjectBuilder.build();
project.image = 'https://example.com/cover.png';
mockProjectDao.findOne.mockResolvedValue(project);

const badgesDoc = {
badges: [
{
_id: 'b1',
name: 'Pioneer',
imageUrl: 'https://example.com/badge1.png',
},
{
_id: 'b2',
name: 'Local',
imageUrl: 'badges/internal.jpg', // already migrated
},
],
};
mockGamificationDao.getBadgesByProject.mockResolvedValue(badgesDoc);

jest.spyOn(service, 'fetchImageBuffer').mockResolvedValue({
buffer: Buffer.from('fake-image-bytes'),
mimetype: 'image/png',
});

mockStorageService.optimizeImage.mockImplementation(async (f) => f);
mockStorageService.uploadFile
.mockResolvedValueOnce('projects/p1/cover.jpg')
.mockResolvedValueOnce('badges/b1.jpg');

const result = await service.migrateImages('p1');

expect(result.projectImageMigrated).toBe(true);
expect(result.badgesMigratedCount).toBe(1);
expect(result.failures).toHaveLength(0);
expect(mockProjectDao.update).toHaveBeenCalledWith('p1', {
image: 'projects/p1/cover.jpg',
});
expect(mockGamificationDao.updateBadgeImageUrl).toHaveBeenCalledWith(
'p1',
'b1',
'badges/b1.jpg',
);
});

it('should record failures gracefully when fetch fails', async () => {
const project = ProjectBuilder.build();
project.image = 'https://broken.com/cover.png';
mockProjectDao.findOne.mockResolvedValue(project);
mockGamificationDao.getBadgesByProject.mockResolvedValue({ badges: [] });

jest
.spyOn(service, 'fetchImageBuffer')
.mockRejectedValue(new Error('Network error'));

const result = await service.migrateImages('p1');

expect(result.projectImageMigrated).toBe(false);
expect(result.failures).toHaveLength(1);
expect(result.failures[0]).toContain('Project cover: Network error');
});
});
});
12 changes: 12 additions & 0 deletions src/module/project/project.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ describe('ProjectController', () => {
findOnePublic: jest.fn(),
update: jest.fn(),
toggleAvailable: jest.fn(),
migrateImages: jest.fn(),
};

const mockUserService = {};
Expand Down Expand Up @@ -81,4 +82,15 @@ describe('ProjectController', () => {
await controller.toggleAvailable('1');
expect(service.toggleAvailable).toHaveBeenCalledWith('1');
});

it('should call migrateImages', async () => {
mockProjectService.migrateImages = jest.fn().mockResolvedValue({
projectId: '1',
projectImageMigrated: true,
badgesMigratedCount: 0,
failures: [],
});
await controller.migrateImages('1');
expect(mockProjectService.migrateImages).toHaveBeenCalledWith('1');
});
});
7 changes: 7 additions & 0 deletions src/module/project/project.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,11 @@ export class ProjectController {
toggleAvailable(@Param('id') id: string) {
return this.projectService.toggleAvailable(id);
}

@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.Admin)
@Post('/:id/migrate-images')
migrateImages(@Param('id') id: string) {
return this.projectService.migrateImages(id);
}
}
2 changes: 2 additions & 0 deletions src/module/project/project.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
CheckInSchema,
CheckInTemplate,
} from '../checkin/persistence/checkin.schema';
import { StorageModule } from '../storage/storage.module';

@Module({
imports: [
Expand All @@ -22,6 +23,7 @@ import {
]),
AuthModule,
GamificationModule,
StorageModule,
],
controllers: [ProjectController],
providers: [ProjectService, ProjectDao],
Expand Down
182 changes: 182 additions & 0 deletions src/module/project/project.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ import {
} from '../checkin/persistence/checkin.schema';
import { CheckinMapper } from '../checkin/persistence/CheckinMapper';
import { BasicBadgeEngine } from '../gamification/entities/engine/gamification/basic-badge-engine';
import { StorageService } from '../storage/storage.service';
import { GamificationDao } from '../gamification/persistence/gamification-dao.service';
import http from 'http';
import https from 'https';

export interface UserStatus {
isSubscribed: boolean;
Expand All @@ -37,6 +41,13 @@ export interface UserStatus {
leaderboard: Leaderboard;
}

export interface ImageMigrationResult {
projectId: string;
projectImageMigrated: boolean;
badgesMigratedCount: number;
failures: string[];
}

@Injectable()
export class ProjectService {
constructor(
Expand All @@ -45,6 +56,8 @@ export class ProjectService {
private readonly leaderboardService: LeaderboardService,
@InjectModel(CheckInTemplate.collectionName())
private readonly checkInModel: Model<CheckInDocument>,
private readonly storageService: StorageService,
private readonly gamificationDao: GamificationDao,
) {}

async findAll(): Promise<(ProjectTemplate & { _id: string })[]> {
Expand Down Expand Up @@ -202,4 +215,173 @@ export class ProjectService {
findOnePublic(id: string) {
return this.projectDao.findOne(id);
}

/**
* Downloads an image from an external HTTP/HTTPS URL with timeout and size cap (10MB).
*/
async fetchImageBuffer(
url: string,
timeoutMs = 8000,
): Promise<{ buffer: Buffer; mimetype: string }> {
return new Promise((resolve, reject) => {
const client = url.startsWith('https') ? https : http;
const req = client.get(
url,
{
timeout: timeoutMs,
headers: {
'User-Agent':
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Rayuela/1.0',
},
},
(res) => {
if (
res.statusCode &&
(res.statusCode === 301 || res.statusCode === 302) &&
res.headers.location
) {
return this.fetchImageBuffer(res.headers.location, timeoutMs)
.then(resolve)
.catch(reject);
}

if (
!res.statusCode ||
res.statusCode < 200 ||
res.statusCode >= 300
) {
return reject(
new Error(`Failed to fetch image: HTTP status ${res.statusCode}`),
);
}

const contentType = res.headers['content-type'] || 'image/jpeg';
const chunks: Buffer[] = [];
let totalLength = 0;
const maxBytes = 10 * 1024 * 1024; // 10MB limit

res.on('data', (chunk: Buffer) => {
totalLength += chunk.length;
if (totalLength > maxBytes) {
req.destroy(new Error('Image exceeds 10MB size limit'));
return;
}
chunks.push(chunk);
});

res.on('end', () => {
resolve({
buffer: Buffer.concat(chunks),
mimetype: contentType,
});
});
},
);

req.on('timeout', () => {
req.destroy(new Error(`Timeout fetching image after ${timeoutMs}ms`));
});

req.on('error', (err) => {
reject(err);
});
});
}

/**
* Migrates external project cover image and badge images into Garage S3.
*/
async migrateImages(projectId: string): Promise<ImageMigrationResult> {
const project = await this.projectDao.findOne(projectId);
if (!project) {
throw new NotFoundException('Project not found');
}

const failures: string[] = [];
let projectImageMigrated = false;
let badgesMigratedCount = 0;

// 1. Migrate Project Cover Image
if (
project.image &&
(project.image.startsWith('http://') ||
project.image.startsWith('https://'))
) {
try {
const { buffer, mimetype } = await this.fetchImageBuffer(project.image);
const ext = mimetype.includes('png')
? 'png'
: mimetype.includes('webp')
? 'webp'
: 'jpg';
const optimized = await this.storageService.optimizeImage(
{ buffer, mimetype, originalname: `project-cover.${ext}` },
{ maxDimension: 1600, quality: 80 },
);
const key = await this.storageService.uploadFile(
optimized,
`projects/${projectId}`,
);
await this.projectDao.update(projectId, { image: key });
projectImageMigrated = true;
} catch (err: any) {
failures.push(`Project cover: ${err?.message || err}`);
}
}

// 2. Migrate Badge Images
const gamificationDoc =
await this.gamificationDao.getBadgesByProject(projectId);
if (
gamificationDoc &&
gamificationDoc.badges &&
gamificationDoc.badges.length > 0
) {
for (const badge of gamificationDoc.badges) {
if (
badge.imageUrl &&
(badge.imageUrl.startsWith('http://') ||
badge.imageUrl.startsWith('https://'))
) {
try {
const { buffer, mimetype } = await this.fetchImageBuffer(
badge.imageUrl,
);
const ext = mimetype.includes('png')
? 'png'
: mimetype.includes('webp')
? 'webp'
: 'jpg';
const optimized = await this.storageService.optimizeImage(
{
buffer,
mimetype,
originalname: `${badge.name || 'badge'}.${ext}`,
},
{ maxDimension: 512, quality: 85 },
);
const key = await this.storageService.uploadFile(
optimized,
'badges',
);
await this.gamificationDao.updateBadgeImageUrl(
projectId,
String(badge._id),
key,
);
badgesMigratedCount++;
} catch (err: any) {
failures.push(`Badge "${badge.name}": ${err?.message || err}`);
}
}
}
}

return {
projectId,
projectImageMigrated,
badgesMigratedCount,
failures,
};
}
}
Loading
Loading