Merge remote-tracking branch 'refs/remotes/origin/main'

This commit is contained in:
2025-12-09 03:17:53 +09:00
27 changed files with 5091 additions and 1847 deletions

1
.gitignore vendored
View File

@@ -67,3 +67,4 @@ node_modules/
# dataconnect generated files
.dataconnect
sendgrid.env

View File

@@ -439,6 +439,8 @@ import { syncBoardAndUploadsData } from '@/utils/boardUtils';
import { useUserStore } from '@/stores/user';
//types
import { UploadSettings } from '@/data/config';
import { sendBoardEmail } from '@/utils/api/sendBoardEmail';
import type {
WadizBoard,
BoardItem,
@@ -461,10 +463,12 @@ const userId = computed(() => userStore.docId);
const { $firebase } = useNuxtApp();
const wadizesCollection = $firebase.wadizesCollection;
const currentCollection = wadizesCollection;
const currentBoard = 'wadiz';
const currentBoard = 'wadizes';
const compData = {
title: '공지사항 | NOTICE',
title: 'WADIZES | 예약 신청',
};
import { generateWadizUploadedEmail } from '@/utils/emailTemplates/wadizUploaded';
//loading Message
const loadingMessage = 'Uploading! 잠시만 기다려주세요...';
const isUploading = ref(false);
@@ -598,6 +602,19 @@ const handleUpload = async () => {
})) as WadizBoard;
await handleUpdateBoard(boardPayload, currentCollection);
//sendemail
try {
const { subject, html } = generateWadizUploadedEmail(boardPayload);
await sendBoardEmail({
access: 'public',
subject,
html,
action: 'created',
});
} catch (e) {
console.warn('Notification email failed', e);
// optional: toast but dont abort the UX
}
showAlert('성공적으로 수정되었습니다', 'bg-green-800');
emit('success');
} else {
@@ -628,7 +645,22 @@ const handleUpload = async () => {
'yyyy-MM-dd'
);
}
//sendEmail
try {
const { subject, html } = generateWadizUploadedEmail(boardPayload);
await sendBoardEmail({
access: 'public',
subject,
html,
action: 'created',
});
} catch (e) {
console.warn('Notification email failed', e);
// optional: toast but dont abort the UX
}
await handleCreateBoard(boardPayload, currentCollection);
showAlert('성공적으로 생성되었습니다', 'bg-green-800', true);
emit('success');
}

View File

@@ -0,0 +1,156 @@
<template>
<div>
<li
class="py-3 transition duration-300 hover:bg-gray-50 dark:hover:bg-gray-800 items-start border-b border-gray-200 dark:border-gray-700"
>
<div class="flex flex-col mx-4 justify-center md:flex-row text-normal">
<!-- Announcement icon or board number -->
<div
v-if="!showSelectBox"
class="pr-4 md:pr-8 text-gray-400 hidden md:block"
>
<template v-if="item.announcement">
<font-awesome-icon :icon="iconName" fade style="color: #990000" />
</template>
<template v-else>
{{ item.boards_number }}
</template>
</div>
<!-- Selection checkbox -->
<label class="flex items-center space-x-2">
<input
v-if="showSelectBox"
type="checkbox"
:checked="selected"
@change="selectItem"
class="mr-8 inline-block text-gray-600 dark:text-white"
/>
</label>
<!-- Title link -->
<NuxtLink
:to="`${routeName}/${item.docId}`"
class="cursor-pointer flex-1 pr-8 inline-block text-gray-600 dark:text-white hover:underline"
>
{{ truncateTitle(item.name) }}
</NuxtLink>
<!-- Display author only for Manager+ -->
<div
v-if="userRole >= ROLE_THRESHOLD.MANAGER"
class="text-xs text-gray-400 flex items-center pr-4"
>
{{ userDisplayName }}
</div>
<!-- Date -->
<div class="inline-block text-gray-400 text-left md:hidden">
{{ formatDate(item.created) }}
</div>
<div class="hidden md:inline-block text-gray-400 text-left">
{{ formatDate2(item.created) }}
</div>
</div>
</li>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue';
import { fetchUserDisplayName } from '@/utils/boardUtils';
import { useUserStore } from '@/stores/user';
import { ROLE_THRESHOLD } from '@/data/config';
import type { WadizBoard, BoardItem } from '@/types';
// Helper to normalize Firestore timestamp or other formats into JS Date
function toDate(raw: unknown): Date {
if (typeof raw === 'string') {
return new Date(raw);
}
if (
raw &&
typeof raw === 'object' &&
'toDate' in (raw as any) &&
typeof (raw as any).toDate === 'function'
) {
return (raw as any).toDate();
}
const sec = (raw as any)?.seconds ?? (raw as any)?._seconds;
if (typeof sec === 'number') {
return new Date(sec * 1000);
}
return new Date();
}
// Props
const props = defineProps<{
item: WadizBoard;
showSelectBox?: boolean;
selected?: boolean;
iconName?: [string, string];
routeName: string;
}>();
const emit = defineEmits<{
(e: 'select', item: WadizBoard): void;
}>();
// Reactive state
const userStore = useUserStore();
const userRole = computed(() => userStore.userRole);
const userDisplayName = ref('');
// Title truncation length
const truncatedLength = ref(6);
// Date formatting
const formatDate = (raw: unknown) => {
const d = toDate(raw);
const YYYY = d.getFullYear();
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
return `${YYYY}-${MM}-${DD}`;
};
const formatDate2 = (raw: unknown) => {
const d = toDate(raw);
const MM = String(d.getMonth() + 1).padStart(2, '0');
const DD = String(d.getDate()).padStart(2, '0');
return `${MM}-${DD}`;
};
// Title truncation based on screen width
const updateTruncatedLength = () => {
const w = window.innerWidth / 14;
truncatedLength.value = Math.round(Math.min(w, 60));
};
onMounted(() => {
updateTruncatedLength();
window.addEventListener('resize', updateTruncatedLength);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateTruncatedLength);
});
const truncateTitle = (t: string) =>
t.length > truncatedLength.value
? t.slice(0, truncatedLength.value) + '…'
: t;
// Fetch user display name for manager+
watch(
() => props.item.userId,
async (uid) => {
if (uid && userRole.value >= ROLE_THRESHOLD.MANAGER) {
userDisplayName.value = await fetchUserDisplayName(uid);
} else {
userDisplayName.value = uid || '';
}
},
{ immediate: true }
);
// Selection emit
const selectItem = () => {
emit('select', props.item);
};
</script>

View File

@@ -21,7 +21,7 @@ export function useBoardList<T extends BoardItem>(
title = 'Board',
itemsPerPage = 20,
defaultSort = 'desc',
access = 'public',
access,
loadingMessage = '잠시만 기다려주세요...',
} = options;
/* UI + meta */

View File

@@ -1,6 +1,21 @@
<template>
<AdminLogin />
<div v-if="userLoggedIn">
<button @click="userStore.signOut">로그아웃</button>
<NuxtLink to="/wadiz/manage">TO manage</NuxtLink>
</div>
<div v-else>
<AdminLogin />
</div>
</template>
<script setup lang="ts">
import AdminLogin from "~/components/auth/admin-login.vue";
import AdminLogin from '~/components/auth/admin-login.vue';
const userStore = useUserStore();
const userLoggedIn = computed(() => userStore.userLoggedIn);
onMounted(() => {
if (userLoggedIn.value) {
navigateTo('/');
}
});
</script>

View File

@@ -0,0 +1,447 @@
<template>
<div class="mx-auto max-w-5xl w-full py-4 px-6 items-center">
<div class="border-b border-gray-200 dark:border-gray-700 pb-8">
<div class="mx-auto mt-10 max-w-lg space-y-8">
<!-- 1) 와디즈 결제 번호 -->
<div>
<label
for="paymentId"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
와디즈 결제 번호
</label>
<VeeField
name="paymentId"
id="paymentId"
type="text"
rules="required"
v-model="board.paymentId"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage name="paymentId" class="text-red-500 text-sm mt-1" />
</div>
<!-- 2) 성함 -->
<div>
<label
for="name"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
성함
</label>
<VeeField
name="name"
id="name"
type="text"
rules="required"
v-model="board.name"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage name="name" class="text-red-500 text-sm mt-1" />
</div>
<!-- 3) 주소 -->
<div>
<label
for="address"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
주소
</label>
<VeeField
name="address"
id="address"
type="text"
rules="required"
v-model="board.address"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600"
/>
<VeeErrorMessage name="address" class="text-red-500 text-sm mt-1" />
</div>
<!-- 4) 메일 -->
<div>
<label
for="email"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
메일
</label>
<VeeField
name="email"
id="email"
type="email"
rules="required|email"
v-model="board.email"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage name="email" class="text-red-500 text-sm mt-1" />
</div>
<!-- 5) 본인 연락처 -->
<div>
<label
for="phone"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
본인 연락처 (Your Phone)
</label>
<VeeField
name="phone"
id="phone"
type="tel"
rules="required"
v-model="board.phone"
placeholder="010-1234-5678"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage name="phone" class="text-red-500 text-sm mt-1" />
</div>
<!-- 6) 긴급 연락처 -->
<div>
<label
for="emergencyPhone"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
긴급 연락처 | 본인 제3자
</label>
<VeeField
name="emergencyPhone"
id="emergencyPhone"
type="tel"
rules="required"
v-model="board.emergencyPhone"
placeholder="010-0000-0000"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600"
/>
<VeeErrorMessage
name="emergencyPhone"
class="text-red-500 text-sm mt-1"
/>
</div>
<!-- 7) 예약 일정 선택 -->
<div>
<label
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
예약 일정 선택
</label>
<div class="mt-2 flex gap-4">
<!-- 시작일 -->
<div class="w-1/2">
<label
for="scheduleStart"
class="mb-1 block text-sm text-gray-700 dark:text-gray-300"
>
시작일
</label>
</div>
<!-- 종료일 -->
<div class="w-1/2">
<label
for="scheduleEnd"
class="mb-1 block text-sm text-gray-700 dark:text-gray-300"
>
종료일
</label>
<input
v-model="board.scheduleEnd"
id="scheduleEnd"
type="text"
disabled
placeholder="자동 계산 (1박 2일)"
class="w-full rounded-md bg-gray-100 dark:bg-gray-700 px-3 py-2 text-gray-400 dark:text-gray-400 border dark:border-gray-600"
/>
</div>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
예약불가 날짜: 2025 10 3()~5(), 연박 불가 / 체류가능일 :
··
</p>
</div>
<!-- 8) 본인 참석 여부 -->
<div>
<span
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
본인 참석 여부 (Are you attending?)
</span>
<div class="mt-2 flex items-center gap-6">
<label class="inline-flex items-center">
<VeeField
name="attending"
type="radio"
:value="'yes'"
v-model="board.attending"
class="h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
<span class="ml-2 text-gray-700 dark:text-gray-300"
> (Yes)</span
>
</label>
<label class="inline-flex items-center">
<VeeField
name="attending"
type="radio"
:value="'no'"
v-model="board.attending"
class="h-4 w-4 text-indigo-600 border-gray-300 focus:ring-indigo-500"
/>
<span class="ml-2 text-gray-700 dark:text-gray-300"
>아니오 (No)</span
>
</label>
</div>
<VeeErrorMessage name="attending" class="text-red-500 text-sm mt-1" />
</div>
<!-- 8-2) If "아니오", show alternate attendee fields -->
<div v-if="board.attending === 'no'" class="space-y-4">
<!-- 대리 참석자 성함 -->
<div>
<label
for="altName"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
대리 참석자 성함 (Alternate Attendee Name)
</label>
<VeeField
name="altName"
id="altName"
type="text"
v-model="board.altName"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage name="altName" class="text-red-500 text-sm mt-1" />
</div>
<!-- 대리 참석자 연락처 -->
<div>
<label
for="altPhone"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
대리 참석자 연락처 (Alternate Attendee Phone)
</label>
<VeeField
name="altPhone"
id="altPhone"
type="tel"
v-model="board.altPhone"
placeholder="010-1234-5678"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border outline-1 outline-offset-1 outline-gray-300 dark:outline-gray-600 placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline-2 focus:outline-indigo-600"
/>
<VeeErrorMessage
name="altPhone"
class="text-red-500 text-sm mt-1"
/>
</div>
</div>
<!-- 9) 참여 동반자 정보 -->
<div>
<label
for="companions"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
참여 동반자 정보 (선택)
</label>
<VeeField
name="companions"
id="companions"
type="text"
v-model="board.companions"
placeholder="동반자 성함 등"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border dark:border-gray-600"
/>
<VeeErrorMessage
name="companions"
class="text-red-500 text-sm mt-1"
/>
</div>
<!-- 10) 알레르기나 건강 특이사항 (선택) -->
<div>
<label
for="healthNotes"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
알레르기나 건강 특이사항 (선택)
</label>
<VeeField
name="healthNotes"
id="healthNotes"
v-model="board.healthNotes"
as="textarea"
rows="2"
placeholder="예: 견과류 알레르기"
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border dark:border-gray-600"
/>
<VeeErrorMessage
name="healthNotes"
class="text-red-500 text-sm mt-1"
/>
</div>
<!-- 11) 전달하고 싶은 (선택) -->
<div>
<label
for="remarks"
class="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
전달하고 싶은 (선택)
</label>
<VeeField
name="remarks"
id="remarks"
v-model="board.remarks"
as="textarea"
rows="4"
placeholder="예: 채팅으로 연락해주세요."
class="mt-1 block w-full rounded-md bg-white dark:bg-gray-800 px-3.5 py-2 text-base text-gray-900 dark:text-gray-100 border dark:border-gray-600"
/>
<VeeErrorMessage name="remarks" class="text-red-500 text-sm mt-1" />
</div>
<!-- Submit -->
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue';
import useUserStore from '@/stores/user';
import AppBoardHeader from '@/components/boards/BoardHeader.vue';
import AppBoardAction from '@/components/boards/BoardAction.vue';
import AppBoardBody from '@/components/boards/BoardBody.vue';
import AppLoadingOverlay from '@/components/LoadingOverlay.vue';
import { loadBoardDetails, deleteSingle } from '@/utils/boardUtils';
import type {
BoardItem,
OrderByDirection,
WadizBoard,
FileItem,
} from '@/types';
const { $firebase } = useNuxtApp();
const storage = $firebase.storage;
//***Variables, Things you need to change****)
const wadizesCollection = $firebase.wadizesCollection;
import AppUploadWadizForm from '@/components/boards/wadiz/UploadWadizForm.vue';
import AppUploadWadiz from '@/pages/wadiz/upload.vue';
const currentCollection = wadizesCollection;
const currentBoardRouteName = '/wadiz';
const compData = {
title: '와디즈 | WADIZ',
routeName: '/wadiz/[docId]', // path to single notice view
itemsPerPage: 20,
defaultSort: 'desc' as OrderByDirection,
listRouteName: '/wadiz',
uploadRouteName: '/wadiz/upload',
};
//
const in_submission = ref(false);
const deletingMessage = '삭제 중! 잠시만 기다려주세요...';
//Reactive variables
const router = useRouter();
const route = useRoute(); // Access the route object
const docId = computed(() => route.params.docId as string);
const board: Ref<WadizBoard> = ref({
docId: '',
userId: '',
title: '',
description: '',
boardState: { state: 'processing' },
announcement: false,
created: '',
files: [],
//depreciated
boards_number: 0,
thumbnail: { name: '', url: '' } as FileItem,
ishidden: false,
//wadizes
name: '',
paymentId: '',
email: '',
phone: '',
address: '',
emergencyPhone: '',
scheduleStart: '',
scheduleEnd: '',
attending: 'yes',
altName: '',
altPhone: '',
companions: '',
healthNotes: '',
remarks: '',
});
const userStore = useUserStore();
const userRole = computed(() => userStore.userRole);
const toggleEdit = ref(false);
const handleDelete = async () => {
const result = await deleteSingle(board.value, currentCollection, storage);
if (result) {
router.push(currentBoardRouteName); // path string works directly in Nuxt3
}
};
const handleUpdateSuccess = async () => {
await loadBoardDetails(
board.value.docId,
board, // ref<BoardItem>
currentCollection,
router,
currentBoardRouteName,
userRole // Ref<number>
);
toggleEdit.value = false;
};
const toggleEditBoard = () => {
toggleEdit.value = !toggleEdit.value;
};
watch(docId, (newId) => {
if (!newId) return;
console.log('newId', newId);
loadBoardDetails(
newId,
board,
currentCollection,
router,
currentBoardRouteName,
userRole
);
});
onMounted(async () => {
if (docId.value) {
await loadBoardDetails(
docId.value,
board,
currentCollection,
router,
currentBoardRouteName,
userRole
);
// Anything here will run AFTER board.value has real data
console.log('after await:', board.value);
}
});
</script>
<style scoped>
.board {
word-wrap: break-word;
}
</style>

View File

@@ -0,0 +1,101 @@
<template>
<div>
<section>
<app-boards-header
:h2data="compdata.title"
v-model="selectedSort"
:sortOptions="sortOptions"
/>
</section>
<div class="text-center">
<p v-if="!userLoggedIn">You are not logged in</p>
<p v-else>You are logged in for {{ userStore.userRole }}</p>
<button @click="verifySession">verifySession</button>
</div>
<app-board-list
v-model="selectedSort"
:title="compdata.title"
:sortOptions="sortOptions"
:userRole="userRole"
:current-page="currentPage"
:total-pages="totalPages"
:page-numbers="pageNumbers"
:show-select-boxes="showSelectBoxes"
:uploadRoute="currentUploadRoute"
@toggle-select-boxes="showSelectBoxes = !showSelectBoxes"
@delete-selected="onDeleteSelected"
@go-to-page="onGoToPage"
@prev-page="onPrevPage"
@next-page="onNextPage"
:isLoading="isLoading"
:loadingMessage="loadingMessage"
>
<template #list>
<app-board-list-single
v-for="item in currentItems"
:key="item.docId"
:userId="item.userId"
:item="item"
:showSelectBox="showSelectBoxes"
@select="onToggleSelect(item)"
:iconName="['fas', 'bell']"
:routeName="currentBoardRouteName"
/>
</template>
</app-board-list>
</div>
</template>
<script setup lang="ts">
import AppBoardsHeader from '@/components/boards/BoardHeader.vue';
import AppBoardList from '@/components/boards/BoardList.vue';
import AppBoardListSingle from '@/components/boards/wadiz/WadizListSingle.vue';
import type { OrderByDirection, BoardAccessMode, WadizBoard } from '@/types';
import { useUserStore } from '@/stores/user';
import { verifySession } from '@/utils/api/verifyFromFunction';
const userStore = useUserStore();
const userLoggedIn = computed(() => userStore.userLoggedIn);
//customize
const access: BoardAccessMode = 'public';
const currentCollection = 'wadizes';
const currentUploadRoute = '/wadiz/upload';
const currentBoardRouteName = '/wadiz';
const compData = {
title: '와디즈 관리',
itemsPerPage: 20,
defaultSort: 'desc' as OrderByDirection,
};
const loading = '게시물을 불러오는 중입니다...';
import { useBoardList } from '@/composables/useBoardList';
// destructure only the things you actually need
const {
isLoading,
loadingMessage,
compdata,
currentItems,
currentPage,
pageNumbers,
selectedSort,
sortOptions,
userRole,
totalPages,
selectedItems,
showSelectBoxes,
onToggleSelect,
onDeleteSelected,
onGoToPage,
onPrevPage,
onNextPage,
} = useBoardList<WadizBoard>(currentCollection, {
title: compData.title,
itemsPerPage: compData.itemsPerPage,
defaultSort: compData.defaultSort,
access: access,
loadingMessage: loading,
});
// no more fetchBoardsAndUpdateItems or onBeforeMount
</script>

View File

@@ -75,6 +75,7 @@ export const useUserStore = defineStore('user', {
everLoggedIn = true;
const idToken = await user.getIdToken();
await createSession(idToken);
await new Promise((resolve) => setTimeout(resolve, 100)); // Wait for 100 milliseconds
this.userLoggedIn = true;
this.initializeListener();
} else if (everLoggedIn) {

View File

@@ -1,12 +1,6 @@
import { useRuntimeConfig } from '#imports';
import type {
BoardItem,
BoardAccessMode,
CursorResponse,
UseBoardListOptions,
} from '~/types';
import type { BoardItem, BoardAccessMode, CursorResponse } from '~/types';
/* ---------- params accepted by this helper --------------------- */
interface FetchBoardsParams {
sortOrder?: 'asc' | 'desc';
pageNumber?: number;
@@ -15,9 +9,11 @@ interface FetchBoardsParams {
pageToken?: string;
}
const FUNCTION_BASE = 'https://fetchboards-edvvp3hbnq-du.a.run.app';
// ⛳️ Split base URLs per function (if deployed separately)
const BASE_FETCH_BOARDS = 'https://fetchboards-edvvp3hbnq-du.a.run.app';
const BASE_FETCH_BOARDS_CURSOR =
'https://fetchboardscursor-edvvp3hbnq-du.a.run.app';
/* --------------------------------------------------------------- */
export async function fetchBoardsFromFunction<T extends BoardItem>(
collection: string,
{
@@ -29,7 +25,7 @@ export async function fetchBoardsFromFunction<T extends BoardItem>(
}: FetchBoardsParams = {}
): Promise<CursorResponse<T>> {
const isCursorMode = !!pageToken || pageNumber === undefined;
const endpoint = isCursorMode ? 'fetchBoardsCursor' : 'fetchBoards';
const endpoint = isCursorMode ? BASE_FETCH_BOARDS_CURSOR : BASE_FETCH_BOARDS;
const params: Record<string, any> = {
collection,
@@ -39,9 +35,11 @@ export async function fetchBoardsFromFunction<T extends BoardItem>(
...(isCursorMode ? { pageToken } : { pageNumber }),
};
return await $fetch<CursorResponse<T>>(`${FUNCTION_BASE}/${endpoint}`, {
const result = await $fetch(endpoint, {
method: 'GET',
params,
credentials: access !== 'public' ? 'include' : undefined,
});
return result as CursorResponse<T>;
}

View File

@@ -0,0 +1,33 @@
import { useRuntimeConfig } from '#imports';
import type { BoardItem, BoardAccessMode } from '~/types';
interface FetchSingleItemParams {
collection: string;
docId: string;
access?: BoardAccessMode;
}
const FUNCTION_BASE = 'https://fetchsingleitem-edvvp3hbnq-du.a.run.app';
/**
* Fetch a single board item from Firebase HTTPS function
*/
export async function fetchSingleItemFromFunction<T extends BoardItem>({
collection,
docId,
access = 'public',
}: FetchSingleItemParams): Promise<T> {
const params = {
collection,
docId,
access,
};
const result = await $fetch(`${FUNCTION_BASE}`, {
method: 'GET',
params,
credentials: access !== 'public' ? 'include' : undefined,
});
return result as T;
}

View File

@@ -0,0 +1,24 @@
// utils/api/sendBoardMail.ts
import type { BoardAccessMode } from '~/types';
interface SendBoardMailParams {
access?: BoardAccessMode;
subject: string;
html: string;
action?: 'created' | 'updated' | 'deleted' | string;
}
const FUNCTION_BASE = 'https://boardmail-edvvp3hbnq-du.a.run.app';
export async function sendBoardEmail({
access = 'public',
action = 'updated',
subject,
html,
}: SendBoardMailParams): Promise<void> {
await $fetch(FUNCTION_BASE, {
method: 'POST',
body: { access, subject, html, action },
credentials: access !== 'public' ? 'include' : undefined,
});
}

View File

@@ -1,3 +1,5 @@
// utils/api/verifyFromFunction.ts
const VERIFY_URL = 'https://verifysession-edvvp3hbnq-du.a.run.app';
export type VerifiedSession = {
@@ -5,9 +7,39 @@ export type VerifiedSession = {
email: string;
role: number;
};
export async function verifySession(): Promise<VerifiedSession> {
return await $fetch<VerifiedSession>(VERIFY_URL, {
method: 'GET',
credentials: 'include', // ensures session cookie is sent
});
console.log('verifySession (client): Initiating GET request to', VERIFY_URL); // Log start
try {
// We use $fetchRaw here to inspect the full response, including headers
const response = await $fetch.raw<VerifiedSession>(VERIFY_URL, {
method: 'GET',
credentials: 'include', // ensures session cookie is sent
});
console.log('verifySession (client): Response status:', response.status);
console.log('verifySession (client): Response headers:', response.headers); // Log all response headers
// To check if a specific cookie was sent by the browser in the *request* (not response):
// This is hard to do directly with $fetch on the client-side *before* the request is sent,
// you'd typically use the Network tab for that.
// If you need the response body:
const data = await response._data;
console.log('verifySession (client): Response data:', data);
if (response.status === 200 && data) {
console.log('verifySession (client): Session verification successful.');
return data;
} else {
const errorMessage = `verifySession (client): Verification failed with status ${response.status}`;
console.error(errorMessage);
throw new Error(errorMessage);
}
} catch (error) {
console.error('verifySession (client): Error during verification:', error);
// Re-throw the error so it can be caught by the calling code
throw error;
}
}

View File

@@ -149,6 +149,7 @@ export const loadBoardDetails = async (
router.push({ name: `${currentboard}list` });
}
}
console.log('board loaded', board.value);
} catch (error) {
console.error('Error loading board details:', error);
}

View File

@@ -0,0 +1,88 @@
// utils/emailTemplates/wadizUploaded.ts
import type { WadizBoard } from '~/types/boardItem';
export function generateWadizUploadedEmail(board: Partial<WadizBoard>) {
const subject = `${board.name || '신청자'}님의 예약 신청이 접수되었습니다`;
const html = `
<h2>예약 정보</h2>
<table>
${
board.name
? `<tr><td><strong>이름</strong></td><td>${board.name}</td></tr>`
: ''
}
${
board.paymentId
? `<tr><td><strong>결제 ID</strong></td><td>${board.paymentId}</td></tr>`
: ''
}
${
board.email
? `<tr><td><strong>이메일</strong></td><td>${board.email}</td></tr>`
: ''
}
${
board.phone
? `<tr><td><strong>전화번호</strong></td><td>${board.phone}</td></tr>`
: ''
}
${
board.address
? `<tr><td><strong>주소</strong></td><td>${board.address}</td></tr>`
: ''
}
${
board.emergencyPhone
? `<tr><td><strong>비상연락처</strong></td><td>${board.emergencyPhone}</td></tr>`
: ''
}
${
board.scheduleStart
? `<tr><td><strong>시작일</strong></td><td>${board.scheduleStart}</td></tr>`
: ''
}
${
board.scheduleEnd
? `<tr><td><strong>종료일</strong></td><td>${board.scheduleEnd}</td></tr>`
: ''
}
${
board.attending
? `<tr><td><strong>참석 여부</strong></td><td>${
board.attending === 'yes' ? '참석' : '불참'
}</td></tr>`
: ''
}
${
board.altName
? `<tr><td><strong>대리인 이름</strong></td><td>${board.altName}</td></tr>`
: ''
}
${
board.altPhone
? `<tr><td><strong>대리인 전화번호</strong></td><td>${board.altPhone}</td></tr>`
: ''
}
${
board.companions
? `<tr><td><strong>동반 인원</strong></td><td>${board.companions}</td></tr>`
: ''
}
${
board.healthNotes
? `<tr><td><strong>건강/알레르기</strong></td><td>${board.healthNotes}</td></tr>`
: ''
}
${
board.remarks
? `<tr><td><strong>기타 요청사항</strong></td><td>${board.remarks}</td></tr>`
: ''
}
</table>
<hr />
<p>이 메일은 예약 신청 확인용으로 자동 전송되었습니다.</p>
`;
return { subject, html };
}

5501
bobu/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,7 @@
"@nuxtjs/sitemap": "^7.2.7",
"@nuxtjs/tailwindcss": "^6.13.2",
"@pinia/nuxt": "^0.10.1",
"@sendgrid/mail": "^8.1.5",
"@vee-validate/i18n": "^4.15.0",
"@vee-validate/nuxt": "^4.15.0",
"@vee-validate/rules": "^4.15.0",
@@ -34,7 +35,7 @@
"firebase-functions": "^6.3.2",
"install": "^0.13.0",
"npm": "^11.2.0",
"nuxt": "^3.16.0",
"nuxt": "^3.17.0",
"pinia": "^3.0.2",
"vue": "^3.5.13",
"vue-router": "^4.5.0",

View File

@@ -6,7 +6,8 @@
"": {
"name": "functions",
"dependencies": {
"firebase-admin": "^13.3.0",
"@sendgrid/mail": "^8.1.5",
"firebase-admin": "^13.4.0",
"firebase-functions": "^6.3.2"
},
"devDependencies": {
@@ -1995,6 +1996,44 @@
"dev": true,
"license": "MIT"
},
"node_modules/@sendgrid/client": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.5.tgz",
"integrity": "sha512-Jqt8aAuGIpWGa15ZorTWI46q9gbaIdQFA21HIPQQl60rCjzAko75l3D1z7EyjFrNr4MfQ0StusivWh8Rjh10Cg==",
"license": "MIT",
"dependencies": {
"@sendgrid/helpers": "^8.0.0",
"axios": "^1.8.2"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/@sendgrid/helpers": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz",
"integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2"
},
"engines": {
"node": ">= 12.0.0"
}
},
"node_modules/@sendgrid/mail": {
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.5.tgz",
"integrity": "sha512-W+YuMnkVs4+HA/bgfto4VHKcPKLc7NiZ50/NH2pzO6UHCCFuq8/GNB98YJlLEr/ESDyzAaDr7lVE7hoBwFTT3Q==",
"license": "MIT",
"dependencies": {
"@sendgrid/client": "^8.1.5",
"@sendgrid/helpers": "^8.0.0"
},
"engines": {
"node": ">=12.*"
}
},
"node_modules/@sinclair/typebox": {
"version": "0.27.8",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
@@ -2869,8 +2908,7 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
@@ -2888,6 +2926,33 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/axios": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
"integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz",
"integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -3394,7 +3459,6 @@
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"optional": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -3604,9 +3668,7 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3652,7 +3714,6 @@
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.4.0"
}
@@ -3912,7 +3973,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -4654,9 +4714,9 @@
}
},
"node_modules/firebase-admin": {
"version": "13.3.0",
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.3.0.tgz",
"integrity": "sha512-MFxv86Aw2rjM/TpKwU86jN7YUFfN1jy6mREYZTLL1aW1rCpZFi4c70b9U12J9Xa4RbJkiXpWBAwth9IVSqR91A==",
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.4.0.tgz",
"integrity": "sha512-Y8DcyKK+4pl4B93ooiy1G8qvdyRMkcNFfBSh+8rbVcw4cW8dgG0VXCCTp5NUwub8sn9vSPsOwpb9tE2OuFmcfQ==",
"license": "Apache-2.0",
"dependencies": {
"@fastify/busboy": "^3.0.0",
@@ -4743,6 +4803,26 @@
"license": "ISC",
"peer": true
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -5249,7 +5329,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -7819,6 +7898,12 @@
"node": ">= 0.10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@@ -15,7 +15,8 @@
},
"main": "lib/index.js",
"dependencies": {
"firebase-admin": "^13.3.0",
"@sendgrid/mail": "^8.1.5",
"firebase-admin": "^13.4.0",
"firebase-functions": "^6.3.2"
},
"devDependencies": {

View File

@@ -4,16 +4,19 @@ import { initializeApp, getApps } from 'firebase-admin/app';
import * as cookie from 'cookie';
import { logger } from 'firebase-functions/v2';
import { cookieExpiration, corsMiddlewareHandler } from '../config';
// ✅ Admin SDK init
if (!getApps().length) {
initializeApp(); // ✅ auto uses service account in Firebase Functions
initializeApp();
}
export const createSession = onRequest(
{ region: 'asia-northeast3' },
async (req, res) => {
return corsMiddlewareHandler(req, res, async () => {
logger.info('countBoards: CORSenabled function called');
// Fix this logger message: it should be about createSession, not countBoards
logger.info('createSession: CORS-enabled function called');
if (req.method !== 'POST') {
res.status(405).send('Method not allowed');
return;
@@ -38,7 +41,7 @@ export const createSession = onRequest(
httpOnly: true,
secure: true,
maxAge: expiresIn / 1000,
sameSite: 'lax' as const,
sameSite: 'none' as const,
path: '/',
};
@@ -48,6 +51,10 @@ export const createSession = onRequest(
);
res.status(200).json({ success: true });
} catch (error) {
logger.error(
'createSession: Invalid token or session cookie creation failed',
error,
); // Added more specific logging
res.status(401).send('Invalid token');
}
});

View File

@@ -1,11 +1,11 @@
// In your verifySession.ts (the API endpoint Cloud Function)
import { onRequest } from 'firebase-functions/v2/https';
import * as cookie from 'cookie';
import { getAuth } from 'firebase-admin/auth';
import { initializeApp, getApps } from 'firebase-admin/app';
import { corsMiddlewareHandler } from '../config';
import { logger } from 'firebase-functions/v2';
import { logger } from 'firebase-functions/v2'; // Ensure logger is imported
// ✅ Ensure Admin SDK is initialized
if (!getApps().length) {
initializeApp();
}
@@ -14,17 +14,31 @@ export const verifySession = onRequest(
{ region: 'asia-northeast3' },
async (req, res) => {
return corsMiddlewareHandler(req, res, async () => {
logger.info('verifySession: Function invoked');
logger.info('verifySession: Function invoked'); // This log is already there
// --- ADD THESE LOGS ---
logger.info(
'verifySession (endpoint): Raw headers.cookie:',
req.headers.cookie,
);
const cookies = cookie.parse(req.headers.cookie || '');
logger.info('verifySession (endpoint): Parsed cookies object:', cookies);
const session = cookies.__session;
logger.info(
'verifySession (endpoint): __session cookie value:',
session ? 'Found' : 'Not Found',
);
// --- END ADDED LOGS ---
if (req.method !== 'GET') {
res.status(405).send('Method Not Allowed');
return;
}
const cookies = cookie.parse(req.headers.cookie || '');
const session = cookies.__session;
if (!session) {
logger.warn(
'verifySession (endpoint): No __session cookie found, returning 401.',
); // Specific warn
res.status(401).send({ message: 'No session cookie' });
return;
}
@@ -32,14 +46,22 @@ export const verifySession = onRequest(
try {
const decoded = await getAuth().verifySessionCookie(session, true);
const role = decoded.role || 0;
logger.info(
'verifySession (endpoint): Session verified successfully for UID:',
decoded.uid,
); // Log success
res.status(200).json({
uid: decoded.uid,
email: decoded.email,
role,
});
} catch (err) {
logger.warn('verifySession: Session invalid or expired', err);
} catch (err: unknown) {
// Use any for err to access message
logger.warn(
'verifySession (endpoint): Session invalid or expired, returning 401:',
err instanceof Error ? err.message : String(err),
); // Specific warn
res.status(401).send({ message: 'Invalid session' });
}
});

View File

@@ -0,0 +1,73 @@
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { defineSecret } from 'firebase-functions/params';
import { initializeApp, getApps } from 'firebase-admin/app';
import sgMail from '@sendgrid/mail';
import type { BoardAccessMode } from '../types/boardItem';
import { corsMiddlewareHandler, SendEmail } from '../config';
/* ─── Boiler-plate init ───────────────────────────────────────── */
if (!getApps().length) initializeApp();
const SENDGRID_API_KEY = defineSecret('SENDGRID_API_KEY');
let isSendGridInitialized = false;
/* ─── Cloud Function entry ────────────────────────────────────── */
export const boardMail = onRequest(
{
region: 'asia-northeast3',
secrets: [SENDGRID_API_KEY], // ❷ declare dependency
},
async (req, res) => {
return corsMiddlewareHandler(req, res, async () => {
/* one-time SendGrid init per cold start */
if (!isSendGridInitialized) {
const apiKey = process.env.SENDGRID_API_KEY;
if (!apiKey) {
logger.error('SendGrid API key is undefined. Check secret setup.');
throw new Error('Missing SendGrid API key');
}
sgMail.setApiKey(apiKey);
isSendGridInitialized = true;
}
/* 1. Method guard --------------------------------------------------- */
if (req.method !== 'POST') {
res.status(405).send({ message: 'Method Not Allowed' });
return;
}
/* 2. Extract params ------------------------------------------------- */
const { access = 'public', subject, html, action = '' } = req.body ?? {};
if (!['public', 'private', 'admin'].includes(access)) {
logger.warn(`Invalid access mode: ${access}`);
res.status(400).send({ message: 'Bad access mode' });
return;
}
const effectiveAccess = access as BoardAccessMode; // ❹ always public/param
logger.info('boardMail: effectiveAccess', effectiveAccess);
if (!subject || !html) {
res.status(400).send({ message: 'Missing subject or html' });
return;
}
try {
await sgMail.send({
to: SendEmail.to,
from: SendEmail.from,
subject,
html,
});
logger.info(
`[boardMail] Email sent successfully via SendGrid [${action}]`,
);
res.status(200).send({ message: 'Email sent' });
} catch (err) {
logger.error('[boardMail] Error sending email', err as Error);
res.status(500).send({ message: 'Internal Server Error' });
}
});
},
);

View File

@@ -26,32 +26,22 @@ export const fetchBoards = onRequest(
return corsMiddlewareHandler(request, response, async () => {
logger.info('fetchBoards: CORSenabled function called');
/* 1.Method guard ------------------------------------------------------------------ */
/* 1.Method guard */
if (request.method !== 'GET') {
response.status(405).send({ message: 'Method Not Allowed' });
return;
}
/* 2.Auth (optional) ---------------------------------------------------------------- */
let userRole = 0;
let userId: string | null = null;
const authUser = await verifySessionFromRequest(request);
if (authUser) {
userId = authUser.uid;
userRole = authUser.role;
}
/* 3.Read & validate query params --------------------------------------------------- */
/* 2.Read & validate query params EARLY */
const {
collection = '',
sortOrder = 'desc',
itemsPerPage = '10',
access = 'public',
access = 'public', // Default to 'public' if not provided
pageToken,
} = request.query as Record<string, string>;
// ❶collection allowlist
// ❶ collection allow-list
if (
!ALLOWED_COLLECTIONS.has(
collection as unknown as typeof ALLOWED_COLLECTIONS extends Set<
@@ -65,25 +55,45 @@ export const fetchBoards = onRequest(
return;
}
// ❷access must be one of three strings
// ❷ access must be one of three strings. Validate BEFORE auth for basic correctness.
if (!['public', 'private', 'admin'].includes(access)) {
response.status(400).send({ message: 'Bad access mode' });
return;
}
/* 3. Authentication and Authorization Check */
let userRole = 0;
let userId: string | null = null;
let authUser = null; // Initialize authUser to null
try {
authUser = await verifySessionFromRequest(request);
} catch (err) {
logger.warn('fetchBoards: Session verification failed', err);
}
if (authUser) {
userId = authUser.uid;
userRole = authUser.role;
}
// If user is NOT logged in (userId is null) AND they are requesting 'private' or 'admin' access
if (!userId && (access === 'private' || access === 'admin')) {
response
.status(401)
.send({ message: 'Unauthorized: Login required for this resource.' });
return; // Function stops here.
}
// ❸numeric arguments
const limit = Math.max(1, Math.min(Number(itemsPerPage) || 10, 100));
const order = sortOrder === 'asc' ? 'asc' : 'desc';
/* 4.Build query with access control ----------------------------------------------- */
const accessParam = (access ?? 'public') as BoardAccessMode;
const effectiveAccess = userId ? accessParam : 'public'; // force public if anonymous
let queryRef: Query<DocumentData>;
try {
queryRef = buildQueryWithAccessControl(
db.collection(collection),
effectiveAccess,
access as BoardAccessMode, // Use the original 'access'
userId,
userRole,
);
@@ -141,7 +151,7 @@ export const fetchBoards = onRequest(
)
: null;
if (effectiveAccess === 'public')
if (access === 'public')
response.set('Cache-Control', 'public,max-age=60');
response.status(200).send({ items, nextPageToken });

View File

@@ -0,0 +1,112 @@
import { onRequest } from 'firebase-functions/v2/https';
import { logger } from 'firebase-functions/v2';
import { getFirestore } from 'firebase-admin/firestore';
import { initializeApp, getApps } from 'firebase-admin/app';
import type { BoardItem, BoardAccessMode } from '../types/boardItem';
import { corsMiddlewareHandler, ALLOWED_COLLECTIONS } from '../config';
import {
isValidBoardItem,
verifySessionFromRequest,
buildQueryWithAccessControl,
} from '../utils';
if (!getApps().length) {
initializeApp();
}
const db = getFirestore();
export const fetchSingleItem = onRequest(
{ region: 'asia-northeast3' },
async (request, response) => {
return corsMiddlewareHandler(request, response, async () => {
logger.info('fetchSingleItem: CORS-enabled function called');
/* 1.Method guard ------------------------------------------------------------------ */
if (request.method !== 'GET') {
response.status(405).send({ message: 'Method Not Allowed' });
return;
}
// 2. Auth (optional)
let userRole = 0;
let userId: string | null = null;
const authUser = await verifySessionFromRequest(request);
if (authUser) {
userId = authUser.uid;
userRole = authUser.role;
}
// 3. Query params
const {
collection = '',
docId = '',
access = 'public',
} = request.query as Record<string, string>;
if (!collection || !docId) {
response.status(400).send({ message: 'Missing parameters' });
return;
}
// ❶collection allowlist
if (
!ALLOWED_COLLECTIONS.has(
collection as unknown as typeof ALLOWED_COLLECTIONS extends Set<
infer U
>
? U
: never,
)
) {
response.status(400).send({ message: 'Invalid collection' });
return;
}
if (!['public', 'private', 'admin'].includes(access)) {
response.status(400).send({ message: 'Bad access mode' });
return;
}
const accessParam = (access ?? 'public') as BoardAccessMode;
const effectiveAccess = userId ? accessParam : 'public'; // force public if anonymous
try {
// Build query with access control
const query = buildQueryWithAccessControl(
db.collection(collection),
effectiveAccess,
userId,
userRole,
).where('__name__', '==', docId);
const snap = await query.limit(1).get();
if (snap.empty) {
response
.status(404)
.send({ message: 'Item not found or access denied' });
return;
}
const docSnap = snap.docs[0];
const data = docSnap.data() as BoardItem;
if (!isValidBoardItem(data)) {
response.status(500).send({ message: 'Invalid item data' });
return;
}
if (access === 'public') {
response.set('Cache-Control', 'public,max-age=60');
}
response.status(200).send({ ...data, docId: docSnap.id });
return;
} catch (err) {
logger.error('fetchSingleItem: Firestore error', err);
response.status(500).send({ message: 'Internal Server Error' });
}
});
},
);

View File

@@ -1,2 +1,4 @@
export * from './countBoard';
export * from './fetchBoard';
export * from './fetchSingleItem';
export * from './boardMail';

View File

@@ -82,6 +82,7 @@ const MAX_FILE_SIZE_MB = 20;
const MAX_TOTAL_FILES = 10;
export const ALLOWED_COLLECTIONS = new Set([
'notices',
'wadizes' /* … */,
'projects' /* … */,
] as const);
const ALLOWED_IMAGE_TYPES = [
@@ -123,6 +124,12 @@ const ALLOWED_FILE_TYPES = [
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024;
export const SendEmail = {
to: 'bobu1104@naver.com',
from: 'movemama@manos.kr',
subject: '[보부웹앱] - 새로운 신청',
};
const isImageFile = (file: File): boolean =>
ALLOWED_IMAGE_TYPES.includes(file.type);
const isVideoFile = (file: File): boolean =>

View File

@@ -111,6 +111,23 @@ export type BoardItem = {
thumbnail?: ImageItem; //Leave it for previous data structure
};
// Board : Types
export interface WadizBoard extends BoardItem {
name: string;
paymentId: string;
email: string;
phone: string;
address: string;
emergencyPhone: string;
scheduleStart?: string | Date;
scheduleEnd?: string | Date;
attending: 'yes' | 'no';
altName?: string;
altPhone?: string;
companions?: string;
healthNotes?: string;
remarks?: string;
}
export interface ProjectBoard extends BoardItem {
subtitle: string;
displayDate?: string;

View File

@@ -1,33 +1,60 @@
// Your verifyHelper.ts (or wherever this function is)
import { getAuth } from 'firebase-admin/auth';
import type { Request } from 'express';
import * as cookie from 'cookie';
import { logger } from 'firebase-functions/v2'; // <--- Make sure you import logger here
export type DecodedAuthInfo = {
uid: string;
role: number;
};
/**
* Verifies the session cookie from the request and returns the decoded auth info.
* Throws an error if invalid or missing.
* @param {Request} req - The Express request object containing the session cookie
* @param {Response} [res] - Optional Express response object for sending error responses
*/
export async function verifySessionFromRequest(
req: Request,
): Promise<DecodedAuthInfo | null> {
// --- ADD THESE LOGS ---
logger.info('verifySessionFromRequest: STARTING verification');
logger.info(
'verifySessionFromRequest: Raw headers.cookie:',
req.headers.cookie,
); // Log the raw cookie header
// --- END ADDED LOGS ---
const cookies = cookie.parse(req.headers.cookie || '');
// --- ADD THESE LOGS ---
logger.info('verifySessionFromRequest: Parsed cookies object:', cookies); // Log the parsed cookies object
// --- END ADDED LOGS ---
const session = cookies.__session;
// --- ADD THESE LOGS ---
logger.info(
'verifySessionFromRequest: __session cookie value:',
session ? 'Found (length: ' + session.length + ')' : 'Not Found',
);
// --- END ADDED LOGS ---
if (!session) {
logger.warn('verifySessionFromRequest: No __session cookie found.'); // Specific log for missing cookie
return null;
}
try {
const decoded = await getAuth().verifySessionCookie(session, true);
const role = (decoded.role || 0) as number;
logger.info(
'verifySessionFromRequest: Session cookie decoded successfully for UID:',
decoded.uid,
); // Log success
return { uid: decoded.uid, role };
} catch (err) {
console.error('[Auth] Session cookie verification failed:', err);
} catch (err: unknown) {
// Use 'any' type for err to easily access its properties
logger.error(
'[Auth] Session cookie verification failed:',
err instanceof Error ? err.message : String(err),
); // Log the actual error message
return null;
}
}