250715
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -67,3 +67,4 @@ node_modules/
|
||||
|
||||
# dataconnect generated files
|
||||
.dataconnect
|
||||
sendgrid.env
|
||||
|
||||
@@ -383,6 +383,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,
|
||||
@@ -405,10 +407,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);
|
||||
@@ -541,6 +545,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 don’t abort the UX
|
||||
}
|
||||
showAlert('성공적으로 수정되었습니다', 'bg-green-800');
|
||||
emit('success');
|
||||
} else {
|
||||
@@ -571,7 +588,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 don’t abort the UX
|
||||
}
|
||||
await handleCreateBoard(boardPayload, currentCollection);
|
||||
|
||||
showAlert('성공적으로 생성되었습니다', 'bg-green-800', true);
|
||||
emit('success');
|
||||
}
|
||||
|
||||
156
bobu/app/components/boards/wadiz/WadizListSingle.vue
Normal file
156
bobu/app/components/boards/wadiz/WadizListSingle.vue
Normal 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>
|
||||
@@ -21,7 +21,7 @@ export function useBoardList<T extends BoardItem>(
|
||||
title = 'Board',
|
||||
itemsPerPage = 20,
|
||||
defaultSort = 'desc',
|
||||
access = 'public',
|
||||
access,
|
||||
loadingMessage = '잠시만 기다려주세요...',
|
||||
} = options;
|
||||
/* UI + meta */
|
||||
|
||||
@@ -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>
|
||||
|
||||
447
bobu/app/pages/wadiz/[docId].vue
Normal file
447
bobu/app/pages/wadiz/[docId].vue
Normal 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>
|
||||
101
bobu/app/pages/wadiz/manage.vue
Normal file
101
bobu/app/pages/wadiz/manage.vue
Normal 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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
33
bobu/app/utils/api/fetchSingleItemFromFunction.ts
Normal file
33
bobu/app/utils/api/fetchSingleItemFromFunction.ts
Normal 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;
|
||||
}
|
||||
24
bobu/app/utils/api/sendBoardEmail.ts
Normal file
24
bobu/app/utils/api/sendBoardEmail.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
88
bobu/app/utils/emailTemplates/wadizUploaded.ts
Normal file
88
bobu/app/utils/emailTemplates/wadizUploaded.ts
Normal 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
5501
bobu/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
109
functions/package-lock.json
generated
109
functions/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: CORS‑enabled 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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
});
|
||||
|
||||
73
functions/src/board/boardMail.ts
Normal file
73
functions/src/board/boardMail.ts
Normal 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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -26,32 +26,22 @@ export const fetchBoards = onRequest(
|
||||
return corsMiddlewareHandler(request, response, async () => {
|
||||
logger.info('fetchBoards: CORS‑enabled 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 allow‑list
|
||||
// ❶ 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 });
|
||||
|
||||
112
functions/src/board/fetchSingleItem.ts
Normal file
112
functions/src/board/fetchSingleItem.ts
Normal 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 allow‑list
|
||||
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' });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './countBoard';
|
||||
export * from './fetchBoard';
|
||||
export * from './fetchSingleItem';
|
||||
export * from './boardMail';
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user