Ayato-kosaka / nanicore-audio-guide

2 stars 0 forks source link

【重要設計】擬似コード #33

Closed Ayato-kosaka closed 1 month ago

Ayato-kosaka commented 1 month ago

◆ 実際に実装する時に追加するポイント(全方面ベストプラクティス(production-ready) にする) 以下の擬似コードに対して、全方面ベストプラクティスとなるよう、以下の対応をしてください。

1. JSDocコメントの追加(日本語)

関数の直前に、以下のルールに従って JSDoc コメントを記述してください。

### 🔽 コメント生成ルール:
- コメントは関数の直前に記載する
- JSDoc形式(/** ... */)を使用する
- 要点は **3〜5行以内** にまとめる
- 引数(@param)、返り値(@returns)を含める
- **処理の理由**が複雑な場合、「なぜそうしているか」をコメントに含める
- UIや表示ロジックがある場合は「表示内容や意図」も記載する

### 🔽 補足ルール(複雑な関数向け):
- 条件分岐・ループ・データ変換など、読みづらい処理には**1行コメントを処理単位で挿入**
- 「何をしているか」は関数名で伝わる場合、説明は簡潔でOK
- 「なぜこうしているか」が重要な場合は、理由を中心にコメントを書く

2. エラーメッセージは英語に統一する


3. 変数名の改善(命名ベストプラクティス)


4. エラー処理の強化


5. ファイル分割の検討

次の基準に沿って、ロジックの整理・分離を検討してください。

| 観点 | 内容 |
|------|------|
| ファイルサイズの目安 | 100行以上のロジックは原則分割検討 |
| コンポーネント | 再利用性が見込まれる部分は `components` に切り出す |
| ロジック・処理 | 重めの処理・再利用関数は `utils` / `hooks` / `services` に切り出す |

6. ⚛️ フロントエンド向け最適化(React + Expo 向け)

以下の最適化を行い、パフォーマンス・再利用性・保守性を向上させてください。

パフォーマンス最適化

フック化・分離

テスト/デバッグ対応

i18n対応

Ayato-kosaka commented 1 month ago

フロントエンド

app.config.ts

export default {
  extra: {
    appVersion: "1.0.0",
  },
};

lib/i18n.ts

// 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;

lib/remoteConfig.ts

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;

libs/logger.ts

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);
    }
  }
};

lib/api/core/callCloudFunction.ts

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();
};

hooks/useLang.ts

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;
};

useWithLoading.ts

// 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 };
};

contexts/UserContext.tsx

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);

components/SplashHandler.tsx

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}</>;
};

app/index.tsx

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}`} />;
}

[lang]/_layout.tsx

// 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>
  );
}

app/[lang]/SpotCapture/index.tsx

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",
  },
});

app/[lang]/SpotGuide/index.tsx

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>
  );
}

app/[lang]/SpotGuide/SpotGuideCard.tsx

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',
  },
});

app/[lang]/SpotGuide/SpotRecommendCard.tsx

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',
  },
});
Ayato-kosaka commented 1 month ago

バックエンド

lib/env.ts

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;

lib/prisma.ts

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;

lib/logging.ts

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);
    }
  }
};

lib/storage.ts

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();
};

lib/backendUtils.ts

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 };
  }
};

lib/vision.ts

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;
};

lib/wikipedia.ts

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 };
};

lib/remoteConfig.ts

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;
  }
};

lib/textToSpeech.ts

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}`);
  }
};

lib/claude.ts

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}`);
  }
};

functions/v1/findOrCreateSpotFromImage.ts

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);
  }
});

functions/v1/listSpotGuides.ts

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,
    });
  }
});

functions/v1/generateSpotGuide.ts

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,
    });
  }
});

functions/v1/listRecommendedSpotsByVisitHistory.ts

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,
    });
  }
});
Ayato-kosaka commented 1 month ago

全て反映したのでクローズ