Closed Ayato-kosaka closed 1 month ago
export default {
extra: {
appVersion: "1.0.0",
},
};
// lib/i18n.ts
import * as Localization from 'expo-localization';
import i18n from "i18n-js";
import en from "./en.json";
import ja from "./ja.json";
import fr from "./fr.json";
import zh from "./zh.json";
import ar from "./ar.json";
i18n.translations = { en, ja, fr, zh, ar };
i18n.fallbacks = true;
i18n.defaultLocale = "en";
i18n.locale = Localization.locale.split("-")[0];
export const SUPPORTED_LANGUAGES = Object.keys(i18n.translations);
export default i18n;
import remoteConfig from '@react-native-firebase/remote-config';
export type RemoteConfigValues = {
v1_min_frontend_log_level: 'debug' | 'info' | 'warn' | 'error';
feature_x_enabled: boolean;
welcome_message: string;
};
let cachedValues: RemoteConfigValues | null = null;
/**
* Remote Config を初期化し、値をキャッシュする
*/
export const initRemoteConfig = async () => {
try {
await remoteConfig().setDefaults({
v1_min_frontend_log_level: 'debug',
v1_feature_x_enabled: false,
v1_welcome_message: '',
// ❌ is_supported や max_version 等致命的な値はセットしない
});
await remoteConfig().fetchAndActivate();
cachedValues = {
v1_min_frontend_log_level: remoteConfig().getValue('v1_min_frontend_log_level').asString() as RemoteConfigValues['v1_min_frontend_log_level'],
v1_spot_visits_max_version_major: remoteConfig().getValue('v1_spot_visits_max_version_major').asString() as RemoteConfigValues['v1_spot_visits_max_version_major'],
feature_x_enabled: remoteConfig().getValue('feature_x_enabled').asBoolean(),
welcome_message: remoteConfig().getValue('welcome_message').asString(),
};
console.log('✅ Remote Config initialized:', cachedValues);
} catch (err) {
console.log('⚠️ Remote Config init failed:', err);
}
};
/**
* キャッシュされた Remote Config の値を取得
* 未初期化の場合は null を返す
*/
export const getRemoteConfig = (): RemoteConfigValues | null => cachedValues;
import { supabase } from "./supabase";
import { nanoid } from "nanoid";
import { getRemoteConfig } from "./remoteConfig";
export type LogLevel = "debug" | "info" | "warn" | "error";
// ログレベルの優先度定義
const logLevelPriority: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
};
export const logFrontendEvent = async ({
eventName,
screenName,
payload = {},
level,
userId,
}: {
eventName: string;
screen_name: string;
payload?: Record<string, any>;
level: LogLevel;
userId?: string;
}) => {
try {
const remoteConfig = getRemoteConfig();
const currentLevel = remoteConfig.v1_min_frontend_log_level;
// 設定レベルより優先度が低いログはスキップ
if (logLevelPriority[level] < logLevelPriority[currentLevel]) {
return;
}
// イベントを挿入
await supabase.from("frontend_event_logs").insert({
id: nanoid(12),
user_id: userId,
event_name: eventName,
screen_name: screenName,
payload,
level,
});
if (process.env.NODE_ENV === "development") {
console.log(`📤 [${level}] [${screenName}] ${eventName}`, payload);
}
} catch (err: any) {
if (process.env.NODE_ENV === "development") {
console.log(`⚠️ [${screenName}] logFrontendEvent failed`, err.message);
}
}
};
import Constants from "expo-constants";
import { logFrontendEvent } from "./logFrontendEvent";
type APIVersion = "v1" | "v2";
export const callCloudFunction = async <T extends object | FormData, R>(
functionName: string,
data: T,
version: APIVersion,
screenName: string,
userId: string,
isMultipart: boolean = false,
): Promise<R> => {
const appVersion = Constants.manifest?.version ?? "unknown";
logFrontendEvent({
eventName: `callCloudFunction:${version}/${functionName}`,
screenName,
payload: isMultipart ? "[multipart/form-data]" : data,
userId,
});
const url = `https://<region>-<project>.cloudfunctions.net/${version}/${functionName}`;
const headers: Record<string, string> = {
"x-app-version": appVersion,
};
if (!isMultipart) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(url, {
method: "POST",
headers,
body: isMultipart ? (data as FormData) : JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Function ${functionName} failed with ${response.status}`);
}
return await response.json();
};
import { usePathname } from 'expo-router';
import { logFrontendEvent } from '@/lib/logger';
import { useUser } from '@/contexts/UserContext';
export const useLang = (): string => {
const pathname = usePathname();
const { user } = useUser();
const segments = pathname.split('/');
const lang = segments[1];
if (!lang) {
const message = `[useLang] Unsupported or missing language: "${lang}". Pathname: "${pathname}"`;
logFrontendEvent({
eventName: 'unsupportedLang',
screenName: 'useLang',
payload: {
lang,
pathname,
supported: SUPPORTED_LANGUAGES,
},
userId: user?.id
});
throw new Error(message);
}
return lang;
};
// hooks/useWithLoading.ts
import { useState } from "react";
export const useWithLoading = () => {
const [isLoading, setIsLoading] = useState(false);
const withLoading = (fn: () => Promise<void>) => async () => {
setIsLoading(true);
try {
await fn();
} finally {
setIsLoading(false);
}
};
return { isLoading, withLoading };
};
import React, { createContext, useContext, useEffect, useState } from 'react';
import { supabase } from '@/lib/supabase';
import { User } from '@supabase/supabase-js';
/**
* ユーザー情報の型
*/
type UserContextType = {
user: User | null;
isLoading: boolean;
isSignedIn: boolean;
refresh: () => Promise<void>;
signOut: () => Promise<void>;
};
const UserContext = createContext<UserContextType>({
user: null,
isLoading: true,
isSignedIn: false,
refresh: async () => {},
signOut: async () => {},
});
/**
* ユーザー情報を取得・管理するプロバイダー
*/
export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const fetchUser = async () => {
setIsLoading(true);
const { data: { user }, error } = await supabase.auth.getUser();
if (!error) setUser(user ?? null);
setIsLoading(false);
};
const signOut = async () => {
await supabase.auth.signOut();
setUser(null);
};
useEffect(() => {
fetchUser();
const { data: listener } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
});
return () => {
listener.subscription.unsubscribe();
};
}, []);
return (
<UserContext.Provider
value={{
user,
isLoading,
isSignedIn: !!user,
refresh: fetchUser,
signOut,
}}
>
{children}
</UserContext.Provider>
);
};
/**
* 任意のコンポーネントでユーザー情報を使うカスタムフック
*/
export const useUser = () => useContext(UserContext);
import React, { useEffect } from 'react';
import * as SplashScreen from 'expo-splash-screen';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { useUser } from '@/contexts/UserContext';
const SplashHandler = ({ children }: { children: React.ReactNode }) => {
const { isLoading, user } = useUser();
const hasHiddenRef = useRef(false);
useEffect(() => {
if (!isLoading && !hasHiddenRef.current && user) {
SplashScreen.hideAsync();
hasHiddenRef.current = true;
logFrontendEvent({
eventName: "appReady",
screenName: "App",
payload: { isReady, remoteConfig: getRemoteConfig() },
level: "info",
userId: user.id,
});
}
}, [isLoading]);
if (isLoading) return null;
return <>{children}</>;
};
import * as Localization from "expo-localization";
import { Redirect } from "expo-router";
export default function Index() {
const lang = Localization.locale.split("-")[0] || "en";
return <Redirect href={`/${lang}`} />;
}
// app/[lang]/_layout.tsx
import React, { useEffect, useState, useCallback } from "react";
import { NavigationContainer } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import * as SplashScreen from "expo-splash-screen";
import SpotCapture from "./screens/SpotCapture";
import SpotGuide from "./screens/SpotGuide";
import { supabase } from "@/lib/supabase";
import { logFrontendEvent } from "@/lib/logger";
import { initRemoteConfig } from "@/lib/remoteConfig";
import { UserProvider } from '@/contexts/UserContext';
import { SplashHandler } from '@/components/SplashHandler';
const Stack = createNativeStackNavigator();
SplashScreen.preventAutoHideAsync(); // スプラッシュ固定(初回のみ実行)
export default function App() {
const [isReady, setIsReady] = useState(false);
const prepare = useCallback(async () => {
try {
const { data: session } = await supabase.auth.getSession();
if (!session.session) {
const { error } = await supabase.auth.signInAnonymously();
const event = error ? "signInFailed" : "signInAnonymously";
logFrontendEvent({
eventName: event,
screenName: "App",
payload: error ? { message: error.message } : {},
});
} else {
logFrontendEvent({
eventName: "sessionRestored",
screenName: "App",
});
}
await initRemoteConfig();
} catch (err: any) {
logFrontendEvent({
eventName: "initFailed",
screenName: "App",
payload: { message: err.message },
});
} finally {
setIsReady(true);
}
}, []);
useEffect(() => {
prepare();
}, []);
if (!isReady) {
return null;
}
return (
<UserProvider>
<SplashHandler>
<NavigationContainer>
<Stack.Navigator initialRouteName="SpotCapture" screenOptions={{ headerShown: false }}>
<Stack.Screen name="SpotCapture" component={SpotCapture} />
<Stack.Screen name="SpotGuide" component={SpotGuide} />
</Stack.Navigator>
</NavigationContainer>
</SplashHandler>
</UserProvider>
);
}
import React, { useEffect } from "react";
import { View, Button, ActivityIndicator, StyleSheet, Alert } from "react-native";
import * as ImagePicker from "expo-image-picker";
import { logFrontendEvent } from "@/lib/logger";
import { useNavigation } from "@react-navigation/native";
import { useWithLoading } from "@/hooks/useWithLoading";
import { callCloudFunction } from "@/lib/callCloudFunction";
import { useUser } from "@/contexts/UserContext";
export default function SpotCapture() {
const { isLoading, withLoading } = useWithLoading();
const navigation = useNavigation();
const { user } = useUser();
const screenName = "SpotCapture";
useEffect(() => {
logFrontendEvent({
eventName: "mounted",
screenName,
userId: user.id,
});
}, []);
const handleCapture = withLoading(async () => {
logFrontendEvent({
eventName: "onPressCapture",
screenName,
userId: user.id,
});
try {
const result = await ImagePicker.launchCameraAsync({
quality: 0.7,
base64: false,
});
if (result.canceled || !result.assets?.[0]) return;
const asset = result.assets[0];
const formData = new FormData();
formData.append("image", {
uri: asset.uri,
name: asset.fileName ?? "upload.jpg",
type: "image/jpeg",
} as any);
const {ext_spots, uploadedUri, takenPhotoStoragePath} = await callCloudFunction<findOrCreateSpotFromImageRequest, findOrCreateSpotFromImageResponse>(
"findOrCreateSpotFromImage",
formData,
"v1",
screenName,
user.id,
true // isMultipart
);
navigation.navigate("SpotGuide", {ext_spots, imageUri: uploadedUri, takenPhotoStoragePath});
} catch (e) {
logFrontendEvent({
eventName: "captureFailed",
screenName,
payload: { message: e.message },
userId: user.id,
});
Alert.alert("エラー", "画像の認識に失敗しました"); // TODO: i18n に対応する
}
});
return (
<View style={styles.container}>
{isLoading ? (
<ActivityIndicator size="large" />
) : (
<Button title="撮影する" onPress={handleCapture} />
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
});
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { View, ActivityIndicator, Button, Share } from 'react-native';
import { useLocalSearchParams, useRouter, usePathname } from 'expo-router';
import Carousel from 'react-native-reanimated-carousel';
import { SpotGuideCard } from './components/SpotGuideCard';
import { SpotRecommendCard } from './components/SpotRecommendCard';
import { Dimensions } from 'react-native';
import { useWithLoading } from '@/hooks/useWithLoading';
import { callCloudFunction } from '@/lib/callCloudFunction';
import { logFrontendEvent } from '@/lib/logFrontendEvent';
import type { ExtSpot, SpotGuide } from '@/types';
import { IconButton } from 'react-native-paper';
import { AdMobInterstitial } from 'expo-ads-admob';
import { useUser } from "@/contexts/UserContext";
export default function SpotGuideScreen() {
const { user } = useUser();
const { imageUri, ext_spots, takenPhotoStoragePath } = useLocalSearchParams<{ imageUri?: string; ext_spots?: ExtSpot, takenPhotoStoragePath?: string }>();
const router = useRouter();
const pathname = usePathname();
const lang = pathname.split('/')[1];
const { isLoading, withLoading } = useWithLoading();
const [spotGuides, setSpotGuides] = useState<SpotGuide[]>([]);
const [recommendedSpots, setRecommendedSpots] = useState<ExtSpot[]>([]);
const [lastAdShownIndex, setLastAdShownIndex] = useState<number | null>(null);
const carouselRef = useRef<any>(null);
const screenName = 'SpotGuide';
useEffect(() => {
if (!ext_spots) {
router.replace('/SpotCapture');
}
logFrontendEvent({
eventName: "mounted",
screenName,
userId: user.id,
});
}, [ext_spots]);
useEffect(() => {
if (!ext_spots) return;
withLoading(async () => {
try {
const guides = await callCloudFunction<{ spot_id: string; lang_code: string }, SpotGuide[]>(
'listSpotGuides',
{ spot_id: ext_spots.id, lang_code: lang },
'v1',
screenName,
user.id,
);
setSpotGuides(guides);
const recommends = await callCloudFunction<{ spot_id: string }, ExtSpot[]>(
'listRecommendedSpotsByVisitHistory',
{ spot_id: ext_spots.id },
'v1',
screenName,
user.id,
);
setRecommendedSpots(recommends);
} catch (err: any) {
logFrontendEvent({
eventName: 'loadFailed',
screenName,
payload: { error: err.message },
userId: user.id,
});
}
})();
}, [ext_spots]);
if (!ext_spots || isLoading) {
return <ActivityIndicator size="large" style={{ flex: 1 }} />;
}
const displayImageUri = imageUri ?? ext_spots.image_url;
const carouselItems = [ext_spots, ...recommendedSpots];
const spotIndex = carouselRef.current?.getCurrentIndex?.() ?? 0;
const handleReturnToCamera = () => {
router.replace('/SpotCapture');
logFrontendEvent({
eventName: 'returnToCamera',
screenName,
payload: {},
userId: user.id,
});
};
const maybeShowAd = useCallback(async (nextIndex: number) => {
if (nextIndex % 5 === 0 && lastAdShownIndex !== nextIndex) {
try {
await AdMobInterstitial.setAdUnitID('ca-app-pub-xxxxxxxxxxxxxxxx/yyyyyyyyyy'); // 本番用IDに差し替え
await AdMobInterstitial.requestAdAsync({ servePersonalizedAds: true });
await AdMobInterstitial.showAdAsync();
setLastAdShownIndex(nextIndex);
logFrontendEvent({
eventName: 'showInterstitialAd',
screenName,
payload: { index: nextIndex },
userId: user.id,
});
} catch (err: any) {
logFrontendEvent({
eventName: 'adFailed',
screenName,
payload: { error: err.message },
userId: user.id,
});
}
}
}, [lastAdShownIndex]);
const handleBack = () => {
if (spotIndex > 0) {
const nextIndex = spotIndex - 1;
carouselRef.current?.scrollTo({ index: nextIndex });
}
};
const handleForward = () => {
if (spotIndex < carouselItems.length - 1) {
const nextIndex = spotIndex + 1;
maybeShowAd(nextIndex);
carouselRef.current?.scrollTo({ index: nextIndex });
}
};
const handleShareInstagram = async () => {
try {
await Share.share({
url: displayImageUri,
message: 'Check out this spot I found with なにこれオーディオガイド!',
});
logFrontendEvent({
eventName: 'shareInstagram',
screenName,
payload: { imageUri: displayImageUri },
userId: user.id,
});
} catch (error: any) {
logFrontendEvent({
eventName: 'shareInstagramFailed',
screenName,
payload: { error: error.message },
userId: user.id,
});
}
};
const renderItem = useCallback(({ index }: { index: number }) => {
if (index === 0) {
return (
<SpotGuideCard
spot={ext_spots}
guides={spotGuides}
imageUri={displayImageUri}
takenPhotoStoragePath={takenPhotoStoragePath}
/>
);
}
const recommendedSpot = recommendedSpots[index - 1];
return <SpotRecommendCard spot={recommendedSpot} />;
}, [spotGuides, recommendedSpots, displayImageUri]);
return (
<View style={{ flex: 1 }}>
<Carousel
ref={carouselRef}
loop={false}
width={Dimensions.get('window').width}
height={Dimensions.get('window').height}
data={carouselItems}
scrollAnimationDuration={300}
renderItem={renderItem}
panGestureHandlerProps={{ activeOffsetX: [-10, 10] }}
onSnapToItem={maybeShowAd}
mode="parallax"
/>
<View style={{ flexDirection: 'row', justifyContent: 'space-around', padding: 10 }}>
<IconButton icon="arrow-left" onPress={handleReturnToCamera} />
<IconButton icon="share-variant" onPress={handleShareInstagram} />
{spotIndex > 0 && <IconButton icon="chevron-left" onPress={handleBack} />}
{spotIndex < carouselItems.length - 1 && <IconButton icon="chevron-right" onPress={handleForward} />}
</View>
</View>
);
}
import React, { useEffect, useState } from 'react';
import { View, Text, Image, StyleSheet, ActivityIndicator } from 'react-native';
import { Audio } from 'expo-av';
import { IconButton } from 'react-native-paper';
import { useWithLoading } from '@/hooks/useWithLoading';
import { callCloudFunction } from '@/lib/callCloudFunction';
import { logFrontendEvent } from '@/lib/logFrontendEvent';
import { supabase } from '@/lib/supabase';
import type { ExtSpot, SpotGuide } from '@/types';
import { usePathname } from 'expo-router';
import fallbackImage from '../assets/images/no_image_logo.png';
import { nanoid } from 'nanoid';
import { useUser } from "@/contexts/UserContext";
type Props = {
spot: ExtSpot;
spotGuides: SpotGuide[];
imageUri: string;
takenPhotoStoragePath?: string;
};
export const SpotGuideCard = ({ spot, spotGuides: initialGuides, imageUri, takenPhotoStoragePath }: Props) => {
const { user } = useUser();
const [spotGuideIndex, setSpotGuideIndex] = useState(0);
const [spotGuides, setSpotGuides] = useState<SpotGuide[]>(initialGuides);
const [isPlaying, setIsPlaying] = useState(false);
const [isLiked, setIsLiked] = useState(false);
const { isLoading, withLoading } = useWithLoading();
const [sound, setSound] = useState<Audio.Sound | null>(null);
const [imageSrc, setImageSrc] = useState(imageUri);
const visitIdRef = useRef<string>(nanoid(12));
const screenName = 'SpotGuideCard';
const pathname = usePathname();
const lang = pathname.split('/')[1];
const currentGuide = spotGuides[spotGuideIndex];
const handlePlayAudio = async () => {
if (isPlaying || !currentGuide?.audio_url) return;
setIsPlaying(true);
try {
const { sound: newSound } = await Audio.Sound.createAsync(
{ uri: currentGuide.audio_url },
{ shouldPlay: true }
);
setSound(newSound);
newSound.setOnPlaybackStatusUpdate((status) => {
if (!status.isLoaded || status.didJustFinish) {
setIsPlaying(false);
newSound.unloadAsync();
}
});
logFrontendEvent({
eventName: 'playAudio',
screenName,
payload: { spot_id: spot.id },
userId: user.id,
});
} catch (err: any) {
setIsPlaying(false);
logFrontendEvent({
eventName: 'playAudioFailed',
screenName,
payload: { error: err.message },
userId: user.id,
});
}
};
const handleToggleLike = async () => {
const targetId = currentGuide.id;
const willLike = !isLiked;
setIsLiked(willLike);
logFrontendEvent({
eventName: 'toggleLike',
screenName,
payload: { targetId, willLike },
userId: user.id,
});
const userId = user.id;
try {
if (willLike) {
const { error: insertError } = await supabase.from('reactions').insert([
{
user_id: userId,
target_type: 'spot_guide',
target_id: targetId,
reaction_type: 'like',
},
]);
if (insertError) throw new Error(insertError.message);
} else {
const { error: deleteError } = await supabase
.from('reactions')
.delete()
.eq('user_id', userId)
.eq('target_type', 'spot_guide')
.eq('target_id', targetId)
.eq('reaction_type', 'like');
if (deleteError) throw new Error(deleteError.message);
}
} catch (err: any) {
logFrontendEvent({
eventName: 'toggleLikeFailed',
screenName,
payload: { error: err.message, userId, targetId, willLike },
userId: user.id,
});
}
};
const generateSpotGuide = async () => {
try {
const newGuide = await callCloudFunction<
{ spot: ExtSpot; langCode: string },
SpotGuide
>('generateSpotGuide', { spot, langCode: lang }, 'v1', screenName, user.id);
setSpotGuides([...spotGuides, newGuide]);
logFrontendEvent({
eventName: 'generateSpotGuide',
screenName,
payload: { spot_guide_id: newGuide.id },
userId: user.id,
});
} catch (err: any) {
logFrontendEvent({
eventName: 'generateSpotGuideFailed',
screenName,
payload: { error: err.message },
userId: user.id,
});
}
}
const handleNextGuideSpot = withLoading(async () => {
await supabase.from('reactions').insert([
{
user_id: user.id,
target_type: 'spot_guide',
target_id: currentGuide.id,
reaction_type: 'dislike',
},
]).catch((error) => {
logFrontendEvent({
eventName: 'dislikeInsertFailed',
screenName,
payload: { error: error.message },
userId: user.id,
});
});
setSpotGuideIndex((prev) => prev + 1);
if(spotGuides.length === spotGuideIndex + 1) generateSpotGuide();
handlePlayAudio();
if (visitIdRef.current) {
await supabase
.from('spot_visits')
.update({ represent_guide_id: currentGuide.id })
.eq('id', visitIdRef.current);
}
});
const handleImageError = () => {
setImageSrc(Image.resolveAssetSource(fallbackImage).uri);
logFrontendEvent({
eventName: 'imageLoadError',
screenName,
payload: { spot_id: spot.id, failed_url: imageUri },
userId: user.id,
});
};
useEffect(() => {
const initialize = async () => {
if (spotGuides.length === 0) {
await generateSpotGuide();
}
// 音声再生
handlePlayAudio();
// 最新の spot_visits を取得
const { data: prevVisit, error: selectError } = await supabase
.from('spot_visits')
.select('id, spot_id')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (selectError) {
logFrontendEvent({
eventName: 'spotVisitSelectFailed',
screenName,
payload: { error: selectError.message },
userId: user.id,
});
return;
}
const remoteConfig = getRemoteConfig();
// 現在スポットを spot_visits に INSERT
const { error: insertError } = await supabase.from('spot_visits').insert([
{
id: visitIdRef.current,
user_id: user.id,
spot_id: spot.id,
represent_guide_id: currentGuide?.id ?? null,
taken_photo_storage_path: takenPhotoStoragePath ?? null,
prev_spot_id: prevVisit?.spot_id ?? null,
time_gap_minutes: (new Date()).getTime() - new Date(prevVisit.created_at).getTime(),
min_version_major: 1,
max_version_major: remoteConfig.v1_spot_visits_max_version_major,
},
]);
if (insertError) {
logFrontendEvent({
eventName: 'spotVisitInsertFailed',
screenName,
payload: { error: insertError.message },
userId: user.id,
});
}
};
initialize();
}, []);
return (
<View style={styles.container}>
<Image
source={{ uri: imageSrc }}
style={styles.image}
onError={handleImageError}
/>
<Text style={styles.title}>{spot.name}</Text>
<Text style={styles.guideText}>{currentGuide?.text ?? 'ガイドを生成中...'}</Text>
<View style={styles.buttonRow}>
<IconButton
icon={isLiked ? 'heart' : 'heart-outline'}
onPress={handleToggleLike}
disabled={!currentGuide}
/>
<IconButton
icon="volume-high"
onPress={handlePlayAudio}
disabled={isPlaying || !currentGuide?.audio_url}
/>
<IconButton
icon="refresh"
onPress={handleNextGuideSpot}
disabled={isLoading}
/>
</View>
</View>
);
};
const styles = StyleSheet.create({
container: {
padding: 16,
alignItems: 'center',
},
image: {
width: '100%',
height: 220,
borderRadius: 12,
marginBottom: 16,
},
title: {
fontSize: 22,
fontWeight: 'bold',
marginBottom: 12,
textAlign: 'center',
},
guideText: {
fontSize: 16,
textAlign: 'center',
marginBottom: 12,
},
buttonRow: {
flexDirection: 'row',
gap: 12,
alignItems: 'center',
},
});
import React from 'react';
import { View, Image, StyleSheet } from 'react-native';
import { Button } from 'react-native-paper';
import { useRouter } from 'expo-router';
import type { ExtSpot } from '@/types';
import { logFrontendEvent } from '@/lib/logFrontendEvent';
import fallbackImage from '../assets/images/no_image_logo.png';
export const SpotRecommendCard = React.memo( function ({ spot }: { spot: ExtSpot }) {
const router = useRouter();
const [imageSrc, setImageSrc] = useState(spot.image_url);
const handleImageError = () => {
setImageSrc(Image.resolveAssetSource(fallbackImage).uri);
logFrontendEvent({
eventName: 'imageLoadError',
'SpotRecommendCard',
payload: { spot_id: spot.id, failed_url: spot.image_url },
userId: user.id,
});
};
const handlePress = () => {
router.replace({
pathname: '/SpotGuide',
params: {
ext_spots: spot,
imageUri: spot.image_url,
},
});
};
return (
<View style={styles.container}>
<Image
source={{ uri: imageSrc }}
style={styles.image}
onError={handleImageError}
/>
<Button mode="contained" onPress={handlePress} style={styles.button}>
{i18n.t('SpotRecommend.nanicore')}
</Button>
</View>
);
});
const styles = StyleSheet.create({
container: {
padding: 16,
alignItems: 'center',
},
image: {
width: '100%',
height: 200,
borderRadius: 12,
marginBottom: 16,
},
button: {
alignSelf: 'center',
},
});
import { z } from 'zod';
const envSchema = z.object({
GOOGLE_KG_API_KEY: z.string(),
CLAUDE_API_KEY: z.string(),
COMMIT_ID: z.string().default('unknown'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
GOOGLE_APPLICATION_CREDENTIALS: z.string().optional(), // ローカルで必要なら
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
throw new Error('Invalid environment variables');
}
export const env = parsed.data;
import { PrismaClient } from '@prisma/client';
declare global {
// Allow global var reuse in dev
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma =
global.prisma ||
new PrismaClient({
log: ['query', 'error', 'warn'],
});
if (process.env.NODE_ENV !== 'production') global.prisma = prisma;
import { prisma } from '@/lib/prisma';
import { nanoid } from 'nanoid';
import { getCurrentCommitId } from '@/lib/env';
export const logBackendEvent = async ({
requestId,
functionName,
eventName,
payload = {},
userId = null,
errorLevel = 'info',
}: {
requestId: string;
functionName: string;
eventName: string;
payload?: Record<string, any>;
userId?: string | null;
errorLevel?: 'info' | 'warn' | 'error';
}) => {
try {
await prisma.backend_event_logs.create({
data: {
id: nanoid(12),
request_id: requestId,
function_name: functionName,
event_name: eventName,
payload,
user_id: userId,
created_commit_id: getCurrentCommitId(),
},
});
if (process.env.NODE_ENV === 'development') {
console.log(`📘 [${errorLevel}] ${functionName}:${eventName}`, payload);
}
} catch (err: any) {
if (process.env.NODE_ENV === 'development') {
console.log(`❌ [logBackendEvent failed]`, err.message);
}
}
};
export const logExternalApi = async ({
requestId,
functionName,
apiName,
endpoint,
requestPayload,
responsePayload,
statusCode,
errorMessage,
responseTimeMs,
userId = null,
}: {
requestId: string;
functionName: string;
apiName: string;
endpoint: string;
requestPayload?: any;
responsePayload?: any;
statusCode?: number;
errorMessage?: string | null;
responseTimeMs: number;
userId?: string | null;
}) => {
try {
await prisma.external_api_logs.create({
data: {
id: nanoid(12),
request_id: requestId,
function_name: functionName,
api_name: apiName,
endpoint,
request_payload: requestPayload,
response_payload: responsePayload,
status_code: statusCode,
error_message: errorMessage,
response_time_ms: responseTimeMs,
user_id: userId,
created_commit_id: getCurrentCommitId(),
},
});
} catch (err: any) {
if (process.env.NODE_ENV === 'development') {
console.log('❌ [logExternalApi failed]', err.message);
}
}
};
import { Storage } from '@google-cloud/storage';
const storage = new Storage();
const BUCKET_NAME = process.env.GCS_BUCKET_NAME || 'your-default-bucket';
const ENV = process.env.NODE_ENV || 'dev';
const bucket = storage.bucket(BUCKET_NAME);
type UploadFileParams = {
buffer: Buffer;
mimeType: string;
resourceType: string; // e.g., 'user-uploads', 'system-generated'
usageType: string; // e.g., 'photos', 'audio-guides'
identifier: string; // e.g., userId, spotId
fileName?: string; // 任意
requestId: string;
createdVersion: string;
expiresInSeconds?: number; // 任意
};
const getExtensionFromMime = (mime: string): string => {
switch (mime) {
case 'image/jpeg': return 'jpg';
case 'image/png': return 'png';
case 'image/webp': return 'webp';
case 'audio/mpeg': return 'mp3';
default: return 'bin';
}
};
/**
* ファイルアップロード + 署名付きURL生成
*/
export const uploadFile = async ({
buffer,
mimeType,
resourceType,
usageType,
identifier,
fileName,
requestId,
createdVersion,
expiresInSeconds = 60 * 15,
}: UploadFileParams): Promise<{
path: string;
signedUrl: string;
}> => {
const timestamp = Date.now();
const extension = getExtensionFromMime(mimeType);
const safeFileName = fileName
? `${timestamp}_${fileName}.${extension}`
: `${timestamp}.${extension}`;
const fullPath = `${ENV}/${resourceType}/${usageType}/${identifier}/${safeFileName}`;
const file = bucket.file(fullPath);
const nowIso = new Date().toISOString();
await file.save(buffer, {
metadata: {
contentType: mimeType,
metadata: {
request_id: requestId,
created_version: createdVersion,
created_at: nowIso,
updated_at: nowIso,
},
},
resumable: false,
});
const signedUrl = await generateSignedUrl(fullPath, expiresInSeconds); // 15分有効
return {
path: fullPath,
signedUrl,
};
};
/**
* 指定パスの署名付きURLを生成
*/
export const generateSignedUrl = async (
path: string,
expiresInSeconds: number = 60 * 15
): Promise<string> => {
const file = bucket.file(path);
const [url] = await file.getSignedUrl({
action: 'read',
expires: Date.now() + expiresInSeconds * 1000,
});
return url;
};
/**
* ファイル削除ユーティリティ
*/
export const deleteFile = async (path: string): Promise<void> => {
const file = bucket.file(path);
await file.delete();
};
import { Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { logBackendEvent, logExternalApi } from '@/lib/logging';
import { getAuth } from 'firebase-admin/auth';
export const createRequestId = (): string => {
return uuidv4();
};
export const handleFunctionError = (
res: Response,
err: any,
requestId: string,
functionName: string
) => {
logBackendEvent({
requestId,
functionName,
eventName: 'unhandledException',
payload: { message: err.message, stack: err.stack },
errorLevel: 'error',
});
return res.status(500).json({ error: 'Internal server error', requestId });
};
export const callExternalApi = async ({
requestId,
functionName,
apiName,
endpoint,
method,
payload,
}: {
requestId: string;
functionName: string;
apiName: string;
endpoint: string;
method: 'GET' | 'POST';
payload?: any;
}) => {
const start = Date.now();
let statusCode = 0;
let responseData = null;
let errorMessage = null;
try {
const res = await fetch(endpoint, {
method,
headers: { 'Content-Type': 'application/json' },
body: method === 'POST' ? JSON.stringify(payload) : undefined,
});
statusCode = res.status;
responseData = await res.json();
return responseData;
} catch (error: any) {
errorMessage = error.message;
throw error;
} finally {
const responseTimeMs = Date.now() - start;
await logExternalApi({
requestId,
functionName,
apiName,
endpoint,
requestPayload: payload,
responsePayload: responseData,
statusCode,
errorMessage,
responseTimeMs,
});
}
};
export const withAuthUser = async (req: any): Promise<{ userId: string | null }> => {
const authHeader = req.headers.authorization || '';
const token = authHeader.replace('Bearer ', '');
try {
const decoded = await getAuth().verifyIdToken(token);
return { userId: decoded.uid };
} catch {
return { userId: null };
}
};
import vision from '@google-cloud/vision';
import { WebDetection, EntityAnnotation } from '@google-cloud/vision/build/protos/protos';
const client = new vision.ImageAnnotatorClient();
const LANDMARK_SCORE_THRESHOLD = 0.3;
const LANDMARK_SCORE_WEIGHT = 1.0;
const WEBENTITY_SCORE_THRESHOLD = 0.4;
const WEBENTITY_SCORE_WEIGHT = 0.6;
export const identifySpotCandidates = async (
imageUri: string,
requestId: string
): Promise<(WebDetection.WebEntity & Partial<EntityAnnotation>)[]> => {
const [result] = await client.annotateImage({
image: { source: { imageUri } },
features: [
{ type: 'LANDMARK_DETECTION' },
{ type: 'WEB_DETECTION' },
],
});
const candidates: (WebDetection.WebEntity & Partial<EntityAnnotation>)[] = [];
// 1. Landmark with weight
if (result.landmarkAnnotations?.length) {
for (const landmark of result.landmarkAnnotations) {
if (landmark.mid && landmark.score && landmark.score >= LANDMARK_SCORE_THRESHOLD) {
candidates.push({
mid: landmark.mid,
description: landmark.description,
score: landmark.score * LANDMARK_SCORE_WEIGHT,
latitude: landmark.locations?.[0]?.latLng?.latitude,
longitude: landmark.locations?.[0]?.latLng?.longitude,
});
}
}
}
// 2. WebEntities with weight
if (result.webDetection?.webEntities?.length) {
for (const webEntity of result.webDetection.webEntities) {
if (webEntity.entityId && webEntity.score && webEntity.score >= WEBENTITY_SCORE_THRESHOLD) {
candidates.push({
entityId: webEntity.entityId,
description: webEntity.description,
score: webEntity.score * WEBENTITY_SCORE_WEIGHT,
fullMatchingImages: result.webDetection.fullMatchingImages,
});
}
}
}
// ソート: スコア降順
candidates.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
return candidates;
};
import { callExternalApi } from '@/lib/backendUtils';
export const getWikipediaImageFromMid = async (
mid: string,
requestId: string
): Promise<{ imageUrl: string | null; title: string | null }> => {
// Step 1: Knowledge Graph Search API
const kgUrl = `https://kgsearch.googleapis.com/v1/entities:search?ids=${encodeURIComponent(
mid
)}&key=${process.env.GOOGLE_KG_API_KEY}&limit=1&indent=true`;
const kgResponse = await callExternalApi({
requestId,
functionName: 'getWikipediaImageFromMid',
apiName: 'GoogleKnowledgeGraphAPI',
endpoint: kgUrl,
method: 'GET',
});
const articleUrl = kgResponse?.itemListElement?.[0]?.result?.detailedDescription?.url;
if (!articleUrl) {
return { imageUrl: null, title: null };
}
// Step 2: Extract title from Wikipedia URL
const title = decodeURIComponent(articleUrl.split('/').pop() || '').replace(/_/g, ' ');
// Step 3: Wikipedia API (pageimages)
const wikiApiUrl = `https://en.wikipedia.org/w/api.php?action=query&titles=${encodeURIComponent(
title
)}&prop=pageimages&format=json&pithumbsize=640&origin=*`;
const wikiResponse = await callExternalApi({
requestId,
functionName: 'getWikipediaImageFromMid',
apiName: 'WikipediaAPI',
endpoint: wikiApiUrl,
method: 'GET',
});
const page = Object.values(wikiResponse?.query?.pages || {})?.[0];
const imageUrl = page?.thumbnail?.source ?? null;
return { imageUrl, title };
};
import { getRemoteConfig } from 'firebase-admin/remote-config';
import { app } from 'firebase-admin';
import { logBackendEvent } from './logging';
const remoteConfigClient = getRemoteConfig(app());
/**
* Remote Config の値を取得(失敗時は fallback を返す)
*/
export const getRemoteConfigValue = async (
key: string,
fallback: string,
requestId: string
): Promise<string> => {
try {
const template = await remoteConfigClient.getTemplate();
const value = template.parameters[key]?.defaultValue?.value;
if (!value) {
await logBackendEvent({
event_name: 'remoteConfigFallbackUsed',
function_name: 'getRemoteConfigValue',
payload: { key, reason: 'value not found' },
request_id,
});
return fallback;
}
return value;
} catch (error: any) {
await logBackendEvent({
event_name: 'remoteConfigFetchFailed',
function_name: 'getRemoteConfigValue',
payload: { key, error: error.message },
request_id,
});
return fallback;
}
};
import textToSpeech from '@google-cloud/text-to-speech';
import { logExternalApi } from './logging';
import { env } from './env';
const client = new textToSpeech.TextToSpeechClient();
type TTSOptions = {
text: string;
languageCode: string; // 例: 'en', 'ja'
ssmlGender: 'FEMALE' | 'MALE';
requestId: string;
};
export const synthesizeTextToSpeech = async ({
text,
languageCode,
ssmlGender,
requestId
}: TTSOptions): Promise<Buffer> => {
const request = {
input: { text },
voice: {
languageCode,
ssmlGender,
},
audioConfig: {
audioEncoding: 'MP3',
},
};
const start = Date.now();
try {
const [response] = await client.synthesizeSpeech(request);
const duration = Date.now() - start;
await logExternalApi({
request_id: requestId,
function_name: 'synthesizeTextToSpeech',
api_name: 'GoogleTextToSpeech',
endpoint: 'synthesizeSpeech',
request_payload: request,
response_payload: { audioLength: response.audioContent?.length ?? 0 },
status_code: 200,
response_time_ms: duration,
});
if (!response.audioContent) {
throw new Error('音声合成に失敗しました(audioContentが空)');
}
return Buffer.from(response.audioContent);
} catch (error: any) {
const duration = Date.now() - start;
await logExternalApi({
request_id: requestId,
function_name: 'synthesizeTextToSpeech',
api_name: 'GoogleTextToSpeech',
endpoint: 'synthesizeSpeech',
request_payload: request,
response_payload: {},
status_code: 500,
response_time_ms: duration,
error_message: error.message,
});
throw new Error(`TTS失敗: ${error.message}`);
}
};
import { logExternalApi } from './logging';
import { env } from './env';
type GuideContent = {
title: string;
manuscript: string;
tags: string;
};
/**
* Claude を用いて、観光スポットのガイド情報を生成する
*/
export const generateSpotGuideContent = async (
spotTitle: string,
langCode: string,
requestId: string
): Promise<GuideContent> => {
const basePrompt = await getRemoteConfigValue(
'generate_spot_guide_prompt',
`You are an expert travel guide AI.
Please generate:
1. A friendly guide title for beginners (max 30 characters)
2. A 3-line concise guide text for tourists
3. A few related tags (comma-separated)`
);
const prompt = `
${basePrompt}
Tourist spot is "${spotTitle}"
Output in ${langCode}, using the following JSON format:
{
"title": "...",
"manuscript": "...",
"tags": "..."
}
`.trim();
const start = Date.now();
try {
const response = await fetch('https://api.anthropic.com/v1/complete', {
method: 'POST',
headers: {
'x-api-key': env.CLAUDE_API_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'claude-instant-1.2',
prompt: `\n\nHuman: ${prompt}\n\nAssistant:`,
max_tokens: 512,
temperature: 0.7,
stop_sequences: ['\n\nHuman:'],
}),
});
const duration = Date.now() - start;
const result = await response.json();
const parsed = JSON.parse(result.completion ?? '{}') as GuideContent;
await logExternalApi({
request_id: requestId,
function_name: 'generateSpotGuideContent',
api_name: 'Claude',
endpoint: 'claude-instant-1.2',
request_payload: { prompt },
response_payload: parsed,
status_code: response.status,
response_time_ms: duration,
});
return parsed;
} catch (error: any) {
const duration = Date.now() - start;
await logExternalApi({
request_id: requestId,
function_name: 'generateSpotGuideContent',
api_name: 'Claude',
endpoint: 'claude-instant-1.2',
request_payload: { prompt },
response_payload: {},
status_code: 500,
error_message: error.message,
response_time_ms: duration,
});
throw new Error(`Claude API 呼び出し失敗: ${error.message}`);
}
};
import { onRequest } from 'firebase-functions/v2/https';
import { identifySpotCandidates } from '@/lib/vision';
import { createRequestId, handleFunctionError, withAuthUser } from '@/lib/backendUtils';
import { logBackendEvent } from '@/lib/logging';
import { getWikipediaImageFromMid } from '@/lib/wikipedia';
import { prisma } from '@/lib/prisma';
import { uploadFile } from '@/lib/storage';
import multer from 'multer';
const upload = multer({ storage: multer.memoryStorage() });
export const findOrCreateSpotFromImage = onRequest(async (req, res) => {
const requestId = createRequestId();
const functionName = 'findOrCreateSpotFromImage';
try {
const parsed = requestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: 'Invalid request', issues: parsed.error.issues });
}
const { imageUri } = parsed.data;
const { userId } = await withAuthUser(req);
// 画像ファイル受け取り(FormData)
await new Promise<void>((resolve, reject) => {
upload.single('file')(req as any, res as any, (err) => {
if (err) reject(err);
else resolve();
});
});
if (!req.file) {
return res.status(400).json({ error: '画像が添付されていません' });
}
const { path: imagePath, signedUrl: imageUri } = await uploadFile({
buffer: req.file.buffer,
mimeType: req.file.mimetype,
resourceType: 'user-uploads',
usageType: 'photos',
identifier: userId,
fileName: 'capture',
requestId,
createdVersion: getCurrentVersionFromRequest(req),
expiresInSeconds: 24*60*60,
});
const candidates = await identifySpotCandidates(imageUri, requestId);
if (candidates.length === 0) {
await logBackendEvent({
requestId,
functionName,
eventName: 'noSpotCandidatesFound',
payload: { imageUri },
userId,
errorLevel: 'warn',
});
return res.status(404).json({ error: 'No valid spot candidate found' });
}
const top = candidates[0];
const spotId = top.entityId || top.mid;
const existing = await prisma.ext_spots.findUnique({
where: { id: spotId },
});
if (existing) return res.status(200).json(existing);
let image_url: string | null = null;
let wikipediaTitle: string | null = null;
if (top.mid) {
const wiki = await getWikipediaImageFromMid(top.mid, requestId);
image_url = wiki?.imageUrl ?? null;
wikipediaTitle = wiki?.title ?? null;
if (!wikipediaTitle || !image_url) {
await logBackendEvent({
requestId,
functionName,
eventName: 'wikipediaDataMissing',
payload: { mid: top.mid, wiki },
userId,
errorLevel: 'warn',
});
}
} else if (top.fullMatchingImages?.length) {
const sorted = [...top.fullMatchingImages].sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
image_url = sorted[0]?.url ?? null;
if (!image_url) {
await logBackendEvent({
requestId,
functionName,
eventName: 'webImageMissing',
payload: { top },
userId,
errorLevel: 'warn',
});
}
}
const inserted = await prisma.ext_spots.create({
data: {
id: spotId,
source_type: top.mid ? 'landmark' : 'web',
title: top.mid ? wikipediaTitle ?? top.description : top.description,
image_url,
landmark_latitude: top.latitude ?? null,
landmark_longitude: top.longitude ?? null,
is_recommendable: image_url !== null,
},
});
return res.status(200).json({ext_spots: inserted, uploadedUri: imageUri, takenPhotoStoragePath: imagePath});
} catch (err: any) {
return handleFunctionError(res, err, requestId, functionName);
}
});
import { onRequest } from 'firebase-functions/v2/https';
import { z } from 'zod';
import { prisma } from './lib/prisma';
import {
createRequestId,
handleFunctionError,
getCurrentVersionMajorFromRequest,
} from './lib/backendUtils';
import { logBackendEvent } from './lib/logging';
/**
* 📘 リクエストクエリのバリデーションスキーマ(Zod)
*/
const schema = z.object({
spot_id: z.string(),
lang_code: z.string(),
});
/** 最大ガイド件数(乱数取得でも制限) */
const MAX_GUIDE_COUNT = 20;
/**
* 🎲 同一weight内でランダムシャッフルしつつ、weight順にソート
*
* @param items - ガイドオブジェクト配列
* @returns {T[]} weight降順かつ同スコア内シャッフルされた配列
*/
function shuffleByWeight<T extends { weight: number }>(items: T[]): T[] {
return [...items].sort((a, b) => {
if (a.weight !== b.weight) {
return b.weight - a.weight;
}
return Math.random() - 0.5;
});
}
/**
* 🎧 指定されたスポットIDと言語コードに対するガイド情報を取得。
*
* - price_amount = 0 のみ対象(無料ガイド)
* - アプリバージョンに合致するガイドのみ抽出
* - weight順で最大20件まで取得
* - weightが同じ場合はランダムシャッフル
*
* @param req.query.spot_id - 対象スポットのID
* @param req.query.lang_code - 対象言語コード(例: 'ja', 'en')
* @returns JSON配列(shuffled guides)
*/
export const listSpotGuides = onRequest(async (req, res) => {
const requestId = createRequestId();
const functionName = 'listSpotGuides';
try {
// クエリパラメータのバリデーション
const input = schema.parse(req.query);
const spotId = input.spot_id;
const langCode = input.lang_code;
const currentVersionMajor = getCurrentVersionMajorFromRequest(req);
// 🎯 DBから該当ガイドを取得(バージョン制約・無料のみ)
const guides = await prisma.spot_guides.findMany({
where: {
spot_id: spotId,
lang_code: langCode,
price_amount: 0,
min_version_major: { lte: currentVersionMajor },
max_version_major: { gte: currentVersionMajor },
},
orderBy: {
weight: 'desc',
},
take: MAX_GUIDE_COUNT,
});
// 🎲 重み付きシャッフル
const shuffled = shuffleByWeight(guides);
// ✅ 成功ログを記録
await logBackendEvent({
event_name: 'listSpotGuidesSuccess',
function_name: functionName,
payload: { spot_id: spotId, lang_code: langCode, count: shuffled.length },
request_id: requestId,
});
return res.status(200).json(shuffled);
} catch (err: any) {
return handleFunctionError({
res,
err,
requestId,
functionName,
});
}
});
import { onRequest } from 'firebase-functions/v2/https';
import { z } from 'zod';
import { nanoid } from 'nanoid';
import { createRequestId, getCurrentVersionMajorFromRequest, handleFunctionError } from '@/lib/backendUtils';
import { logBackendEvent } from '@/lib/logging';
import { generateSpotGuideContent } from '@/lib/claude';
import { synthesizeTextToSpeech } from '@/lib/textToSpeech';
import { uploadFile } from '@/lib/storage';
import { prisma } from '@/lib/prisma';
import { getRemoteConfigValue } from '@/lib/remoteConfig';
const schema = z.object({
spot: z.object({
id: z.string(),
title: z.string(),
}),
lang_code: z.string(),
});
export const generateSpotGuide = onRequest(async (req, res) => {
const requestId = createRequestId();
const functionName = 'generateSpotGuide';
try {
const input = schema.parse(req.body);
const { spot, lang_code } = input;
const screenName = 'generateSpotGuide';
const spotGuideId = nanoid();
// ① Claudeによるタイトル・原稿・タグ生成
const { title, manuscript, tags } = await generateSpotGuideContent(spot.title, lang_code, requestId);
// ② Remote Config から voiceType を取得
const ssmlGender = await getRemoteConfigValue('SSML_Gender', 'female', requestId);
// ③ TTS 音声合成
const audioBuffer = await synthesizeTextToSpeech({
text: manuscript,
languageCode: lang_code,
ssmlGender,
requestId,
});
// ④ Storage に保存
const { path: audioPath, signedUrl: audioUrl } = await uploadFile({
buffer: audioBuffer,
mimeType: 'audio/mpeg',
resourceType: 'system-generated',
usageType: 'audio-guides',
identifier: spot.id,
fileName: spotGuideId,
requestId,
createdVersion: getCurrentVersionFromRequest(req),
});
// ⑤ DB 登録
const spotGuide = await prisma.spot_guides.create({
data: {
id: spotGuideId,
spot_id: spot.id,
lang_code,
title,
manuscript,
tags,
audio_storage_path: audioPath,
voice_type: ssmlGender,
price_amount: 0,
price_currency: null,
weight: 0,
min_version_major: 1,
max_version_major: 9999,
created_by: 'auto',
},
});
await logBackendEvent({
event_name: 'generateSpotGuideSuccess',
function_name: functionName,
payload: { spot_id: spot.id },
request_id: requestId,
});
// 🎧 音声URLを含めて返却
res.status(200).json({
...spotGuide,
audio_url: audioUrl,
});
} catch (err: any) {
handleFunctionError({
error: err,
functionName,
requestId,
res,
});
}
});
import { onRequest } from 'firebase-functions/v2/https';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import {
createRequestId,
getCurrentVersionMajorFromRequest,
handleFunctionError,
} from '@/lib/backendUtils';
import { logBackendEvent } from '@/lib/logging';
const schema = z.object({
spot_id: z.string(),
});
const RECOMMENDED_SPOT_LIMIT = 20;
export const listRecommendedSpotsByVisitHistory = onRequest(async (req, res) => {
const requestId = createRequestId();
const functionName = 'listRecommendedSpotsByVisitHistory';
try {
const input = schema.parse(req.query);
const { spot_id } = input;
const currentVersionMajor = getCurrentVersionMajorFromRequest(req);
// 1. 履歴ベースで訪問先を集計(上位5件)
const visitResults = await prisma.spot_visits.groupBy({
by: ['spot_id'],
where: {
prev_spot_id: spot_id,
min_version_major: { lte: currentVersionMajor },
max_version_major: { gte: currentVersionMajor },
},
_count: { spot_id: true },
orderBy: [{ _count: { spot_id: 'desc' } }],
take: RECOMMENDED_SPOT_LIMIT,
});
const recommendedSpotIds = visitResults.map((r) => r.spot_id);
// 2. 推薦先スポット情報を取得(is_recommendable = true)
const spots = await prisma.ext_spots.findMany({
where: {
id: { in: recommendedSpotIds },
is_recommendable: true,
},
});
// 3. 順序を維持しつつマッピング
const orderedSpots = visitResults
.map((r) => spots.find((s) => s.id === r.spot_id))
.filter((s): s is NonNullable<typeof s> => !!s);
await logBackendEvent({
event_name: 'listRecommendedSpotsByVisitHistorySuccess',
function_name: functionName,
payload: { spot_id, result_count: orderedSpots.length },
request_id: requestId,
});
res.status(200).json(orderedSpots);
} catch (err: any) {
handleFunctionError({
error: err,
functionName,
requestId,
res,
});
}
});
全て反映したのでクローズ
◆ 実際に実装する時に追加するポイント(全方面ベストプラクティス(production-ready) にする) 以下の擬似コードに対して、全方面ベストプラクティスとなるよう、以下の対応をしてください。
1. JSDocコメントの追加(日本語)
関数の直前に、以下のルールに従って JSDoc コメントを記述してください。
2. エラーメッセージは英語に統一する
throw new Error(...)
の文言はすべて英語に置き換えてください3. 変数名の改善(命名ベストプラクティス)
data
,parsed
,res
,tmp
)は、文脈に応じてより具体的な名前に変更してください4. エラー処理の強化
try/catch
が必要な箇所では漏れなくonError
処理を追加してください5. ファイル分割の検討
次の基準に沿って、ロジックの整理・分離を検討してください。
6. ⚛️ フロントエンド向け最適化(React + Expo 向け)
以下の最適化を行い、パフォーマンス・再利用性・保守性を向上させてください。
パフォーマンス最適化
useCallback
,useMemo
,React.memo
を適切に導入フック化・分離
useXXX
)として切り出すlib/
,hooks/
,services/
などに分離テスト/デバッグ対応
data-testid
属性を追加してテスト可能性を高めるlogger
によるレベル分けログ出力(debug/info/error)i18n対応
i18n
で管理(例:t("SpotRecommend.seeMore")
)