Skip to content

Commit 8e6265d

Browse files
authored
Merge pull request #32 from llddang/Feature/#29-supabase-api
Feature/#29 supabase api
2 parents 1d2f14f + 1c365c1 commit 8e6265d

File tree

11 files changed

+453
-13
lines changed

11 files changed

+453
-13
lines changed

src/lib/apis/meal.api.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
'use server';
2+
3+
import { camelToSnakeObject, snakeToCamelObject } from '@/lib/utils/camelize.util';
4+
import { getServerClient } from '@/lib/utils/supabase/server.util';
5+
import {
6+
CreateMealDetailDTO,
7+
CreateMealDTO,
8+
MealDetailDTO,
9+
MealDetailSnakeCaseDTO,
10+
MealDTO,
11+
MealOverviewDTO,
12+
MealOverviewSnakeCaseDTO,
13+
MealSnakeCaseDTO
14+
} from '@/types/DTO/meal.dto';
15+
16+
/**
17+
* 특정 기간 내의 사용자 식사 기록을 모든 상세 정보와 함께 조회한다.
18+
*
19+
* @param {string} startDate - 조회 시작 날짜 ex) 2023-10-15
20+
* @param {string} endDate - 조회 종료 날짜 ex) 2023-10-15
21+
* @returns {MealDTO[]} 사용자의 식사 정보 배열 (MealDTO[])
22+
* @throws Supabase 쿼리 실행 중 오류가 발생한 경우 Error
23+
*/
24+
export const getAllMyMealsByPeriod = async (startDate: string, endDate: string): Promise<MealDTO[]> => {
25+
const supabase = getServerClient();
26+
27+
const startDateTime = `${startDate}T00:00:00Z`;
28+
const endDateTime = `${endDate}T23:59:59.999Z`;
29+
30+
const { data, error } = await supabase
31+
.from('meals')
32+
.select(` *, meal_details (*) `)
33+
.gte('ate_at', startDateTime)
34+
.lte('ate_at', endDateTime)
35+
.order('ate_at', { ascending: false });
36+
if (error) throw error;
37+
38+
return snakeToCamelObject<MealSnakeCaseDTO[]>(data);
39+
};
40+
41+
/**
42+
* 특정 날의 사용자 식사 기록을 모든 상세 정보와 함께 조회한다.
43+
*
44+
* @param {string} date - 조회 날짜 ex) 2023-10-15
45+
* @returns {MealDTO[]} 사용자의 식사 정보 배열 (MealDTO[])
46+
* @throws Supabase 쿼리 실행 중 오류가 발생한 경우 Error
47+
*/
48+
export const getMyMealByDate = async (date: string): Promise<MealDTO[]> => {
49+
const supabase = getServerClient();
50+
51+
const { data, error } = await supabase
52+
.from('meals')
53+
.select(` *, meal_details (*) `)
54+
.gte('ate_at', `${date}T00:00:00Z`)
55+
.lt('ate_at', `${date}T24:00:00Z`)
56+
.order('ate_at', { ascending: false });
57+
if (error) throw error;
58+
59+
return snakeToCamelObject<MealSnakeCaseDTO[]>(data);
60+
};
61+
62+
/**
63+
* mealId를 기준으로 사용자 식사 기록을 모든 상세 정보와 함께 조회한다.
64+
*
65+
* @param {string} mealId - 조회하려고 하는 meal ID
66+
* @returns {MealDTO} 사용자의 식사 정보 (MealDTO)
67+
* @throws Supabase 쿼리 실행 중 오류가 발생한 경우 Error
68+
*/
69+
export const getMyMealById = async (mealId: string): Promise<MealDTO> => {
70+
const supabase = getServerClient();
71+
72+
const { data, error } = await supabase
73+
.from('meals')
74+
.select(` *, meal_details (*) `)
75+
.eq('id', mealId)
76+
.order('ate_at', { ascending: false })
77+
.single();
78+
if (error) throw error;
79+
80+
return snakeToCamelObject<MealSnakeCaseDTO>(data);
81+
};
82+
83+
/**
84+
* 새로운 식사 데이터를 데이터베이스에 생성합니다.
85+
*
86+
* @param {CreateMealDTO} meal - 생성할 식사 정보가 담긴 DTO 객체
87+
* @returns {Promise<MealOverviewDTO>} 생성된 식사 정보
88+
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
89+
*/
90+
const createMeal = async (meal: CreateMealDTO): Promise<MealOverviewDTO> => {
91+
const supabase = getServerClient();
92+
const mealSnakeCase = camelToSnakeObject(meal);
93+
const { data, error } = await supabase.from('meals').insert(mealSnakeCase).select().single();
94+
if (error) throw error;
95+
return snakeToCamelObject<MealOverviewSnakeCaseDTO>(data);
96+
};
97+
98+
/**
99+
* 새로운 상세 식사 데이터를 데이터베이스에 생성합니다.
100+
*
101+
* @param {string} mealId - 생성할 식사 정보와 관련된 ID
102+
* @param {CreateMealDetailDTO[]} mealDetails - 생성할 식사 정보가 담긴 DTO 객체
103+
* @returns {Promise<MealDetailDTO[]>} 생성된 상세 식사 정보
104+
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
105+
*/
106+
const createMealDetails = async (mealId: string, mealDetails: CreateMealDetailDTO[]): Promise<MealDetailDTO[]> => {
107+
const supabase = getServerClient();
108+
const mealDetailsRequest = mealDetails.map((mealDetail) => ({ meal_id: mealId, ...camelToSnakeObject(mealDetail) }));
109+
const { data, error } = await supabase.from('meal_details').insert(mealDetailsRequest).select();
110+
if (error) throw error;
111+
return snakeToCamelObject<MealDetailSnakeCaseDTO[]>(data);
112+
};
113+
114+
/**
115+
* 새로운 기본 식사 정보와 상세 식사 정보를 데이터베이스에 함께 생성합니다.
116+
*
117+
* @param {CreateMealDTO} mealData - 생성할 식사 정보가 담긴 객체
118+
* @param {CreateMealDetailDTO[]} mealDetails - 생성할 식사 정보가 담긴 DTO 객체
119+
* @returns {Promise<MealDTO>} 생성된 식사 정보
120+
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
121+
*/
122+
export const createMealWithDetails = async (
123+
mealData: CreateMealDTO,
124+
mealDetails: CreateMealDetailDTO[]
125+
): Promise<MealDTO> => {
126+
const meal = await createMeal(mealData);
127+
const mealDetailResponse = await createMealDetails(meal.id, mealDetails);
128+
return { ...meal, mealDetails: mealDetailResponse };
129+
};
130+
131+
/**
132+
* 특정 식사 데이터를 데이터베이스에 수정합니다.
133+
*
134+
* @param {string} mealId - 수정할 식사 정보의 ID
135+
* @param {Partial<CreateMealDTO>} meal - 수정할 식사 정보가 담긴 DTO 객체
136+
* @returns {Promise<MealOverviewDTO|null>} 수정된 식사의 정보 객체 또는 수정 실패 시 null
137+
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
138+
*/
139+
export const updateMeal = async (mealId: string, meal: Partial<CreateMealDTO>): Promise<MealOverviewDTO> => {
140+
const supabase = getServerClient();
141+
const mealSnakeCase = camelToSnakeObject(meal);
142+
const { data, error } = await supabase.from('meals').update(mealSnakeCase).eq('id', mealId).select().single();
143+
if (error) throw error;
144+
return snakeToCamelObject<MealOverviewSnakeCaseDTO>(data);
145+
};
146+
147+
/**
148+
* 특정 식사 데이터를 데이터베이스에 삭제합니다.
149+
*
150+
* @param {string} mealId - 삭제할 식사 정보의 ID
151+
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
152+
*/
153+
export const deleteMeal = async (mealId: string) => {
154+
const supabase = getServerClient();
155+
const { error } = await supabase.from('meals').delete().eq('id', mealId);
156+
if (error) throw error;
157+
};
158+
159+
/**
160+
* 특정 상세 식사 데이터를 데이터베이스에서 삭제합니다.
161+
*
162+
* @param {string} mealDetailId - 삭제할 식사 상세 정보의 ID
163+
* @throws {Error} 데이터베이스 오류 발생 시 에러를 던집니다
164+
*/
165+
export const deleteMealDetail = async (mealDetailId: string) => {
166+
const supabase = getServerClient();
167+
const { error } = await supabase.from('meal_details').delete().eq('id', mealDetailId);
168+
if (error) throw error;
169+
};

src/lib/apis/storage.api.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use server';
2+
3+
import { getServerClient } from '@/lib/utils/supabase/server.util';
4+
import { sanitizeFilename } from '@/lib/utils/filename.util';
5+
import { categoriesError, ErrorResponse } from '@/types/error.type';
6+
import { SupabaseBucketValue } from '@/types/supabase-bucket.type';
7+
8+
/**
9+
* Supabase 스토리지의 지정된 버킷에 이미지 파일을 업로드합니다.
10+
*
11+
* 이 함수는 파일명을 고유한 UUID와 안전하게 처리된 원본 파일명의 조합으로 생성하고,
12+
* 파일을 업로드한 후 공개 URL을 반환합니다.
13+
*
14+
* @async
15+
* @param {SupabaseBucketValue} bucketName - 업로드할 Supabase 버킷 이름 ('meal' 또는 'profile-image')
16+
* @param {FormData} formData - 업로드할 파일 객체
17+
* @returns {Promise<ErrorResponse<string>>} 성공 시 data에 업로드된 파일의 공개 URL을 포함하고, 실패 시 error에 오류 정보를 포함하는 객체
18+
*/
19+
export const uploadImage = async (
20+
bucketName: SupabaseBucketValue,
21+
formData: FormData
22+
): Promise<ErrorResponse<string>> => {
23+
const supabase = getServerClient();
24+
25+
const file = formData.get('file') as File;
26+
const filename = crypto.randomUUID() + sanitizeFilename(file.name);
27+
28+
const { error: uploadError } = await supabase.storage.from(bucketName).upload(filename, file);
29+
if (uploadError) return { data: null, error: categoriesError(uploadError) };
30+
31+
const { data: fileUrl } = supabase.storage.from(bucketName).getPublicUrl(filename);
32+
return { data: fileUrl.publicUrl, error: null };
33+
};

src/lib/apis/user.api.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use server';
2+
3+
import { getAuth } from '@/lib/apis/auth-server.api';
4+
import { camelToSnakeObject, snakeToCamelObject } from '@/lib/utils/camelize.util';
5+
import { getServerClient } from '@/lib/utils/supabase/server.util';
6+
import { UpdateUserDTO, UserDTO } from '@/types/DTO/user.dto';
7+
8+
/**
9+
* 자신의 정보를 불러오는 함수
10+
*
11+
* @returns {Promise<UserDTO>} 자신의 정보가 담긴 객체
12+
*/
13+
export const getUser = async (): Promise<UserDTO> => {
14+
const supabase = getServerClient();
15+
const { data, error } = await supabase.from('users').select().single();
16+
if (error) throw error;
17+
return snakeToCamelObject(data);
18+
};
19+
20+
/**
21+
* 유저 정보를 변경하는 함수
22+
*
23+
* @param {Partial<UpdateUserDTO>} userInfo 수정할 일부 정보
24+
* @returns {Promise<UserDTO>} 변경 이후 수정된 값
25+
*/
26+
export const updateUser = async (userInfo: Partial<UpdateUserDTO>): Promise<UserDTO> => {
27+
const supabase = getServerClient();
28+
const { id: userId } = await getAuth();
29+
const userInfoSnakeCase = camelToSnakeObject(userInfo);
30+
const { data, error } = await supabase.from('users').update(userInfoSnakeCase).eq('id', userId).select().single();
31+
if (error) throw error;
32+
return snakeToCamelObject(data);
33+
};

src/lib/utils/camelize.util.ts

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,24 @@
1-
import { CamelCaseObject } from '@/types/common.type';
1+
import { CamelCaseObject, SnakeCaseObject } from '@/types/common.type';
22

3-
export const toCamelCase = (str: string): string => str.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
4-
export const camelize = <T>(obj: T): CamelCaseObject<T> => {
3+
/**
4+
* snake_case를 camelCase로 변환하는 함수
5+
* @param {string} str - 변환할 snake_case 문자열
6+
* @returns {string} - 변환된 camelCase 문자열
7+
*/
8+
export const snakeToCamel = (str: string): string => str.replace(/_([a-z])/g, (_, char: string) => char.toUpperCase());
9+
10+
/**
11+
* 객체의 모든 키를 snake_case에서 camelCase로 재귀적으로 변환
12+
* @param {any} obj - 변환할 객체
13+
* @returns {any} - 키가 camelCase로 변환된 객체
14+
*/
15+
export const snakeToCamelObject = <T>(obj: T): CamelCaseObject<T> => {
516
if (obj === null || obj === undefined) {
617
return obj as unknown as CamelCaseObject<T>;
718
}
819

920
if (Array.isArray(obj)) {
10-
return obj.map((item) => camelize(item)) as unknown as CamelCaseObject<T>;
21+
return obj.map((item) => snakeToCamelObject(item)) as unknown as CamelCaseObject<T>;
1122
}
1223

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

1728
const result: Record<string, unknown> = {};
1829
for (const [key, value] of Object.entries(obj)) {
19-
const newKey = toCamelCase(key);
20-
if (typeof value === 'object') result[newKey] = camelize(value);
30+
const newKey = snakeToCamel(key);
31+
if (typeof value === 'object') result[newKey] = snakeToCamelObject(value);
2132
else result[newKey] = value;
2233
}
2334

2435
return result as CamelCaseObject<T>;
2536
};
37+
38+
/**
39+
* camelCase를 snake_case로 변환하는 함수
40+
* @param {string} str - 변환할 camelCase 문자열
41+
* @returns {string} - 변환된 snake_case 문자열
42+
*/
43+
export const camelToSnake = (str: string): string => str.replace(/[A-Z]/g, (char) => `_${char.toLowerCase()}`);
44+
45+
/**
46+
* 객체의 모든 키를 camelCase에서 snake_case로 재귀적으로 변환
47+
* @param {any} obj - 변환할 객체
48+
* @returns {any} - 키가 snake_case로 변환된 객체
49+
*/
50+
export function camelToSnakeObject<T extends object>(obj: T): SnakeCaseObject<T> {
51+
if (obj === null || typeof obj !== 'object') {
52+
return obj;
53+
}
54+
55+
if (Array.isArray(obj)) {
56+
return obj.map((item) => camelToSnakeObject(item)) as unknown as SnakeCaseObject<T>;
57+
}
58+
59+
const result: Record<string, unknown> = {};
60+
for (const [key, value] of Object.entries(obj)) {
61+
const newKey = camelToSnake(key);
62+
if (typeof value === 'object') result[newKey] = camelToSnakeObject(value);
63+
else result[newKey] = value;
64+
}
65+
66+
return result as SnakeCaseObject<T>;
67+
}

src/lib/utils/filename.util.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* 파일명이 S3 객체 키로 안전한 문자만 포함하는지 검증합니다.
3+
*
4+
* 이 함수는 파일명이 Amazon S3 객체 키에서 특별한 처리 없이 사용할 수 있는
5+
* 안전한 문자와 특별한 처리가 필요하지만 여전히 유효한 일부 문자만 포함하는지 확인합니다.
6+
*
7+
* 안전한 문자는 다음과 같습니다:
8+
* - 영숫자 (a-z, A-Z, 0-9)
9+
* - 밑줄 (_)
10+
* - 슬래시 (/)
11+
* - 느낌표 (!)
12+
* - 하이픈 (-)
13+
* - 마침표 (.)
14+
* - 별표 (*)
15+
* - 작은따옴표 (')
16+
* - 괄호 ( () )
17+
* - 공백 ( )
18+
* - 앰퍼샌드 (&)
19+
* - 달러 기호 ($)
20+
* - 골뱅이 (@)
21+
* - 등호 (=)
22+
* - 세미콜론 (;)
23+
* - 콜론 (:)
24+
* - 더하기 기호 (+)
25+
* - 쉼표 (,)
26+
* - 물음표 (?)
27+
*
28+
* @see {@link https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html AWS S3 객체 키 문서}
29+
*
30+
* @param {string} filename - 검증할 파일명
31+
* @returns {boolean} - 파일명이 S3 안전 문자만 포함하면 true, 그렇지 않으면 false 반환
32+
*/
33+
export const isValidFilename = (filename: string): boolean => {
34+
// only allow s3 safe characters and characters which require special handling for now
35+
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html
36+
return /^(\w|\/|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/.test(filename);
37+
};
38+
39+
/**
40+
* 파일명을 S3 객체 키에서 사용 가능한 안전한 형식으로 변환합니다.
41+
*
42+
* 이 함수는 S3에서 허용되지 않는 문자를 제거하거나 안전한 문자로 대체합니다.
43+
* 특별한 처리가 필요한 문자들도 적절히 처리합니다.
44+
*
45+
* @param {string} filename - 변환할 원본 파일명
46+
* @param {string} [replacement='_'] - 안전하지 않은 문자를 대체할 문자
47+
* @returns {string} S3에서 사용 가능한 안전한 파일명
48+
*/
49+
export function sanitizeFilename(filename: string, replacement: string = '_'): string {
50+
if (!filename || typeof filename !== 'string') {
51+
return '';
52+
}
53+
const safeChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_/!-.*'() &$@=;:+,?";
54+
55+
let sanitized = '';
56+
for (const char of filename) {
57+
sanitized += safeChars.includes(char) ? char : replacement;
58+
}
59+
60+
// 시작과 끝의 공백 제거
61+
sanitized = sanitized.trim();
62+
63+
// 연속된 대체 문자를 하나로 압축 (예: '___' -> '_')
64+
const regexPattern = new RegExp(`\\${replacement}{2,}`, 'g');
65+
sanitized = sanitized.replace(regexPattern, replacement);
66+
67+
// 파일명 시작/끝에 있는 대체 문자 제거
68+
const startEndPattern = new RegExp(`^\\${replacement}|\\${replacement}$`, 'g');
69+
sanitized = sanitized.replace(startEndPattern, '');
70+
71+
// 빈 문자열이 되면 기본값 설정
72+
if (sanitized === '') sanitized = 'file';
73+
74+
return sanitized;
75+
}

0 commit comments

Comments
 (0)