Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
169 changes: 169 additions & 0 deletions src/lib/apis/meal.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
'use server';

import { camelToSnakeObject, snakeToCamelObject } from '@/lib/utils/camelize.util';
import { getServerClient } from '@/lib/utils/supabase/server.util';
import {
CreateMealDetailDTO,
CreateMealDTO,
MealDetailDTO,
MealDetailSnakeCaseDTO,
MealDTO,
MealOverviewDTO,
MealOverviewSnakeCaseDTO,
MealSnakeCaseDTO
} from '@/types/DTO/meal.dto';

/**
* 특정 기간 내의 사용자 식사 기록을 모든 상세 정보와 함께 조회한다.
*
* @param {string} startDate - 조회 시작 날짜 ex) 2023-10-15
* @param {string} endDate - 조회 종료 날짜 ex) 2023-10-15
* @returns {MealDTO[]} 사용자의 식사 정보 배열 (MealDTO[])
* @throws Supabase 쿼리 실행 중 오류가 발생한 경우 Error
*/
export const getAllMyMealsByPeriod = async (startDate: string, endDate: string): Promise<MealDTO[]> => {
const supabase = getServerClient();

const startDateTime = `${startDate}T00:00:00Z`;
const endDateTime = `${endDate}T23:59:59.999Z`;

const { data, error } = await supabase
.from('meals')
.select(` *, meal_details (*) `)
.gte('ate_at', startDateTime)
.lte('ate_at', endDateTime)
.order('ate_at', { ascending: false });
if (error) throw error;
Comment on lines +30 to +36
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

로그인한 유저 기준이 아니라 전체 식사데이터를 불러오고 있는것 같습니다
저희가 전체 기간에 모든 유저의 데이터를 받아오는 경우가 있을까요?
아니라면 .eq(user_id, userId) 조건이 필요할 것 같습니다!

Copy link
Owner Author

@llddang llddang Apr 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ㅋㅋㅋㅋ 이 질문 기다렸습니다!

저렇게만 되어 있어도 자신이 작성한 내용만 가져오게끔됩니다.
왜냐! supabase의 RLS SELECT 설정에서 자신이 작성한 글만 조회가능하도록 제한을 걸어놨거든요!
그래서 자신이 작성한 글 외에는 조회 자체가 안되게 되어서 client에서 한 번 더 조건을 작성할 필요가 없게된겁니다.
image

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아니 이런 방법이...? 미쳤네요! supabase에서 저런 설정이 가능했군요

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 그럼 유저 프로필도 굳이 id 대조안하고 자기 정보만 가져오게 할 수 있겠당!


return snakeToCamelObject<MealSnakeCaseDTO[]>(data);
};

/**
* 특정 날의 사용자 식사 기록을 모든 상세 정보와 함께 조회한다.
*
* @param {string} date - 조회 날짜 ex) 2023-10-15
* @returns {MealDTO[]} 사용자의 식사 정보 배열 (MealDTO[])
* @throws Supabase 쿼리 실행 중 오류가 발생한 경우 Error
*/
export const getMyMealByDate = async (date: string): Promise<MealDTO[]> => {
const supabase = getServerClient();

const { data, error } = await supabase
.from('meals')
.select(` *, meal_details (*) `)
.gte('ate_at', `${date}T00:00:00Z`)
.lt('ate_at', `${date}T24:00:00Z`)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

24:00:00 으로 사용하려면 date가 아니라 다음 날이 되어야 하지 않나요 ?! ${date}T23:59:59.999Z 으로 들어가야 하는 것 아닌지 여쭤봅니다!

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분의 조건문이 equal이 없는 less than이기 때문에 괜찮습니다!
지윤님 말씀대로 ${date}T23:59:59.999Z를 작성하고, lte를 사용하는 것과 동일하게 동작합니다!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그런가요..? 제가 찾아본 바에 따르면 표준 시간 형식(ISO 8601 포함)에서는 시간을 00:00:00부터 23:59:59까지 표현해서 24:00:00이라는 표기는 다음 날에 해당한다고 해서 여쭤봤습니다! 제가 생각할 때
25년 4월 4일의 00시는 23시 59분보다 더 앞시간이라고 생각했어요!

Copy link
Collaborator

@jiyxxn jiyxxn Apr 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아하~ 해당 표기가 비표준이긴 하지만 데이터베이스 쿼리 상에서는 다음 날의 ${date+1}T00:00:00Z와 동일하게 해석된다고 하네요!

.order('ate_at', { ascending: false });
if (error) throw error;
Comment on lines +51 to +57
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서도 해당유저의 원하는 날짜의 식사가 아닌
입력된 기간에 있는 모든 meal 데이터를 불러오고 있는 것 같습니다!

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

위 질문의 답변과 동일합니다!


return snakeToCamelObject<MealSnakeCaseDTO[]>(data);
};

/**
* mealId를 기준으로 사용자 식사 기록을 모든 상세 정보와 함께 조회한다.
*
* @param {string} mealId - 조회하려고 하는 meal ID
* @returns {MealDTO} 사용자의 식사 정보 (MealDTO)
* @throws Supabase 쿼리 실행 중 오류가 발생한 경우 Error
*/
export const getMyMealById = async (mealId: string): Promise<MealDTO> => {
const supabase = getServerClient();

const { data, error } = await supabase
.from('meals')
.select(` *, meal_details (*) `)
.eq('id', mealId)
.order('ate_at', { ascending: false })
.single();
if (error) throw error;

return snakeToCamelObject<MealSnakeCaseDTO>(data);
};

/**
* 새로운 식사 데이터를 데이터베이스에 생성합니다.
*
* @param {CreateMealDTO} meal - 생성할 식사 정보가 담긴 DTO 객체
* @returns {Promise<MealOverviewDTO>} 생성된 식사 정보
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
*/
const createMeal = async (meal: CreateMealDTO): Promise<MealOverviewDTO> => {
const supabase = getServerClient();
const mealSnakeCase = camelToSnakeObject(meal);
const { data, error } = await supabase.from('meals').insert(mealSnakeCase).select().single();
if (error) throw error;
return snakeToCamelObject<MealOverviewSnakeCaseDTO>(data);
};

/**
* 새로운 상세 식사 데이터를 데이터베이스에 생성합니다.
*
* @param {string} mealId - 생성할 식사 정보와 관련된 ID
* @param {CreateMealDetailDTO[]} mealDetails - 생성할 식사 정보가 담긴 DTO 객체
* @returns {Promise<MealDetailDTO[]>} 생성된 상세 식사 정보
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
*/
const createMealDetails = async (mealId: string, mealDetails: CreateMealDetailDTO[]): Promise<MealDetailDTO[]> => {
const supabase = getServerClient();
const mealDetailsRequest = mealDetails.map((mealDetail) => ({ meal_id: mealId, ...camelToSnakeObject(mealDetail) }));
const { data, error } = await supabase.from('meal_details').insert(mealDetailsRequest).select();
if (error) throw error;
return snakeToCamelObject<MealDetailSnakeCaseDTO[]>(data);
};

/**
* 새로운 기본 식사 정보와 상세 식사 정보를 데이터베이스에 함께 생성합니다.
*
* @param {CreateMealDTO} mealData - 생성할 식사 정보가 담긴 객체
* @param {CreateMealDetailDTO[]} mealDetails - 생성할 식사 정보가 담긴 DTO 객체
* @returns {Promise<MealDTO>} 생성된 식사 정보
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
*/
export const createMealWithDetails = async (
mealData: CreateMealDTO,
mealDetails: CreateMealDetailDTO[]
): Promise<MealDTO> => {
const meal = await createMeal(mealData);
const mealDetailResponse = await createMealDetails(meal.id, mealDetails);
return { ...meal, mealDetails: mealDetailResponse };
};

/**
* 특정 식사 데이터를 데이터베이스에 수정합니다.
*
* @param {string} mealId - 수정할 식사 정보의 ID
* @param {Partial<CreateMealDTO>} meal - 수정할 식사 정보가 담긴 DTO 객체
* @returns {Promise<MealOverviewDTO|null>} 수정된 식사의 정보 객체 또는 수정 실패 시 null
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
*/
export const updateMeal = async (mealId: string, meal: Partial<CreateMealDTO>): Promise<MealOverviewDTO> => {
const supabase = getServerClient();
const mealSnakeCase = camelToSnakeObject(meal);
const { data, error } = await supabase.from('meals').update(mealSnakeCase).eq('id', mealId).select().single();
if (error) throw error;
return snakeToCamelObject<MealOverviewSnakeCaseDTO>(data);
};

/**
* 특정 식사 데이터를 데이터베이스에 삭제합니다.
*
* @param {string} mealId - 삭제할 식사 정보의 ID
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
*/
export const deleteMeal = async (mealId: string) => {
const supabase = getServerClient();
const { error } = await supabase.from('meals').delete().eq('id', mealId);
if (error) throw error;
};

/**
* 특정 상세 식사 데이터를 데이터베이스에서 삭제합니다.
*
* @param {string} mealDetailId - 삭제할 식사 상세 정보의 ID
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
*/
export const deleteMealDetail = async (mealDetailId: string) => {
const supabase = getServerClient();
const { error } = await supabase.from('meal_details').delete().eq('id', mealDetailId);
if (error) throw error;
};
33 changes: 33 additions & 0 deletions src/lib/apis/storage.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use server';

import { getServerClient } from '@/lib/utils/supabase/server.util';
import { sanitizeFilename } from '@/lib/utils/filename.util';
import { categoriesError, ErrorResponse } from '@/types/error.type';
import { SupabaseBucketValue } from '@/types/supabase-bucket.type';

/**
* Supabase 스토리지의 지정된 버킷에 이미지 파일을 업로드합니다.
*
* 이 함수는 파일명을 고유한 UUID와 안전하게 처리된 원본 파일명의 조합으로 생성하고,
* 파일을 업로드한 후 공개 URL을 반환합니다.
*
* @async
* @param {SupabaseBucketValue} bucketName - 업로드할 Supabase 버킷 이름 ('meal' 또는 'profile-image')
* @param {FormData} formData - 업로드할 파일 객체
* @returns {Promise<ErrorResponse<string>>} 성공 시 data에 업로드된 파일의 공개 URL을 포함하고, 실패 시 error에 오류 정보를 포함하는 객체
*/
export const uploadImage = async (
bucketName: SupabaseBucketValue,
formData: FormData
): Promise<ErrorResponse<string>> => {
const supabase = getServerClient();

const file = formData.get('file') as File;
const filename = crypto.randomUUID() + sanitizeFilename(file.name);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일이름을 정제하는 것을 sanitize라고 하는군요!


const { error: uploadError } = await supabase.storage.from(bucketName).upload(filename, file);
if (uploadError) return { data: null, error: categoriesError(uploadError) };

const { data: fileUrl } = supabase.storage.from(bucketName).getPublicUrl(filename);
return { data: fileUrl.publicUrl, error: null };
};
33 changes: 33 additions & 0 deletions src/lib/apis/user.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use server';

import { getAuth } from '@/lib/apis/auth-server.api';
import { camelToSnakeObject, snakeToCamelObject } from '@/lib/utils/camelize.util';
import { getServerClient } from '@/lib/utils/supabase/server.util';
import { UpdateUserDTO, UserDTO } from '@/types/DTO/user.dto';

/**
* 자신의 정보를 불러오는 함수
*
* @returns {Promise<UserDTO>} 자신의 정보가 담긴 객체
*/
export const getUser = async (): Promise<UserDTO> => {
const supabase = getServerClient();
const { data, error } = await supabase.from('users').select().single();
if (error) throw error;
return snakeToCamelObject(data);
};

/**
* 유저 정보를 변경하는 함수
*
* @param {Partial<UpdateUserDTO>} userInfo 수정할 일부 정보
* @returns {Promise<UserDTO>} 변경 이수 수정된 값
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

변경 이수

*/
export const updateUser = async (userInfo: Partial<UpdateUserDTO>): Promise<UserDTO> => {
const supabase = getServerClient();
const { id: userId } = await getAuth();
const userInfoSnakeCase = camelToSnakeObject(userInfo);
const { data, error } = await supabase.from('users').update(userInfoSnakeCase).eq('id', userId).select().single();
if (error) throw error;
return snakeToCamelObject(data);
};
54 changes: 48 additions & 6 deletions src/lib/utils/camelize.util.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import { CamelCaseObject } from '@/types/common.type';
import { CamelCaseObject, SnakeCaseObject } from '@/types/common.type';

export const toCamelCase = (str: string): string => str.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
export const camelize = <T>(obj: T): CamelCaseObject<T> => {
/**
* snake_case를 camelCase로 변환하는 함수
* @param {string} str - 변환할 snake_case 문자열
* @returns {string} - 변환된 camelCase 문자열
*/
export const snakeToCamel = (str: string): string => str.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());

/**
* 객체의 모든 키를 snake_case에서 camelCase로 재귀적으로 변환
* @param {any} obj - 변환할 객체
* @returns {any} - 키가 camelCase로 변환된 객체
*/
export const snakeToCamelObject = <T>(obj: T): CamelCaseObject<T> => {
if (obj === null || obj === undefined) {
return obj as unknown as CamelCaseObject<T>;
}

if (Array.isArray(obj)) {
return obj.map((item) => camelize(item)) as unknown as CamelCaseObject<T>;
return obj.map((item) => snakeToCamelObject(item)) as unknown as CamelCaseObject<T>;
}

if (typeof obj !== 'object') {
Expand All @@ -16,10 +27,41 @@ export const camelize = <T>(obj: T): CamelCaseObject<T> => {

const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = toCamelCase(key);
if (typeof value === 'object') result[newKey] = camelize(value);
const newKey = snakeToCamel(key);
if (typeof value === 'object') result[newKey] = snakeToCamelObject(value);
else result[newKey] = value;
}

return result as CamelCaseObject<T>;
};

/**
* camelCase를 snake_case로 변환하는 함수
* @param {string} str - 변환할 camelCase 문자열
* @returns {string} - 변환된 snake_case 문자열
*/
export const camelToSnake = (str: string): string => str.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`);

/**
* 객체의 모든 키를 camelCase에서 snake_case로 재귀적으로 변환
* @param {any} obj - 변환할 객체
* @returns {any} - 키가 snake_case로 변환된 객체
*/
export function camelToSnakeObject<T extends object>(obj: T): SnakeCaseObject<T> {
if (obj === null || typeof obj !== 'object') {
return obj;
}

if (Array.isArray(obj)) {
return obj.map((item) => camelToSnakeObject(item)) as unknown as SnakeCaseObject<T>;
}

const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
const newKey = camelToSnake(key);
if (typeof value === 'object') result[newKey] = snakeToCamelObject(value);
else result[newKey] = value;
}

return result as SnakeCaseObject<T>;
}
75 changes: 75 additions & 0 deletions src/lib/utils/filename.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* 파일명이 S3 객체 키로 안전한 문자만 포함하는지 검증합니다.
*
* 이 함수는 파일명이 Amazon S3 객체 키에서 특별한 처리 없이 사용할 수 있는
* 안전한 문자와 특별한 처리가 필요하지만 여전히 유효한 일부 문자만 포함하는지 확인합니다.
*
* 안전한 문자는 다음과 같습니다:
* - 영숫자 (a-z, A-Z, 0-9)
* - 밑줄 (_)
* - 슬래시 (/)
* - 느낌표 (!)
* - 하이픈 (-)
* - 마침표 (.)
* - 별표 (*)
* - 작은따옴표 (')
* - 괄호 ( () )
* - 공백 ( )
* - 앰퍼샌드 (&)
* - 달러 기호 ($)
* - 골뱅이 (@)
* - 등호 (=)
* - 세미콜론 (;)
* - 콜론 (:)
* - 더하기 기호 (+)
* - 쉼표 (,)
* - 물음표 (?)
*
* @see {@link https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html AWS S3 객체 키 문서}
*
* @param {string} filename - 검증할 파일명
* @returns {boolean} - 파일명이 S3 안전 문자만 포함하면 true, 그렇지 않으면 false 반환
*/
export const isValidFilename = (filename: string): boolean => {
// only allow s3 safe characters and characters which require special handling for now
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
return /^(\w|\/|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/.test(filename);
};

/**
* 파일명을 S3 객체 키에서 사용 가능한 안전한 형식으로 변환합니다.
*
* 이 함수는 S3에서 허용되지 않는 문자를 제거하거나 안전한 문자로 대체합니다.
* 특별한 처리가 필요한 문자들도 적절히 처리합니다.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

특별한 처리가 필요한 문자란게 &$@=;:+,?를 말씀하신 것 같습니다.
이 문자들을 특별하게 처리하진 않고 안전한 문자로 같이 처리하신거죠?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아 제가 코드로 참고 링크를 남긴다고 메모도 해두었는데 까먹었습니다!

참고 링크 :

&$@=;:+,? 해당 문자들은 사용가능한 축에 속합니다!
대체 되는 특수 기호는 AWS 참고자료에 가면 더 볼 수 있습니다!

*
* @param {string} filename - 변환할 원본 파일명
* @param {string} [replacement='_'] - 안전하지 않은 문자를 대체할 문자
* @returns {string} S3에서 사용 가능한 안전한 파일명
*/
export function sanitizeFilename(filename: string, replacement: string = '_'): string {
if (!filename || typeof filename !== 'string') {
return '';
}
const safeChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_/!-.*'() &$@=;:+,?";

let sanitized = '';
for (const char of filename) {
sanitized += safeChars.includes(char) ? char : replacement;
}

// 시작과 끝의 공백 제거
sanitized = sanitized.trim();

// 연속된 대체 문자를 하나로 압축 (예: '___' -> '_')
const regexPattern = new RegExp(`\\${replacement}{2,}`, 'g');
sanitized = sanitized.replace(regexPattern, replacement);

// 파일명 시작/끝에 있는 대체 문자 제거
const startEndPattern = new RegExp(`^\\${replacement}|\\${replacement}$`, 'g');
sanitized = sanitized.replace(startEndPattern, '');

// 빈 문자열이 되면 기본값 설정
if (sanitized === '') sanitized = 'file';

return sanitized;
}
Loading