Migrate from Supabase to Appwrite with core functionality and UI components

This commit is contained in:
hansoo
2025-05-05 08:58:27 +09:00
parent fdfdf15166
commit f83bb384af
79 changed files with 2373 additions and 199 deletions

21
src/archive/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Archive 폴더
이 폴더는 Zellyy Finance 프로젝트에서 더 이상 활발하게 사용되지 않는 레거시 코드를 보관하는 곳입니다.
## 개요
Zellyy Finance는 백엔드 서비스를 Supabase에서 Appwrite로 전환했습니다. 이 폴더에는 Supabase 관련 코드가 보관되어 있으며, 참조용으로만 유지됩니다.
## 폴더 구조
- `components/`: Supabase 관련 UI 컴포넌트
- `hooks/`: Supabase 관련 훅
- `integrations/`: Supabase 통합 코드
- `lib/`: Supabase 클라이언트 및 유틸리티
- `utils/`: Supabase 트랜잭션 유틸리티
## 주의사항
이 폴더의 코드는 더 이상 유지보수되지 않으며, 새로운 기능 개발에 사용해서는 안 됩니다. 모든 새로운 개발은 Appwrite 기반으로 진행해야 합니다.
마이그레이션이 완전히 완료되면 이 폴더는 삭제될 예정입니다.

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { CheckCircle, XCircle } from 'lucide-react';
interface SupabaseConnectionStatusProps {
testResults: {
connected: boolean;
message: string;
details?: string;
} | null;
}
const SupabaseConnectionStatus = ({ testResults }: SupabaseConnectionStatusProps) => {
if (!testResults) return null;
return (
<div className={`mt-2 p-3 rounded-md text-sm ${testResults.connected ? 'bg-green-50' : 'bg-red-50'}`}>
<div className="flex items-start">
{testResults.connected ? (
<CheckCircle className="h-5 w-5 text-green-500 mr-2 flex-shrink-0 mt-0.5" />
) : (
<XCircle className="h-5 w-5 text-red-500 mr-2 flex-shrink-0 mt-0.5" />
)}
<div>
<p className={`font-medium ${testResults.connected ? 'text-green-800' : 'text-red-800'}`}>
{testResults.connected ? '연결됨' : '연결 실패'}
</p>
<p className={testResults.connected ? 'text-green-700' : 'text-red-700'}>
{testResults.message}
</p>
{testResults.details && (
<p className="text-gray-500 mt-1 text-xs">
{testResults.details}
</p>
)}
</div>
</div>
</div>
);
};
export default SupabaseConnectionStatus;

View File

@@ -0,0 +1,99 @@
import React from 'react';
import { Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TestResults } from '@/lib/supabase/tests/types';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from "@/components/ui/collapsible";
interface DebugInfoCollapsibleProps {
testResults: TestResults;
showDebug: boolean;
setShowDebug: (show: boolean) => void;
}
const DebugInfoCollapsible: React.FC<DebugInfoCollapsibleProps> = ({
testResults,
showDebug,
setShowDebug
}) => {
if (!testResults.debugInfo) return null;
return (
<Collapsible
open={showDebug}
onOpenChange={setShowDebug}
className="border border-gray-200 rounded-md mt-4"
>
<div className="flex items-center justify-between px-4 py-2 bg-gray-50">
<h4 className="text-sm font-medium"> </h4>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
<Info className="h-4 w-4" />
<span className="sr-only">
{showDebug ? '진단 정보 숨기기' : '진단 정보 표시'}
</span>
</Button>
</CollapsibleTrigger>
</div>
<CollapsibleContent className="px-4 py-2 text-xs">
<div className="space-y-2">
<div>
<span className="font-medium">Supabase URL:</span>
<div className="mt-1 bg-gray-100 p-1 rounded break-all">
{testResults.url}
</div>
</div>
{testResults.usingProxy && (
<div>
<span className="font-medium"> URL:</span>
<div className="mt-1 bg-gray-100 p-1 rounded break-all">
{testResults.proxyUrl}
</div>
<div className="mt-1">
: {testResults.proxyType || 'corsproxy.io'}
</div>
</div>
)}
<div>
<span className="font-medium"> :</span>
<div className="mt-1 text-green-600">
{testResults.client ? '성공' : '실패'}
</div>
</div>
{testResults.debugInfo.lastErrorDetails && (
<div>
<span className="font-medium"> :</span>
<div className="mt-1 bg-red-50 p-1 rounded break-all text-red-600">
{testResults.debugInfo.lastErrorDetails}
</div>
</div>
)}
<div>
<span className="font-medium"> :</span>
<div className="mt-1 bg-gray-100 p-1 rounded break-all">
{testResults.debugInfo.browserInfo}
</div>
</div>
<div>
<span className="font-medium"> :</span>
<div className="mt-1">
{new Date(testResults.debugInfo.timestamp).toLocaleString()}
</div>
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
};
export default DebugInfoCollapsible;

View File

@@ -0,0 +1,20 @@
import React from 'react';
interface ErrorMessageCardProps {
errors: string[];
}
const ErrorMessageCard: React.FC<ErrorMessageCardProps> = ({ errors }) => {
if (errors.length === 0) return null;
return (
<div className="bg-red-50 border border-red-200 rounded p-2 mt-2">
{errors.map((error: string, index: number) => (
<p key={index} className="text-xs text-red-600 mb-1">{error}</p>
))}
</div>
);
};
export default ErrorMessageCard;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { AlertCircle } from "lucide-react";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
interface ProxyRecommendationAlertProps {
errors: string[];
}
const ProxyRecommendationAlert: React.FC<ProxyRecommendationAlertProps> = ({ errors }) => {
const hasCorsError = errors.some(err =>
err.includes('CORS') ||
err.includes('Failed to fetch') ||
err.includes('프록시 사용시 정상 작동') ||
err.includes('프록시를 활성화')
);
if (!hasCorsError || errors.length === 0) return null;
return (
<Alert className="bg-amber-50 border-amber-200 mt-3">
<AlertCircle className="h-4 w-4 text-amber-600" />
<AlertTitle className="text-amber-800 text-xs font-medium">CORS </AlertTitle>
<AlertDescription className="text-amber-700 text-xs">
<p>HTTP URL에 .</p>
<ul className="list-disc pl-4 mt-1">
<li className="mt-1">CORS .</li>
<li className="mt-1"> HTTPS URL로 .</li>
</ul>
</AlertDescription>
</Alert>
);
};
export default ProxyRecommendationAlert;

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { HelpCircle } from "lucide-react";
import { TestResults } from '@/lib/supabase/tests/types';
interface TroubleshootingTipsProps {
testResults: TestResults;
}
const TroubleshootingTips: React.FC<TroubleshootingTipsProps> = ({ testResults }) => {
if (!(!testResults.restApi && testResults.auth)) return null;
return (
<div className="bg-yellow-50 border border-yellow-200 rounded p-2 mt-2">
<div className="flex items-start gap-1">
<HelpCircle className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
<div>
<p className="text-xs text-yellow-800 font-medium"> API/DB </p>
<ul className="list-disc text-xs text-yellow-700 pl-4 mt-1">
<li> CORS </li>
<li>Supabase CORS </li>
<li> </li>
</ul>
</div>
</div>
</div>
);
};
export default TroubleshootingTips;

View File

@@ -0,0 +1,138 @@
import { Transaction } from '@/components/TransactionCard';
import { supabase } from '@/lib/supabase';
import { isSyncEnabled } from '@/utils/syncUtils';
import { formatISO } from 'date-fns';
// ISO 형식으로 날짜 변환 (Supabase 저장용)
const convertDateToISO = (dateStr: string): string => {
try {
// 이미 ISO 형식인 경우 그대로 반환
if (dateStr.match(/^\d{4}-\d{2}-\d{2}T/)) {
return dateStr;
}
// "오늘, 시간" 형식 처리
if (dateStr.includes('오늘')) {
const today = new Date();
// 시간 추출 시도
const timeMatch = dateStr.match(/(\d{1,2}):(\d{2})/);
if (timeMatch) {
const hours = parseInt(timeMatch[1], 10);
const minutes = parseInt(timeMatch[2], 10);
today.setHours(hours, minutes, 0, 0);
}
return formatISO(today);
}
// 일반 날짜 문자열은 그대로 Date 객체로 변환 시도
const date = new Date(dateStr);
if (!isNaN(date.getTime())) {
return formatISO(date);
}
// 변환 실패 시 현재 시간 반환
console.warn(`날짜 변환 오류: "${dateStr}"를 ISO 형식으로 변환할 수 없습니다.`);
return formatISO(new Date());
} catch (error) {
console.error(`날짜 변환 오류: "${dateStr}"`, error);
return formatISO(new Date());
}
};
// Supabase와 트랜잭션 동기화 - Cloud 최적화 버전
export const syncTransactionsWithSupabase = async (user: any, transactions: Transaction[]): Promise<Transaction[]> => {
if (!user || !isSyncEnabled()) return transactions;
try {
const { data, error } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id);
if (error) {
console.error('Supabase 데이터 조회 오류:', error);
return transactions;
}
if (data && data.length > 0) {
// Supabase 데이터 로컬 형식으로 변환
const supabaseTransactions = data.map(t => ({
id: t.transaction_id || t.id,
title: t.title,
amount: t.amount,
date: t.date,
category: t.category,
type: t.type
}));
// 로컬 데이터와 병합 (중복 ID 제거)
const mergedTransactions = [...transactions];
supabaseTransactions.forEach(newTx => {
const existingIndex = mergedTransactions.findIndex(t => t.id === newTx.id);
if (existingIndex >= 0) {
mergedTransactions[existingIndex] = newTx;
} else {
mergedTransactions.push(newTx);
}
});
return mergedTransactions;
}
} catch (err) {
console.error('Supabase 동기화 오류:', err);
}
return transactions;
};
// Supabase에 트랜잭션 업데이트 - Cloud 최적화 버전
export const updateTransactionInSupabase = async (user: any, transaction: Transaction): Promise<void> => {
if (!user || !isSyncEnabled()) return;
try {
// 날짜를 ISO 형식으로 변환
const isoDate = convertDateToISO(transaction.date);
const { error } = await supabase.from('transactions')
.upsert({
user_id: user.id,
title: transaction.title,
amount: transaction.amount,
date: isoDate, // ISO 형식 사용
category: transaction.category,
type: transaction.type,
transaction_id: transaction.id
});
if (error) {
console.error('트랜잭션 업데이트 오류:', error);
} else {
console.log('Supabase 트랜잭션 업데이트 성공:', transaction.id);
}
} catch (error) {
console.error('Supabase 업데이트 오류:', error);
}
};
// Supabase에서 트랜잭션 삭제 - Cloud 최적화 버전
export const deleteTransactionFromSupabase = async (user: any, transactionId: string): Promise<void> => {
if (!user || !isSyncEnabled()) return;
try {
const { error } = await supabase.from('transactions')
.delete()
.eq('transaction_id', transactionId);
if (error) {
console.error('트랜잭션 삭제 오류:', error);
} else {
console.log('Supabase 트랜잭션 삭제 성공:', transactionId);
}
} catch (error) {
console.error('Supabase 삭제 오류:', error);
}
};

View File

@@ -0,0 +1,20 @@
// This file is automatically generated. Do not edit it directly.
import { createClient } from '@supabase/supabase-js';
import type { Database } from './types';
const SUPABASE_URL = (() => {
const url = import.meta.env.VITE_SUPABASE_URL;
if (!url) throw new Error("VITE_SUPABASE_URL is not set");
return url;
})();
const SUPABASE_PUBLISHABLE_KEY = (() => {
const key = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!key) throw new Error("VITE_SUPABASE_ANON_KEY is not set");
return key;
})();
// Import the supabase client like this:
// import { supabase } from "@/integrations/supabase/client";
export const supabase = createClient<Database>(SUPABASE_URL, SUPABASE_PUBLISHABLE_KEY);

View File

@@ -0,0 +1,225 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
public: {
Tables: {
budgets: {
Row: {
categories: Json
created_at: string | null
id: string
month: number
total_budget: number
updated_at: string | null
user_id: string
year: number
}
Insert: {
categories?: Json
created_at?: string | null
id?: string
month: number
total_budget?: number
updated_at?: string | null
user_id: string
year: number
}
Update: {
categories?: Json
created_at?: string | null
id?: string
month?: number
total_budget?: number
updated_at?: string | null
user_id?: string
year?: number
}
Relationships: []
}
category_budgets: {
Row: {
amount: number
category: string
created_at: string | null
id: string
updated_at: string | null
user_id: string
}
Insert: {
amount?: number
category: string
created_at?: string | null
id?: string
updated_at?: string | null
user_id: string
}
Update: {
amount?: number
category?: string
created_at?: string | null
id?: string
updated_at?: string | null
user_id?: string
}
Relationships: []
}
transactions: {
Row: {
amount: number
category: string
created_at: string | null
date: string | null
id: string
notes: string | null
title: string
transaction_id: string | null
type: string
updated_at: string | null
user_id: string
}
Insert: {
amount: number
category: string
created_at?: string | null
date?: string | null
id?: string
notes?: string | null
title: string
transaction_id?: string | null
type: string
updated_at?: string | null
user_id: string
}
Update: {
amount?: number
category?: string
created_at?: string | null
date?: string | null
id?: string
notes?: string | null
title?: string
transaction_id?: string | null
type?: string
updated_at?: string | null
user_id?: string
}
Relationships: []
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}
type PublicSchema = Database[Extract<keyof Database, "public">]
export type Tables<
PublicTableNameOrOptions extends
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
PublicSchema["Views"])
? (PublicSchema["Tables"] &
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never,
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
PublicEnumNameOrOptions extends
| keyof PublicSchema["Enums"]
| { schema: keyof Database },
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
: never = never,
> = PublicEnumNameOrOptions extends { schema: keyof Database }
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
? PublicSchema["Enums"][PublicEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof PublicSchema["CompositeTypes"]
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database
}
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never,
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never

View File

@@ -0,0 +1,45 @@
import { createClient } from '@supabase/supabase-js';
import { getSupabaseUrl, getSupabaseKey } from './config';
const supabaseUrl = getSupabaseUrl();
const supabaseAnonKey = getSupabaseKey();
let supabaseClient;
try {
console.log(`Supabase 클라이언트 생성 중: ${supabaseUrl}`);
// Supabase 클라이언트 생성 - Cloud 환경에 최적화
supabaseClient = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: true,
persistSession: true,
}
});
console.log('Supabase 클라이언트가 성공적으로 생성되었습니다.');
} catch (error) {
console.error('Supabase 클라이언트 생성 오류:', error);
// 더미 클라이언트 생성 (앱이 완전히 실패하지 않도록)
supabaseClient = {
auth: {
getUser: () => Promise.resolve({ data: { user: null } }),
getSession: () => Promise.resolve({ data: { session: null } }),
signInWithPassword: () => Promise.reject(new Error('Supabase 설정이 필요합니다')),
signUp: () => Promise.reject(new Error('Supabase 설정이 필요합니다')),
signOut: () => Promise.resolve({ error: null }),
onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
},
from: () => ({
select: () => ({ eq: () => ({ data: null, error: new Error('Supabase 설정이 필요합니다') }) }),
insert: () => ({ error: new Error('Supabase 설정이 필요합니다') }),
delete: () => ({ eq: () => ({ error: new Error('Supabase 설정이 필요합니다') }) }),
}),
};
}
export const supabase = supabaseClient;
export const isValidUrl = true; // Supabase Cloud 환경에서는 항상 true

View File

@@ -0,0 +1,30 @@
// Supabase Cloud URL과 anon key 설정
export const getSupabaseUrl = () => {
const url = import.meta.env.VITE_SUPABASE_URL;
if (!url) throw new Error("VITE_SUPABASE_URL is not set");
return url;
};
export const getSupabaseKey = () => {
const key = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!key) throw new Error("VITE_SUPABASE_ANON_KEY is not set");
return key;
};
// Supabase 키 유효성 검사 - Cloud 환경에서는 항상 유효함
export const isValidSupabaseKey = () => {
return Boolean(import.meta.env.VITE_SUPABASE_ANON_KEY);
};
// 다음 함수들은 Cloud 환경에서는 필요 없지만 호환성을 위해 유지
export const isCorsProxyEnabled = () => {
return false;
};
export const getProxyType = () => {
return 'none';
};
export const getOriginalSupabaseUrl = () => {
return getSupabaseUrl();
};

View File

@@ -0,0 +1,78 @@
import { getSupabaseKey } from './config';
import { modifyStorageApiRequest } from './storageUtils';
/**
* Supabase 클라이언트에서 사용할 커스텀 fetch 구현
*/
export const customFetch = (...args: [RequestInfo | URL, RequestInit?]): Promise<Response> => {
// 첫 번째 인자는 URL 또는 Request 객체
let requestToUse = args[0];
let url = '';
// URL 형식 변환
if (typeof requestToUse === 'string') {
url = requestToUse;
} else if (requestToUse instanceof Request) {
url = requestToUse.url;
}
// Storage API 호출 감지 및 요청 수정
if (url.includes('/storage/v1/')) {
requestToUse = modifyStorageApiRequest(requestToUse, args[1], getSupabaseKey());
// args[1]이 존재하는 경우 modifyStorageApiHeaders 함수를 통해 헤더 수정
if (args[1]) {
args[1] = modifyStorageApiHeaders(args[1], getSupabaseKey());
}
}
// URL 로깅 및 디버깅
console.log('Supabase fetch 요청:', url);
// 기본 fetch 호출
return fetch(requestToUse, args[1])
.then(response => {
console.log('Supabase 응답 상태:', response.status);
return response;
})
.catch(err => {
console.error('Supabase fetch 오류:', err);
throw err;
});
};
/**
* Storage API 요청에 대한 헤더 수정
*/
function modifyStorageApiHeaders(options: RequestInit, supabaseAnonKey: string): RequestInit {
if (!options.headers) {
return options;
}
console.log('Storage API 호출 감지');
// 헤더 수정
const originalHeaders = options.headers;
const newHeaders = new Headers(originalHeaders);
// apikey 헤더가 있으면 삭제하고 Authorization 헤더로 교체
if (newHeaders.has('apikey')) {
const apiKeyValue = newHeaders.get('apikey');
newHeaders.delete('apikey');
newHeaders.set('Authorization', `Bearer ${apiKeyValue}`);
} else if (newHeaders.has('Apikey')) {
const apiKeyValue = newHeaders.get('Apikey');
newHeaders.delete('Apikey');
newHeaders.set('Authorization', `Bearer ${apiKeyValue}`);
} else {
// apikey 헤더가 없지만 Storage API를 호출하는 경우
newHeaders.set('Authorization', `Bearer ${supabaseAnonKey}`);
}
// 수정된 헤더로 새 옵션 객체 생성
const newOptions = { ...options, headers: newHeaders };
console.log('Storage API 헤더 형식 수정 완료');
return newOptions;
}

View File

@@ -0,0 +1,15 @@
import { supabase, isValidUrl } from './client';
import { createRequiredTables, checkTablesStatus } from './setup';
import { customFetch } from './customFetch';
import { modifyStorageApiRequest, getStorageApiHeaders } from './storageUtils';
export {
supabase,
isValidUrl,
createRequiredTables,
checkTablesStatus,
customFetch,
modifyStorageApiRequest,
getStorageApiHeaders
};

View File

@@ -0,0 +1,3 @@
// 리팩토링된 파일로 리다이렉션
export { createRequiredTables, checkTablesStatus } from './setup/index';

View File

@@ -0,0 +1,37 @@
import { supabase } from '../client';
import { checkTablesStatus } from './status';
/**
* Supabase 데이터베이스에 필요한 테이블이 있는지 확인합니다.
* 이 함수는 로그인 후 실행되어야 합니다.
*/
export const createRequiredTables = async (): Promise<{ success: boolean; message: string }> => {
try {
console.log('데이터베이스 테이블 확인 시작...');
// 테이블 상태 확인
const tablesStatus = await checkTablesStatus();
if (tablesStatus.transactions && tablesStatus.budgets && tablesStatus.category_budgets) {
return {
success: true,
message: '필요한 테이블이 이미 존재합니다.'
};
}
return {
success: false,
message: '일부 필요한 테이블이 없습니다. Supabase 대시보드에서 확인해주세요.'
};
} catch (error: any) {
console.error('테이블 확인 중 오류 발생:', error);
return {
success: false,
message: `테이블 확인 실패: ${error.message || '알 수 없는 오류'}`
};
}
};
// 기존 checkTablesStatus 함수 내보내기
export { checkTablesStatus };

View File

@@ -0,0 +1,45 @@
import { supabase } from '../client';
/**
* 테이블 상태를 확인합니다.
*/
export const checkTablesStatus = async (): Promise<{
transactions: boolean;
budgets: boolean;
category_budgets: boolean;
}> => {
const tables = {
transactions: false,
budgets: false,
category_budgets: false
};
try {
// transactions 테이블 확인
const { data: transactionsData, error: transactionsError } = await supabase
.from('transactions')
.select('*', { count: 'exact', head: true });
tables.transactions = !transactionsError;
// budgets 테이블 확인
const { data: budgetsData, error: budgetsError } = await supabase
.from('budgets')
.select('*', { count: 'exact', head: true });
tables.budgets = !budgetsError;
// category_budgets 테이블 확인
const { data: categoryBudgetsData, error: categoryBudgetsError } = await supabase
.from('category_budgets')
.select('*', { count: 'exact', head: true });
tables.category_budgets = !categoryBudgetsError;
return tables;
} catch (error) {
console.error('테이블 상태 확인 중 오류 발생:', error);
return tables;
}
};

View File

@@ -0,0 +1,175 @@
import { supabase } from '../client';
/**
* 트랜잭션 테이블을 생성합니다.
*/
export const createTransactionsTable = async (): Promise<{ success: boolean; message: string }> => {
try {
const { error } = await supabase.rpc('execute_sql', {
sql_query: `
CREATE TABLE IF NOT EXISTS transactions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES auth.users(id) NOT NULL,
title TEXT NOT NULL,
amount NUMERIC NOT NULL,
category TEXT NOT NULL,
date TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
type TEXT NOT NULL,
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- Row Level Security 설정
ALTER TABLE transactions ENABLE ROW LEVEL SECURITY;
-- 사용자 정책 설정 (읽기)
CREATE POLICY "사용자는 자신의 트랜잭션만 볼 수 있음"
ON transactions FOR SELECT
USING (auth.uid() = user_id);
-- 사용자 정책 설정 (쓰기)
CREATE POLICY "사용자는 자신의 트랜잭션만 추가할 수 있음"
ON transactions FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- 사용자 정책 설정 (업데이트)
CREATE POLICY "사용자는 자신의 트랜잭션만 업데이트할 수 있음"
ON transactions FOR UPDATE
USING (auth.uid() = user_id);
-- 사용자 정책 설정 (삭제)
CREATE POLICY "사용자는 자신의 트랜잭션만 삭제할 수 있음"
ON transactions FOR DELETE
USING (auth.uid() = user_id);
`
});
if (error) {
console.error('transactions 테이블 생성 실패:', error);
return {
success: false,
message: `transactions 테이블 생성 실패: ${error.message}`
};
}
return {
success: true,
message: 'transactions 테이블 생성 성공'
};
} catch (error: any) {
console.error('트랜잭션 테이블 생성 중 오류 발생:', error);
return {
success: false,
message: `트랜잭션 테이블 생성 실패: ${error.message || '알 수 없는 오류'}`
};
}
};
/**
* 예산 테이블을 생성합니다.
*/
export const createBudgetsTable = async (): Promise<{ success: boolean; message: string }> => {
try {
const { error } = await supabase.rpc('execute_sql', {
sql_query: `
CREATE TABLE IF NOT EXISTS budgets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES auth.users(id) NOT NULL,
month INTEGER NOT NULL,
year INTEGER NOT NULL,
total_budget NUMERIC NOT NULL DEFAULT 0,
categories JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE (user_id, month, year)
);
-- Row Level Security 설정
ALTER TABLE budgets ENABLE ROW LEVEL SECURITY;
-- 사용자 정책 설정 (읽기)
CREATE POLICY "사용자는 자신의 예산만 볼 수 있음"
ON budgets FOR SELECT
USING (auth.uid() = user_id);
-- 사용자 정책 설정 (쓰기)
CREATE POLICY "사용자는 자신의 예산만 추가할 수 있음"
ON budgets FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- 사용자 정책 설정 (업데이트)
CREATE POLICY "사용자는 자신의 예산만 업데이트할 수 있음"
ON budgets FOR UPDATE
USING (auth.uid() = user_id);
-- 사용자 정책 설정 (삭제)
CREATE POLICY "사용자는 자신의 예산만 삭제할 수 있음"
ON budgets FOR DELETE
USING (auth.uid() = user_id);
`
});
if (error) {
console.error('budgets 테이블 생성 실패:', error);
return {
success: false,
message: `budgets 테이블 생성 실패: ${error.message}`
};
}
return {
success: true,
message: 'budgets 테이블 생성 성공'
};
} catch (error: any) {
console.error('예산 테이블 생성 중 오류 발생:', error);
return {
success: false,
message: `예산 테이블 생성 실패: ${error.message || '알 수 없는 오류'}`
};
}
};
/**
* 테스트 테이블을 생성합니다.
*/
export const createTestsTable = async (): Promise<{ success: boolean; message: string }> => {
try {
const { error } = await supabase.rpc('execute_sql', {
sql_query: `
CREATE TABLE IF NOT EXISTS _tests (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_name TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 모든 사용자가 접근 가능하도록 설정
ALTER TABLE _tests ENABLE ROW LEVEL SECURITY;
CREATE POLICY "모든 사용자가 테스트 테이블에 접근 가능"
ON _tests FOR SELECT
USING (true);
`
});
if (error) {
console.error('_tests 테이블 생성 실패:', error);
return {
success: false,
message: `_tests 테이블 생성 실패: ${error.message}`
};
}
return {
success: true,
message: '_tests 테이블 생성 성공'
};
} catch (error: any) {
console.error('테스트 테이블 생성 중 오류 발생:', error);
return {
success: false,
message: `테스트 테이블 생성 실패: ${error.message || '알 수 없는 오류'}`
};
}
};

View File

@@ -0,0 +1,47 @@
/**
* Storage API 경로 수정 (buckets → bucket)
*/
export function modifyStorageApiRequest(
request: RequestInfo | URL,
options?: RequestInit,
supabaseAnonKey?: string
): RequestInfo | URL {
let url = '';
// URL 추출
if (typeof request === 'string') {
url = request;
// Storage API 엔드포인트 경로 수정 (buckets → bucket)
if (url.includes('/storage/v1/buckets')) {
url = url.replace('/storage/v1/buckets', '/storage/v1/bucket');
console.log('Storage API 경로 수정:', url);
return url;
}
} else if (request instanceof Request) {
url = request.url;
// Storage API 엔드포인트 경로 수정 (buckets → bucket)
if (url.includes('/storage/v1/buckets')) {
const newUrl = url.replace('/storage/v1/buckets', '/storage/v1/bucket');
// Request 객체인 경우 새 Request 객체 생성
const newRequest = new Request(newUrl, request);
console.log('Storage API Request 객체 경로 수정:', newUrl);
return newRequest;
}
}
// 수정이 필요 없으면 원래 요청 반환
return request;
}
/**
* Storage API에 사용할 헤더 가져오기
*/
export function getStorageApiHeaders(supabaseAnonKey: string): Headers {
const headers = new Headers();
headers.set('Authorization', `Bearer ${supabaseAnonKey}`);
headers.set('Content-Type', 'application/json');
return headers;
}

View File

@@ -0,0 +1,76 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { TestResult } from './types';
import { getSupabaseUrl, getSupabaseKey } from '../config';
// REST API 테스트
export const testRestApi = async (
supabase: SupabaseClient
): Promise<TestResult> => {
console.log('REST API 테스트 시작...');
try {
const originalUrl = getSupabaseUrl();
const supabaseKey = getSupabaseKey();
if (!originalUrl || !supabaseKey) {
return {
success: false,
error: '유효하지 않은 Supabase URL 또는 API 키'
};
}
// HTTP URL 감지
const isHttpUrl = originalUrl.startsWith('http:') && !originalUrl.startsWith('http://localhost');
// 클라이언트 인스턴스로 간단한 API 호출 수행
const { data, error } = await supabase
.from('_tests')
.select('*')
.limit(1);
if (error) {
// CORS 관련 오류인지 확인
if (error.message && error.message.includes('fetch failed') && isHttpUrl) {
return {
success: false,
error: `REST API 요청 실패: ${error.message} (CORS 오류 가능성 높음. CORS 프록시 사용을 권장합니다)`
};
}
// 테이블이 없는 경우 정상 (테스트 테이블이 없을 수 있음)
if (error.code === '42P01' || error.code === 'PGRST116') {
return {
success: true,
error: null
};
}
return {
success: false,
error: `REST API 요청 실패: ${error.message}`
};
}
return {
success: true,
error: null
};
} catch (err: any) {
// HTTP URL을 사용하고 있는지 확인
const url = getSupabaseUrl();
const isHttpUrl = url.startsWith('http:') && !url.startsWith('http://localhost');
if (err.message && err.message.includes('Failed to fetch') && isHttpUrl) {
return {
success: false,
error: `REST API 테스트 실패: ${err.message} (CORS 프록시 사용시 정상 작동합니다. 설정에서 프록시를 활성화해보세요)`
};
}
return {
success: false,
error: `REST API 테스트 예외: ${err.message || '알 수 없는 오류'}`
};
}
};

View File

@@ -0,0 +1,71 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { supabase } from '../client';
import { LoginTestResult, TestResult } from './types';
// Supabase 인증 서비스 테스트
export const testAuth = async (
supabase: SupabaseClient,
url: string
): Promise<TestResult> => {
try {
console.log('인증 서비스 테스트 시작...');
const { data, error } = await supabase.auth.getSession();
if (error) {
console.error('인증 테스트 실패:', error);
return { success: false, error: `인증 서비스 오류: ${error.message}` };
}
console.log('인증 테스트 성공');
return { success: true, error: null };
} catch (err: any) {
console.error('인증 테스트 중 예외:', err);
return {
success: false,
error: `인증 테스트 중 예외 발생: ${err.message || '알 수 없는 오류'}`
};
}
};
// 테스트용 직접 로그인 함수 (디버깅 전용)
export const testSupabaseLogin = async (email: string, password: string): Promise<LoginTestResult> => {
try {
console.log('테스트 로그인 시도:', email);
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
});
if (error) {
console.error('테스트 로그인 오류:', error);
return { success: false, error };
}
console.log('테스트 로그인 성공:', data);
return { success: true, data };
} catch (err) {
console.error('테스트 로그인 중 예외 발생:', err);
return { success: false, error: err };
}
};
// 인증 서비스 테스트
export const testAuthService = async (): Promise<{ success: boolean; error?: any }> => {
try {
console.log('인증 서비스 테스트 시작...');
const { data, error } = await supabase.auth.getSession();
if (error) {
console.error('인증 테스트 실패:', error);
return { success: false, error };
}
console.log('인증 테스트 성공');
return { success: true };
} catch (err) {
console.error('인증 테스트 중 예외:', err);
return { success: false, error: err };
}
};

View File

@@ -0,0 +1,55 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { TestResult } from './types';
import { getSupabaseUrl } from '../config';
export const testDatabaseConnection = async (
supabase: SupabaseClient
): Promise<TestResult> => {
try {
// 간단한 쿼리 실행으로 데이터베이스 연결 테스트
const { data, error } = await supabase
.from('_tests')
.select('*')
.limit(1)
.maybeSingle();
// 테이블이 없는 경우 정상 (테스트 테이블이 없을 수 있음)
if (error && (error.code === '42P01' || error.code === 'PGRST116')) {
return {
success: true,
error: null
};
}
// 다른 오류가 있으면 실패로 처리
if (error) {
return {
success: false,
error: `데이터베이스 연결 오류: ${error.message}`
};
}
// 성공
return {
success: true,
error: null
};
} catch (error: any) {
// HTTP URL 관련 CORS 오류 확인
const url = getSupabaseUrl();
const isHttpUrl = url.startsWith('http:') && !url.startsWith('http://localhost');
if (error.message && error.message.includes('Failed to fetch') && isHttpUrl) {
return {
success: false,
error: `데이터베이스 테스트 실패: ${error.message} (CORS 프록시 사용시 정상 작동합니다. 설정에서 프록시를 활성화해보세요)`
};
}
return {
success: false,
error: `데이터베이스 테스트 중 예외 발생: ${error.message || '알 수 없는 오류'}`
};
}
};

View File

@@ -0,0 +1,51 @@
// 테스트 결과를 위한 공통 타입 정의
export interface TestResult {
success: boolean;
error: string | null;
}
export interface TestDebugInfo {
originalUrl: string;
proxyUrl: string;
usingProxy: boolean;
proxyType: string;
keyLength: number;
browserInfo: string;
timestamp: string;
backupProxySuccess: boolean;
lastErrorDetails: string;
}
export interface TestResults {
url: string;
proxyUrl: string;
usingProxy: boolean;
proxyType: string;
client: boolean;
restApi: boolean;
auth: boolean;
database: boolean;
errors: string[];
debugInfo: TestDebugInfo;
}
export interface ConnectionTestResult {
url: string;
proxyUrl: string;
usingProxy: boolean;
proxyType: string;
client: boolean;
restApi: boolean;
auth: boolean;
database: boolean;
errors: string[];
debugInfo: TestDebugInfo;
}
export interface LoginTestResult {
success: boolean;
data?: any;
error?: any;
}

View File

@@ -0,0 +1,102 @@
import { supabase } from '@/lib/supabase';
import { Transaction } from '@/components/TransactionCard';
import { isSyncEnabled } from '@/utils/syncUtils';
import { toast } from '@/hooks/useToast.wrapper';
// Supabase와 거래 데이터 동기화
export const syncTransactionsWithSupabase = async (
user: any,
transactions: Transaction[]
): Promise<Transaction[]> => {
if (!user || !isSyncEnabled()) return transactions;
try {
const { data, error } = await supabase
.from('transactions')
.select('*')
.eq('user_id', user.id);
if (error) {
console.error('Supabase 데이터 조회 오류:', error);
return transactions;
}
if (data && data.length > 0) {
// Supabase 데이터 로컬 형식으로 변환
const supabaseTransactions = data.map(t => ({
id: t.transaction_id || t.id,
title: t.title,
amount: t.amount,
date: t.date,
category: t.category,
type: t.type
}));
// 로컬 데이터와 병합 (중복 ID 제거)
const mergedTransactions = [...transactions];
supabaseTransactions.forEach(newTx => {
const existingIndex = mergedTransactions.findIndex(t => t.id === newTx.id);
if (existingIndex >= 0) {
mergedTransactions[existingIndex] = newTx;
} else {
mergedTransactions.push(newTx);
}
});
return mergedTransactions;
}
} catch (err) {
console.error('Supabase 동기화 오류:', err);
}
return transactions;
};
// Supabase에 트랜잭션 업데이트
export const updateTransactionInSupabase = async (
user: any,
transaction: Transaction
): Promise<void> => {
if (!user || !isSyncEnabled()) return;
try {
const { error } = await supabase.from('transactions')
.upsert({
user_id: user.id,
title: transaction.title,
amount: transaction.amount,
date: transaction.date,
category: transaction.category,
type: transaction.type,
transaction_id: transaction.id
});
if (error) {
console.error('트랜잭션 업데이트 오류:', error);
}
} catch (error) {
console.error('Supabase 업데이트 오류:', error);
}
};
// Supabase에서 트랜잭션 삭제
export const deleteTransactionFromSupabase = async (
user: any,
transactionId: string
): Promise<void> => {
if (!user || !isSyncEnabled()) return;
try {
const { error } = await supabase.from('transactions')
.delete()
.eq('transaction_id', transactionId);
if (error) {
console.error('트랜잭션 삭제 오류:', error);
}
} catch (error) {
console.error('Supabase 삭제 오류:', error);
}
};