Skip to content
This repository was archived by the owner on Feb 18, 2025. It is now read-only.

feat: seller publish items #26

Merged
merged 13 commits into from
Oct 6, 2024
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
2 changes: 1 addition & 1 deletion .github/workflows/ci-item-service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ jobs:
uses: actions/checkout@v4

- name: Start containers
run: docker compose -f container-compose.test.yaml up -d
run: docker compose -f docker-compose.test.yaml up -d

- name: Test with coverage
run: docker exec nshm-item-test-api-1 bun test --coverage
6 changes: 5 additions & 1 deletion services/item/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Refer to `src/global.d.ts` for explanations of each environment variable.
PORT=
JWT_SECRET=
MONGO_DB_URI=
MONGO_DB_NAME=
JWT_SECRET=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=
S3_BUCKET_NAME=
2 changes: 2 additions & 0 deletions services/item/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
dist/
node_modules/
resources/
uploads/
*.tsbuildinfo
4 changes: 2 additions & 2 deletions services/item/Containerfile → services/item/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM docker.io/oven/bun AS build
FROM oven/bun AS build

WORKDIR /app

Expand All @@ -10,7 +10,7 @@ COPY . .

RUN bun run build

FROM docker.io/oven/bun AS production
FROM oven/bun AS production

WORKDIR /app

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM docker.io/oven/bun
FROM oven/bun

WORKDIR /app

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM docker.io/oven/bun
FROM oven/bun

WORKDIR /app

Expand Down
Binary file modified services/item/bun.lockb
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ services:
api:
build:
context: .
dockerfile: Containerfile.test
dockerfile: Dockerfile.test
environment:
- JWT_SECRET=nshm-item-service
- MONGO_DB_URI=mongodb://mongo:27017
- MONGO_DB_NAME=nshm
depends_on:
Expand All @@ -14,7 +15,7 @@ services:
- bridge

mongo:
image: docker.io/library/mongo
image: mongo
environment:
- MONGO_INITDB_DATABASE=nshm
volumes:
Expand Down
1 change: 1 addition & 0 deletions services/item/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"start": "bun dist/index.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.665.0",
"bun-compression": "^0.0.4",
"hono": "^4.6.3",
"hono-rate-limiter": "^0.4.0",
Expand Down
28 changes: 26 additions & 2 deletions services/item/src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ declare module "bun" {
*/
PORT?: number;

/**
* The secret key for JWT decoding.
* Required.
*/
JWT_SECRET: string;

/**
* The URI of the MongoDB database.
* Format: mongodb://<username>:<password>@<host>:<port>
Expand All @@ -20,9 +26,27 @@ declare module "bun" {
MONGO_DB_NAME: string;

/**
* The secret key for JWT decoding.
* The access key ID for the S3 bucket.
* Required.
*/
JWT_SECRET: string;
S3_ACCESS_KEY_ID: string;

/**
* The secret access key for the S3 bucket.
* Required.
*/
S3_SECRET_ACCESS_KEY: string;

/**
* The name of the S3 bucket.
* Required.
*/
S3_BUCKET_NAME: string;

/**
* The region of the S3 bucket.
* Required.
*/
S3_REGION: string;
}
}
24 changes: 19 additions & 5 deletions services/item/src/items/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,21 +29,35 @@ itemsController.get(
},
);

const fileSchema = z.custom<File>((data) => {
return (
data instanceof File &&
["image/jpeg", "image/png", "image/webp", "image/avif"].includes(
data.type,
) &&
data.size <= 5 * 1024 * 1024
);
});

itemsController.post(
"/",
auth(true),
validator(
"json",
"form",
z.object({
name: z.string().min(1).max(50),
description: z.string().min(1).max(500),
price: z.coerce.number().positive(),
photo_urls: z.array(z.string().url()).max(5),
photos: z
.array(fileSchema)
.max(5)
.or(fileSchema.transform((file) => [file]))
.default([]),
}),
),
async (c) => {
const dto = c.req.valid("json");
const item = await itemsService.createItem(dto, c.var.user);
return c.json(item, 201);
const form = c.req.valid("form");
const result = await itemsService.createItem(form, c.var.user);
return c.json(result, 201);
},
);
4 changes: 2 additions & 2 deletions services/item/src/items/repository.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ItemStatus, type Item, type SingleItem } from "@/types";
import { itemsCollection } from "@/utils/db";
import type { Filter, FindOptions } from "mongodb";
import { UUID, type Filter, type FindOptions } from "mongodb";

/**
* Data access layer for items.
Expand Down Expand Up @@ -34,7 +34,7 @@ type InsertOneDto = {
async function insertOne(dto: InsertOneDto) {
const { insertedId } = await itemsCollection.insertOne({
...dto,
id: crypto.randomUUID(),
id: new UUID().toString(),
type: "single",
status: ItemStatus.FOR_SALE,
created_at: new Date().toISOString(),
Expand Down
10 changes: 8 additions & 2 deletions services/item/src/items/service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ItemStatus, type Account } from "@/types";
import { photoManager } from "@/utils/photo-manager";
import { ObjectId } from "mongodb";
import { itemsRepository } from "./repository";

Expand Down Expand Up @@ -47,12 +48,17 @@ type CreateItemDto = {
name: string;
description: string;
price: number;
photo_urls: string[];
photos: File[];
};

async function createItem(dto: CreateItemDto, user: Account) {
const photoUrls = await Promise.all(dto.photos.map(photoManager.save));

const item = await itemsRepository.insertOne({
...dto,
name: dto.name,
description: dto.description,
price: dto.price,
photo_urls: photoUrls,
seller: {
id: user.id,
nickname: user.nickname,
Expand Down
3 changes: 3 additions & 0 deletions services/item/src/utils/photo-manager/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { PhotoManagerFactory } from "./photo-manager-factory";

export const photoManager = PhotoManagerFactory.createPhotoManager();
27 changes: 27 additions & 0 deletions services/item/src/utils/photo-manager/local-photo-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { existsSync, mkdirSync } from "fs";
import { rm, writeFile } from "fs/promises";
import { join } from "path";
import type { PhotoManager } from "./photo-manager-interface";

/**
* Store photos in local directory.
*/
export class LocalPhotoManager implements PhotoManager {
private static UPLOAD_DIR = "./uploads";

public constructor() {
if (!existsSync(LocalPhotoManager.UPLOAD_DIR)) {
mkdirSync(LocalPhotoManager.UPLOAD_DIR);
}
}

public async save(photo: File) {
const filePath = join(LocalPhotoManager.UPLOAD_DIR, photo.name);
await writeFile(filePath, new Uint8Array(await photo.arrayBuffer()));
return filePath;
}

public async remove(photoUrl: string) {
await rm(photoUrl);
}
}
16 changes: 16 additions & 0 deletions services/item/src/utils/photo-manager/photo-manager-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { LocalPhotoManager } from "./local-photo-manager";
import type { PhotoManager } from "./photo-manager-interface";
import { S3PhotoManager } from "./s3-photo-manager";

/**
* Create a photo manager based on the running environment.
*/
export class PhotoManagerFactory {
static createPhotoManager(): PhotoManager {
if (Bun.env.NODE_ENV === "production") {
return new S3PhotoManager();
}

return new LocalPhotoManager();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface PhotoManager {
save: (photo: File) => Promise<string>;
remove: (photoUrl: string) => Promise<void>;
}
48 changes: 48 additions & 0 deletions services/item/src/utils/photo-manager/s3-photo-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {
DeleteObjectCommand,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import type { PhotoManager } from "./photo-manager-interface";

/**
* Store photos in Amazon S3.
*/
export class S3PhotoManager implements PhotoManager {
private static client = new S3Client({
credentials: {
accessKeyId: Bun.env.S3_ACCESS_KEY_ID,
secretAccessKey: Bun.env.S3_SECRET_ACCESS_KEY,
},
region: Bun.env.S3_REGION,
});

private static BASE_URL = `https://${Bun.env.S3_BUCKET_NAME}.s3.${Bun.env.S3_REGION}.amazonaws.com`;

private static UPLOAD_DIR = "item-photos";

public async save(photo: File) {
const [, extension] = photo.name.split(".");

const key = `${S3PhotoManager.UPLOAD_DIR}/${crypto.randomUUID()}.${extension}`;

await S3PhotoManager.client.send(
new PutObjectCommand({
Bucket: Bun.env.S3_BUCKET_NAME,
Key: key,
Body: new Uint8Array(await photo.arrayBuffer()),
}),
);

return `${S3PhotoManager.BASE_URL}/${key}`;
}

public async remove(photoUrl: string): Promise<void> {
await S3PhotoManager.client.send(
new DeleteObjectCommand({
Bucket: Bun.env.S3_BUCKET_NAME,
Key: photoUrl.replace(`${S3PhotoManager.BASE_URL}/`, ""),
}),
);
}
}
Loading