Skip to content
Merged
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-prettier": "^5.2.1",
"globals": "^16.0.0",
"jest": "30.1.2",
"jest": "30.1.3",
"prettier": "^3.4.2",
"rimraf": "^6.0.1",
"source-map-support": "^0.5.21",
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { WalletModule } from "modules/wallet/wallet.module";
import { EmailModule } from "modules/email/email.module";
import { InventoryModule } from "modules/inventory/inventory.module";
import { TeamModule } from "modules/team/team.module";
import { PhotoModule } from "modules/photo/photo.module";

@Module({
imports: [
Expand Down Expand Up @@ -100,6 +101,7 @@ import { TeamModule } from "modules/team/team.module";
EmailModule,
InventoryModule,
TeamModule,
PhotoModule,

// WebSocket
SocketModule,
Expand Down
7 changes: 6 additions & 1 deletion src/common/config/bucket.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,20 @@ import {
InvoiceBucketConfig,
ResumeBucketConfig,
ReimbursementFormBucketConfig,
PhotoBucketConfig,
} from "common/gcp";
import { ConfigToken } from "common/config/config.constants";

export const bucketConfig = registerAs<
InvoiceBucketConfig & ResumeBucketConfig & ReimbursementFormBucketConfig
InvoiceBucketConfig &
ResumeBucketConfig &
ReimbursementFormBucketConfig &
PhotoBucketConfig
>(ConfigToken.BUCKET, () => {
return {
invoice_bucket: process.env.INVOICE_BUCKET,
reimbursement_form_bucket: process.env.REIMBURSEMENT_FORM_BUCKET,
resume_bucket: process.env.RESUME_BUCKET,
photo_bucket: process.env.PHOTO_BUCKET,
};
});
4 changes: 4 additions & 0 deletions src/common/gcp/google-cloud.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export type ReimbursementFormBucketConfig = {
reimbursement_form_bucket?: string;
};

export type PhotoBucketConfig = {
photo_bucket?: string;
};

export type GoogleCloudCoreModuleOptions = {
imports?: any[];
useFactory: (...args: any[]) => FirebaseConfig;
Expand Down
95 changes: 95 additions & 0 deletions src/modules/photo/photo.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import {
BadRequestException,
Controller,
Get,
InternalServerErrorException,
Post,
UseInterceptors,
} from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { ApiDoc } from "common/docs";
import { Role, Roles } from "common/gcp";
import { FileInterceptor } from "@nestjs/platform-express";
import { nanoid } from "nanoid";
import { PhotoService } from "./photo.service";
import { UploadedPhoto } from "./uploaded-photo.decorator";

@ApiTags("Photos")
@Controller("photos")
export class PhotoController {
constructor(private readonly photoService: PhotoService) {}

@Post("/upload")
@Roles(Role.NONE)
@UseInterceptors(FileInterceptor("photo"))
@ApiDoc({
summary: "Upload a photo",
request: {
mimeTypes: ["multipart/form-data"],
},
response: {
created: {
description: "Photo uploaded successfully",
schema: {
type: "object",
properties: {
photoId: { type: "string" },
photoUrl: { type: "string" },
},
},
},
},
})
async uploadPhoto(
@UploadedPhoto() photo: Express.Multer.File,
): Promise<{ photoId: string; photoUrl: string }> {
if (!photo) {
throw new BadRequestException("Photo is required");
}

const photoId = nanoid(32);

try {
const photoUrl = await this.photoService.uploadPhoto(photoId, photo);
return {
photoId,
photoUrl,
};
} catch (error) {
console.error("Error uploading photo:", error);
throw new InternalServerErrorException("Failed to upload photo");
}
}

@Get("/")
@Roles(Role.NONE)
@ApiDoc({
summary: "Get all photos",
response: {
ok: {
description: "List of all photos",
schema: {
type: "array",
items: {
type: "object",
properties: {
name: { type: "string" },
url: { type: "string" },
createdAt: { type: "string", format: "date-time" },
},
},
},
},
},
})
async getAllPhotos(): Promise<
{ name: string; url: string; createdAt: Date }[]
> {
try {
return await this.photoService.getAllPhotos();
} catch (error) {
console.error("Error fetching photos:", error);
throw new InternalServerErrorException("Failed to fetch photos");
}
}
}
12 changes: 12 additions & 0 deletions src/modules/photo/photo.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { PhotoController } from "./photo.controller";
import { PhotoService } from "./photo.service";
import { ConfigModule } from "@nestjs/config";

@Module({
imports: [ConfigModule],
controllers: [PhotoController],
providers: [PhotoService],
exports: [PhotoService],
})
export class PhotoModule {}
63 changes: 63 additions & 0 deletions src/modules/photo/photo.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { Injectable } from "@nestjs/common";
import { PhotoBucketConfig } from "common/gcp";
import * as admin from "firebase-admin";
import { ConfigToken } from "common/config";
import { ConfigService } from "@nestjs/config";

@Injectable()
export class PhotoService {
private photoBucketName: string;

constructor(private readonly configService: ConfigService) {
this.photoBucketName = this.configService.get<PhotoBucketConfig>(
ConfigToken.BUCKET,
).photo_bucket;
}

private get photoBucket() {
return admin.storage().bucket(this.photoBucketName);
}

private getPhotoFileName(photoId: string, originalName: string): string {
const extension = originalName.split(".").pop();
return `${photoId}.${extension}`;
}

getPhotoFile(photoId: string, originalName: string) {
return this.photoBucket.file(this.getPhotoFileName(photoId, originalName));
}

private getPublicPhotoUrl(filename: string): string {
return `https://storage.googleapis.com/${this.photoBucketName}/${filename}`;
}

async uploadPhoto(
photoId: string,
file: Express.Multer.File,
): Promise<string> {
const filename = this.getPhotoFileName(photoId, file.originalname);
const blob = this.photoBucket.file(filename);

await blob.save(file.buffer, {
metadata: {
contentType: file.mimetype,
},
});

return this.getPublicPhotoUrl(filename);
}

async getAllPhotos(): Promise<
{ name: string; url: string; createdAt: Date }[]
> {
const [files] = await this.photoBucket.getFiles();

return files.map((file) => ({
name: file.name,
url: this.getPublicPhotoUrl(file.name),
createdAt: file.metadata.timeCreated
? new Date(file.metadata.timeCreated)
: new Date(),
}));
}
}
14 changes: 14 additions & 0 deletions src/modules/photo/uploaded-photo.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ParseFilePipeBuilder, UploadedFile } from "@nestjs/common";

export function UploadedPhoto(): ParameterDecorator {
return UploadedFile(
new ParseFilePipeBuilder()
.addFileTypeValidator({
fileType: /(jpg|jpeg|png|gif|webp|heic|heif|tiff|bmp|svg|mp4|mov|avi|wmv|flv|mkv|webm|m4v|mpg|mpeg|3gp)$/i,
})
.addMaxSizeValidator({
maxSize: 100 * 1024 * 1024, // 100MB for videos
})
.build({ fileIsRequired: true }),
);
}
Loading