From a6a1ea2f4a461a016988e6eb5db3211e72ef8657 Mon Sep 17 00:00:00 2001 From: Jade Date: Sun, 21 Jun 2026 20:41:19 +0900 Subject: [PATCH 1/4] feat: set supabase connection with repo for migration & add types files --- .gitignore | 3 + src/types/commentary.ts | 17 + src/types/database.types.ts | 397 +++++++++++++++++ src/types/profile.ts | 12 + src/types/subscription.ts | 11 + src/types/work.ts | 10 + supabase/.gitignore | 8 + supabase/config.toml | 414 ++++++++++++++++++ .../migrations/20260621111612_init_schema.sql | 105 +++++ ...0260621113151_add_indexes_and_triggers.sql | 49 +++ tsconfig.json | 8 +- 11 files changed, 1033 insertions(+), 1 deletion(-) create mode 100644 src/types/commentary.ts create mode 100644 src/types/database.types.ts create mode 100644 src/types/profile.ts create mode 100644 src/types/subscription.ts create mode 100644 src/types/work.ts create mode 100644 supabase/.gitignore create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20260621111612_init_schema.sql create mode 100644 supabase/migrations/20260621113151_add_indexes_and_triggers.sql diff --git a/.gitignore b/.gitignore index 5ef6a52..d592c83 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# Supabase Local Settings & Tokens +.supabase/ \ No newline at end of file diff --git a/src/types/commentary.ts b/src/types/commentary.ts new file mode 100644 index 0000000..0b11491 --- /dev/null +++ b/src/types/commentary.ts @@ -0,0 +1,17 @@ +import { Tables, TablesInsert } from "./database.types"; +import { Profile } from "./profile"; + +export type Commentary = Tables<"commentaries">; +export type CommentaryInsert = TablesInsert<"commentaries">; +export type CommentaryTag = Tables<"commentary_tags">; + +// 예시 : 프론트엔드 화면(피드, 상세페이지)에서 가장 많이 쓰일 조인 타입 정의 +export interface CommentaryDetail extends Commentary { + // 릴레이션십에 의해 조인되어 들어오는 데이터 구조 매핑 + profiles: Pick | null; + tags: { + id: string; + name: string; + type: string; + }[]; +} diff --git a/src/types/database.types.ts b/src/types/database.types.ts new file mode 100644 index 0000000..de7ab42 --- /dev/null +++ b/src/types/database.types.ts @@ -0,0 +1,397 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.5" + } + graphql_public: { + Tables: { + [_ in never]: never + } + Views: { + [_ in never]: never + } + Functions: { + graphql: { + Args: { + extensions?: Json + operationName?: string + query?: string + variables?: Json + } + Returns: Json + } + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } + public: { + Tables: { + commentaries: { + Row: { + author_id: string + content: string + created_at: string + episode: number | null + id: string + img_urls: string[] + is_spoiler: boolean + updated_at: string + work_id: string + } + Insert: { + author_id: string + content: string + created_at?: string + episode?: number | null + id?: string + img_urls?: string[] + is_spoiler?: boolean + updated_at?: string + work_id: string + } + Update: { + author_id?: string + content?: string + created_at?: string + episode?: number | null + id?: string + img_urls?: string[] + is_spoiler?: boolean + updated_at?: string + work_id?: string + } + Relationships: [ + { + foreignKeyName: "commentaries_author_id_fkey" + columns: ["author_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "commentaries_work_id_fkey" + columns: ["work_id"] + isOneToOne: false + referencedRelation: "works" + referencedColumns: ["id"] + }, + ] + } + commentary_tags: { + Row: { + commentary_id: string + tag_id: string + } + Insert: { + commentary_id: string + tag_id: string + } + Update: { + commentary_id?: string + tag_id?: string + } + Relationships: [ + { + foreignKeyName: "commentary_tags_commentary_id_fkey" + columns: ["commentary_id"] + isOneToOne: false + referencedRelation: "commentaries" + referencedColumns: ["id"] + }, + { + foreignKeyName: "commentary_tags_tag_id_fkey" + columns: ["tag_id"] + isOneToOne: false + referencedRelation: "tags" + referencedColumns: ["id"] + }, + ] + } + profiles: { + Row: { + created_at: string + email: string + id: string + is_no_spoiler_mode: boolean + nickname: string + profile_url: string | null + updated_at: string + } + Insert: { + created_at?: string + email: string + id: string + is_no_spoiler_mode?: boolean + nickname: string + profile_url?: string | null + updated_at?: string + } + Update: { + created_at?: string + email?: string + id?: string + is_no_spoiler_mode?: boolean + nickname?: string + profile_url?: string | null + updated_at?: string + } + Relationships: [] + } + subscriptions: { + Row: { + created_at: string + episode: number | null + id: string + updated_at: string + user_id: string + work_id: string + } + Insert: { + created_at?: string + episode?: number | null + id?: string + updated_at?: string + user_id: string + work_id: string + } + Update: { + created_at?: string + episode?: number | null + id?: string + updated_at?: string + user_id?: string + work_id?: string + } + Relationships: [ + { + foreignKeyName: "subscriptions_user_id_fkey" + columns: ["user_id"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, + { + foreignKeyName: "subscriptions_work_id_fkey" + columns: ["work_id"] + isOneToOne: false + referencedRelation: "works" + referencedColumns: ["id"] + }, + ] + } + tags: { + Row: { + created_at: string + id: string + name: string + type: string + work_id: string + } + Insert: { + created_at?: string + id?: string + name: string + type: string + work_id: string + } + Update: { + created_at?: string + id?: string + name?: string + type?: string + work_id?: string + } + Relationships: [ + { + foreignKeyName: "tags_work_id_fkey" + columns: ["work_id"] + isOneToOne: false + referencedRelation: "works" + referencedColumns: ["id"] + }, + ] + } + works: { + Row: { + author: string + created_at: string + id: string + subscribe_count: number + title: string + updated_at: string + usage_count: number + } + Insert: { + author: string + created_at?: string + id?: string + subscribe_count?: number + title: string + updated_at?: string + usage_count?: number + } + Update: { + author?: string + created_at?: string + id?: string + subscribe_count?: number + title?: string + updated_at?: string + usage_count?: number + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + [_ in never]: never + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + graphql_public: { + Enums: {}, + }, + public: { + Enums: {}, + }, +} as const diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 0000000..27fd27f --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,12 @@ +import { Tables, TablesInsert, TablesUpdate } from "./database.types"; + +// 기본 로우 타입 정의 +export type Profile = Tables<"profiles">; +export type ProfileInsert = TablesInsert<"profiles">; +export type ProfileUpdate = TablesUpdate<"profiles">; + +// 확장 예시 : 비즈니스 로직 전용 확장 타입이 필요하다면 여기에 추가 +// 예: 현재 로그인한 유저의 세션 정보와 결합된 프로필 +export interface CurrentUserProfile extends Profile { + is_logged_in: boolean; +} diff --git a/src/types/subscription.ts b/src/types/subscription.ts new file mode 100644 index 0000000..baac8db --- /dev/null +++ b/src/types/subscription.ts @@ -0,0 +1,11 @@ +import { Tables, TablesInsert, TablesUpdate } from "./database.types"; +import { Work } from "./work"; + +export type Subscription = Tables<"subscriptions">; +export type SubscriptionInsert = TablesInsert<"subscriptions">; +export type SubscriptionUpdate = TablesUpdate<"subscriptions">; + +// 확장 예시: 마이 페이지나 '내가 구독한 작품 목록' 탭에서 작품의 썸네일, 제목 등을 함께 보여줄 때 사용합니다. +export interface SubscriptionWithWork extends Subscription { + works: Pick | null; +} diff --git a/src/types/work.ts b/src/types/work.ts new file mode 100644 index 0000000..9dd9290 --- /dev/null +++ b/src/types/work.ts @@ -0,0 +1,10 @@ +import { Tables, TablesInsert } from "./database.types"; + +export type Work = Tables<"works">; +export type WorkInsert = TablesInsert<"works">; + +// 가독성을 위한 확장 예시 +export interface WorkWithDetail extends Work { + is_subscribed: boolean; + tags: string[]; +} diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..b7cfb55 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,414 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "commenta" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 +# Controls whether new tables, views, sequences and functions created in the `public` schema by +# `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) +# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud +# default. Set to `true` to keep the legacy behaviour of auto-exposing new entities; this is +# deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent. +# auto_expose_new_tables = true + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +[storage.vector] +enabled = true +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ `{{ .Code }}` }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ `{{ .Code }}` }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# pg-delta is the schema diff engine for db diff / db pull / db remote commit. +# Set enabled = false to fall back to the legacy migra engine. +[experimental.pgdelta] +enabled = true +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/20260621111612_init_schema.sql b/supabase/migrations/20260621111612_init_schema.sql new file mode 100644 index 0000000..c14b6ea --- /dev/null +++ b/supabase/migrations/20260621111612_init_schema.sql @@ -0,0 +1,105 @@ +-- 1. EXTENSIONS (UUID 생성을 위해 필요) +create extension if not exists "uuid-ossp"; + +-- 2. TABLES 생성 +-- profiles 테이블 +create table public.profiles ( + id uuid references auth.users on delete cascade not null primary key, + email varchar not null, + nickname varchar not null, + profile_url varchar, + is_no_spoiler_mode boolean not null default false, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- works 테이블 +create table public.works ( + id uuid default gen_random_uuid() not null primary key, + title varchar not null, + author varchar not null, + usage_count integer not null default 0, + subscribe_count integer not null default 0, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- tags 테이블 +create table public.tags ( + id uuid default gen_random_uuid() not null primary key, + work_id uuid references public.works(id) on delete cascade not null, + name varchar not null, + type varchar not null, + created_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- commentaries 테이블 +create table public.commentaries ( + id uuid default gen_random_uuid() not null primary key, + content text not null, + author_id uuid references public.profiles(id) on delete cascade not null, + work_id uuid references public.works(id) on delete cascade not null, + is_spoiler boolean not null default false, + episode integer, + img_urls text[] default '{}'::text[] not null, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- commentary_tags 테이블 (N:M 매핑 테이블) +create table public.commentary_tags ( + commentary_id uuid references public.commentaries(id) on delete cascade not null, + tag_id uuid references public.tags(id) on delete cascade not null, + primary key (commentary_id, tag_id) +); + +-- subscriptions 테이블 +create table public.subscriptions ( + id uuid default gen_random_uuid() not null primary key, + user_id uuid references public.profiles(id) on delete cascade not null, + work_id uuid references public.works(id) on delete cascade not null, + episode integer, + created_at timestamp with time zone default timezone('utc'::text, now()) not null, + updated_at timestamp with time zone default timezone('utc'::text, now()) not null +); + +-- 3. ROW LEVEL SECURITY (RLS) 활성화 +alter table public.profiles enable row level security; +alter table public.works enable row level security; +alter table public.tags enable row level security; +alter table public.commentaries enable row level security; +alter table public.commentary_tags enable row level security; +alter table public.subscriptions enable row level security; + +-- 4. RLS POLICIES (보안 정책) 정의 + +-- profiles 정책 +create policy "profiles_select_policy" on public.profiles for select using (true); +create policy "profiles_insert_policy" on public.profiles for insert with check (auth.uid() = id); +create policy "profiles_update_policy" on public.profiles for update using (auth.uid() = id); + +-- works 정책 +create policy "works_select_policy" on public.works for select using (true); +create policy "works_insert_policy" on public.works for insert with check (auth.role() = 'authenticated'); + +-- tags 정책 +create policy "tags_select_policy" on public.tags for select using (true); +create policy "tags_insert_policy" on public.tags for insert with check (auth.role() = 'authenticated'); + +-- commentaries 정책 +create policy "commentaries_select_policy" on public.commentaries for select using (true); +create policy "commentaries_insert_policy" on public.commentaries for insert with check (auth.uid() = author_id); +create policy "commentaries_update_policy" on public.commentaries for update using (auth.uid() = author_id); +create policy "commentaries_delete_policy" on public.commentaries for delete using (auth.uid() = author_id); + +-- commentary_tags 정책 +create policy "commentary_tags_select_policy" on public.commentary_tags for select using (true); +create policy "commentary_tags_insert_policy" on public.commentary_tags for insert with check ( + exists (select 1 from public.commentaries where id = commentary_id and author_id = auth.uid()) +); +create policy "commentary_tags_delete_policy" on public.commentary_tags for delete using ( + exists (select 1 from public.commentaries where id = commentary_id and author_id = auth.uid()) +); + +-- subscriptions 정책 +create policy "subscriptions_all_policy" on public.subscriptions for all using (auth.uid() = user_id); \ No newline at end of file diff --git a/supabase/migrations/20260621113151_add_indexes_and_triggers.sql b/supabase/migrations/20260621113151_add_indexes_and_triggers.sql new file mode 100644 index 0000000..57cb844 --- /dev/null +++ b/supabase/migrations/20260621113151_add_indexes_and_triggers.sql @@ -0,0 +1,49 @@ +-- commentaries 성능 최적화 (복합 인덱스) +create index idx_commentaries_work_episode on public.commentaries (work_id, episode desc); +create index idx_commentaries_author_id on public.commentaries (author_id); + +-- subscriptions 마이페이지 조회 최적화 +create index idx_subscriptions_user_id on public.subscriptions (user_id); + +-- tags 작품별 태그 조회 최적화 +create index idx_tags_work_id on public.tags (work_id); + +-- 1. 코멘터리 추가/삭제 시 works.usage_count 자동 증감 함수 및 트리거 +create or replace function public.handle_commentary_count() +returns trigger as $$ +begin + if (TG_OP = 'INSERT') then + update public.works set usage_count = usage_count + 1 where id = NEW.work_id; + elsif (TG_OP = 'DELETE') then + update public.works set usage_count = usage_count - 1 where id = OLD.work_id; + end if; + return null; +end; +$$ language plpgsql security definer; + +create trigger on_commentary_created_or_deleted + after insert or delete on public.commentaries + for each row execute function public.handle_commentary_count(); + + +-- 2. 구독 추가/삭제 시 works.subscribe_count 자동 증감 함수 및 트리거 +create or replace function public.handle_subscription_count() +returns trigger as $$ +begin + if (TG_OP = 'INSERT') then + update public.works set subscribe_count = subscribe_count + 1 where id = NEW.work_id; + elsif (TG_OP = 'DELETE') then + update public.works set subscribe_count = subscribe_count - 1 where id = OLD.work_id; + end if; + return null; +end; +$$ language plpgsql security definer; + +create trigger on_subscription_created_or_deleted + after insert or delete on public.subscriptions + for each row execute function public.handle_subscription_count(); + + +-- 특정 작품(work_id) 내에서 공백을 제거한 태그 이름이 중복되지 않도록 유니크 인덱스 설정 +create unique index idx_tags_work_id_name_unique +on public.tags (work_id, replace(name, ' ', '')); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 252b3a9..1b271df 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,12 @@ "@/*": ["./src/*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], "exclude": ["node_modules"] } From 9428d4db01d3026590269387cb03e0b810f3dd01 Mon Sep 17 00:00:00 2001 From: Jade Date: Sun, 21 Jun 2026 21:09:07 +0900 Subject: [PATCH 2/4] feat: add supabase ssr package, grant public sql --- package.json | 2 + src/lib/supabase/client.ts | 9 ++ src/lib/supabase/server.ts | 29 ++++++ .../20260621120644_grant_public_access.sql | 5 + yarn.lock | 96 ++++++++++++++++++- 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 src/lib/supabase/client.ts create mode 100644 src/lib/supabase/server.ts create mode 100644 supabase/migrations/20260621120644_grant_public_access.sql diff --git a/package.json b/package.json index 96e6e52..b6a184f 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@radix-ui/react-tabs": "^1.1.14", "@radix-ui/react-tooltip": "^1.2.9", "@radix-ui/react-visually-hidden": "^1.2.5", + "@supabase/ssr": "^0.12.0", + "@supabase/supabase-js": "^2.108.2", "@tanstack/react-query": "^5.101.0", "axios": "^1.17.0", "class-variance-authority": "^0.7.1", diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts new file mode 100644 index 0000000..21cc0c7 --- /dev/null +++ b/src/lib/supabase/client.ts @@ -0,0 +1,9 @@ +import { createBrowserClient } from "@supabase/ssr"; +import { Database } from "@/types/database.types"; + +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} diff --git a/src/lib/supabase/server.ts b/src/lib/supabase/server.ts new file mode 100644 index 0000000..49ae39a --- /dev/null +++ b/src/lib/supabase/server.ts @@ -0,0 +1,29 @@ +import { createServerClient } from "@supabase/ssr"; +import { cookies } from "next/headers"; +import { Database } from "@/types/database.types"; + +export async function createClient() { + const cookieStore = await cookies(); + + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ); + } catch { + // Server Component에서 호출되었을 때, 쿠키를 세팅하려 하면 에러가 날 수 있습니다. + // Server Actions나 Route Handlers 환경이 아니라면 이 에러는 무시해도 안전합니다. + } + }, + }, + } + ); +} diff --git a/supabase/migrations/20260621120644_grant_public_access.sql b/supabase/migrations/20260621120644_grant_public_access.sql new file mode 100644 index 0000000..b5f02be --- /dev/null +++ b/supabase/migrations/20260621120644_grant_public_access.sql @@ -0,0 +1,5 @@ +-- public 스키마 및 하위 테이블/함수에 대한 기본 접근 권한 부여 +grant usage on schema public to anon, authenticated; +grant all privileges on all tables in schema public to anon, authenticated; +grant all privileges on all sequences in schema public to anon, authenticated; +grant all privileges on all functions in schema public to anon, authenticated; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 76e0c6e..b1171a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2380,6 +2380,84 @@ __metadata: languageName: node linkType: hard +"@supabase/auth-js@npm:2.108.2": + version: 2.108.2 + resolution: "@supabase/auth-js@npm:2.108.2" + dependencies: + tslib: "npm:2.8.1" + checksum: 10c0/31617770accb5910c1e7d19f230e3f1e5e1fd3e78912ea5ae342dddb7e032f58fb1d277560eae9a7892ef2d20b03c4989118a9ff17cb48aa5e7dd99eb566fb02 + languageName: node + linkType: hard + +"@supabase/functions-js@npm:2.108.2": + version: 2.108.2 + resolution: "@supabase/functions-js@npm:2.108.2" + dependencies: + tslib: "npm:2.8.1" + checksum: 10c0/06e899cd3445f519f8f595c648b8fa38f174f01335b18cf1ebcc87efc7cede997896e6b06c74747b4275c7b2514accee26d5ee0d4bb2bf5fc757c82070435624 + languageName: node + linkType: hard + +"@supabase/phoenix@npm:^0.4.2": + version: 0.4.4 + resolution: "@supabase/phoenix@npm:0.4.4" + checksum: 10c0/b4ba930c82b20d412358826aa87ce0735147fd168efb817bfc34e31d78605220947dc7c5844726268ac6bf338f0cd081f9908a7bab87aaff4058cb80bf71ab5b + languageName: node + linkType: hard + +"@supabase/postgrest-js@npm:2.108.2": + version: 2.108.2 + resolution: "@supabase/postgrest-js@npm:2.108.2" + dependencies: + tslib: "npm:2.8.1" + checksum: 10c0/eafb794763943f1c72683157785e325647e5cc8eb17904b6922b896b5c25f0be75c11376cb7a518b6f6ee983ad7d94a509e75950533f3da4fcf8f910f1e11433 + languageName: node + linkType: hard + +"@supabase/realtime-js@npm:2.108.2": + version: 2.108.2 + resolution: "@supabase/realtime-js@npm:2.108.2" + dependencies: + "@supabase/phoenix": "npm:^0.4.2" + tslib: "npm:2.8.1" + checksum: 10c0/3a5c19c4283b64cf22e91d264580905de0a724376314acf6a9f9e53ae74ba05bad679eaf57fe5c31e95213608c1bd065485127824bed377500b021d01cec0454 + languageName: node + linkType: hard + +"@supabase/ssr@npm:^0.12.0": + version: 0.12.0 + resolution: "@supabase/ssr@npm:0.12.0" + dependencies: + cookie: "npm:^1.0.2" + peerDependencies: + "@supabase/supabase-js": ^2.108.0 + checksum: 10c0/1cdc7e4305d310c76488da5c5ab3801683889264bd5e2dcde79ef6de985c72d8a45d9690eae373c9a74e724bc69d34307c87e89de02e22aba0269c7ffa6ed3c5 + languageName: node + linkType: hard + +"@supabase/storage-js@npm:2.108.2": + version: 2.108.2 + resolution: "@supabase/storage-js@npm:2.108.2" + dependencies: + iceberg-js: "npm:^0.8.1" + tslib: "npm:2.8.1" + checksum: 10c0/9e7ed4560150012ea8fa45c12b180533f166d6900b4a61e1a2bbf7f12e872ea42f85670db3dc781b7e76f983f5c9d320d84574775969d18804c6a0ca4aa44e7d + languageName: node + linkType: hard + +"@supabase/supabase-js@npm:^2.108.2": + version: 2.108.2 + resolution: "@supabase/supabase-js@npm:2.108.2" + dependencies: + "@supabase/auth-js": "npm:2.108.2" + "@supabase/functions-js": "npm:2.108.2" + "@supabase/postgrest-js": "npm:2.108.2" + "@supabase/realtime-js": "npm:2.108.2" + "@supabase/storage-js": "npm:2.108.2" + checksum: 10c0/1e627189af3d8088329fdbdfbff754285dd757f30d7d4e0415881d75b684143b6c1fe519449b69d4b30b52f732a7f4510566718640826a966e124c8331becbfb + languageName: node + linkType: hard + "@swc/helpers@npm:0.5.15": version: 0.5.15 resolution: "@swc/helpers@npm:0.5.15" @@ -3585,6 +3663,8 @@ __metadata: "@radix-ui/react-tabs": "npm:^1.1.14" "@radix-ui/react-tooltip": "npm:^1.2.9" "@radix-ui/react-visually-hidden": "npm:^1.2.5" + "@supabase/ssr": "npm:^0.12.0" + "@supabase/supabase-js": "npm:^2.108.2" "@tailwindcss/postcss": "npm:^4.3.0" "@tanstack/react-query": "npm:^5.101.0" "@types/node": "npm:^25.9.2" @@ -3630,6 +3710,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^1.0.2": + version: 1.1.1 + resolution: "cookie@npm:1.1.1" + checksum: 10c0/79c4ddc0fcad9c4f045f826f42edf54bcc921a29586a4558b0898277fa89fb47be95bc384c2253f493af7b29500c830da28341274527328f18eba9f58afa112c + languageName: node + linkType: hard + "cross-spawn@npm:^7.0.6": version: 7.0.6 resolution: "cross-spawn@npm:7.0.6" @@ -5033,6 +5120,13 @@ __metadata: languageName: node linkType: hard +"iceberg-js@npm:^0.8.1": + version: 0.8.1 + resolution: "iceberg-js@npm:0.8.1" + checksum: 10c0/e504da64cc26e8ad7c5344353a7203c671a740c14eb6086f20505f5e9739cab774493e616032530c962df97f2a855115cc2e89a47dd81581df5917b21a69ec48 + languageName: node + linkType: hard + "idb@npm:7.1.1": version: 7.1.1 resolution: "idb@npm:7.1.1" @@ -7229,7 +7323,7 @@ __metadata: languageName: node linkType: hard -"tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": +"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.1.0, tslib@npm:^2.4.0, tslib@npm:^2.8.0, tslib@npm:^2.8.1": version: 2.8.1 resolution: "tslib@npm:2.8.1" checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62 From f67278910d5783a5688ea77499c4b09928cf093a Mon Sep 17 00:00:00 2001 From: Jade Date: Sun, 21 Jun 2026 22:11:59 +0900 Subject: [PATCH 3/4] feat: login, signUp logic with supabase (need debugging signup error issue - AuthRetryableFetchError) --- src/actions/auth.ts | 117 ++++++++++++++++++ src/components/auth/LoginForm.tsx | 9 +- src/components/auth/SignUpForm.tsx | 32 +++-- ...20260621122334_handle_new_user_trigger.sql | 30 +++++ 4 files changed, 169 insertions(+), 19 deletions(-) create mode 100644 src/actions/auth.ts create mode 100644 supabase/migrations/20260621122334_handle_new_user_trigger.sql diff --git a/src/actions/auth.ts b/src/actions/auth.ts new file mode 100644 index 0000000..9a6d0f3 --- /dev/null +++ b/src/actions/auth.ts @@ -0,0 +1,117 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; + +interface SignUpResult { + success: boolean; + error?: string; + user?: { + id: string; + email: string; + nickname: string; + created_at: string; + is_no_spoiler_mode: boolean; + profile_url: string | null; + updated_at: string; + }; +} + +export const signUpWithEmail = async ( + email: string, + password: string, + nickname: string +): Promise => { + const supabase = await createClient(); + + const { data: authData, error: authError } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + nickname: nickname, + }, + // 이메일 인증(Confirm)을 켜두셨다면 인증 메일이 발송됩니다. + // 대시보드에서 이메일 인증을 끄면 가입 즉시 로그인 상태가 됩니다. + // emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"}/auth/callback`, + }, + }); + + if (authError || !authData.user) { + // console.error("회원가입 에러:", authError?.message); + console.error("❌ 프로필 로드 진짜 에러 원인:", { + name: authError?.name, + status: authError?.status, + message: authError?.message, + }); + return { success: false, error: authError?.message || "SignUp Failed" }; + } + + const { data: profileData, error: profileError } = await supabase + .from("profiles") + .select("*") + .eq("id", authData.user.id) + .single(); + + if (profileError || !profileData) { + // console.error("프로필 로드 실패:", profileError.message); + console.error("❌ 프로필 로드 진짜 에러 원인:", { + message: profileError?.message, + details: profileError?.details, + hint: profileError?.hint, + }); + // 프로필 조회 실패 시 가입은 되었으므로 최소한의 유저 정보라도 리턴 + return { + success: true, + user: { + id: authData.user.id, + email: authData.user.email || "", + nickname, + created_at: new Date().toISOString(), + is_no_spoiler_mode: false, + profile_url: null, + updated_at: new Date().toISOString(), + }, + }; + } + + return { + success: true, + user: { + id: profileData.id, + email: profileData.email || "", + nickname: profileData.nickname, + created_at: profileData.created_at, + is_no_spoiler_mode: profileData.is_no_spoiler_mode, + profile_url: profileData.profile_url, + updated_at: profileData.updated_at, + }, + }; +}; + +export const signInWithEmail = async (email: string, password: string) => { + const supabase = await createClient(); + + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (error) { + console.error("로그인 에러:", error.message); + return { success: false, error: error.message }; + } + + return { success: true, data }; +}; + +export const signOut = async () => { + const supabase = await createClient(); + const { error } = await supabase.auth.signOut(); + + if (error) { + console.error("로그아웃 에러:", error.message); + return { success: false, error: error.message }; + } + + return { success: true }; +}; diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 1d2f497..228e7b7 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -8,9 +8,9 @@ import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { useRouteModal } from "@/hooks/useRouteModal"; import { useLoadingStore } from "@/store/loadingStore"; -import { signInWithEmailAndPassword } from "firebase/auth"; import { Eye, EyeOff } from "lucide-react"; import { auth } from "@/lib/firebase"; +import { signInWithEmail } from "@/actions/auth"; export default function LoginForm({ close }: { close?: () => void }) { const [errorMsg, setErrorMsg] = useState(""); @@ -31,7 +31,12 @@ export default function LoginForm({ close }: { close?: () => void }) { return toast("check email or password"); } - await signInWithEmailAndPassword(auth, values.email, values.password); + const result = await signInWithEmail(values.email, values.password); + + if (!result.success) { + // 액션 에러 리턴 시 캐치문으로 던짐 + throw new Error(result.error); + } setErrorMsg(""); if (close) { diff --git a/src/components/auth/SignUpForm.tsx b/src/components/auth/SignUpForm.tsx index 7421ef1..8467b83 100644 --- a/src/components/auth/SignUpForm.tsx +++ b/src/components/auth/SignUpForm.tsx @@ -6,7 +6,6 @@ import { Label } from "@/components/ui/label"; import { Button } from "../ui/button"; import { useForm } from "@/hooks/useForm"; import { useState } from "react"; -import { signInWithEmailAndPassword } from "firebase/auth"; import { auth } from "@/lib/firebase"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; @@ -14,6 +13,7 @@ import axios from "axios"; import { fetchUserData } from "@/apis/userData"; import { useAuthStore } from "@/store/authStore"; import { checkEmailFormat, checkPasswordFormat } from "@/utils/validation"; +import { signUpWithEmail } from "@/actions/auth"; export default function SignUpForm({ close }: { close?: () => void }) { const [errorMsg, setErrorMsg] = useState(""); @@ -40,27 +40,25 @@ export default function SignUpForm({ close }: { close?: () => void }) { } try { - const { data } = await axios.post("/api/auth/signup", { - email: values.email, - password: values.password, - nickname: values.nickname, - }); + const result = await signUpWithEmail(values.email, values.password, values.nickname); - if (!data?.uid) { - // 서버에서 예상치 못한 응답이 온 경우 - toast.error("회원가입에 실패했습니다."); + if (!result.success || !result.user) { + toast.error(result.error || "회원가입에 실패했습니다."); return; } - toast.success("회원가입 완료! 자동 로그인 중..."); - - await signInWithEmailAndPassword(auth, values.email, values.password); + toast.success("회원가입 완료!"); - const userData = await fetchUserData(data.uid); - if (userData) { - useAuthStore.getState().setIsLoggedIn(true); - useAuthStore.getState().setUser(userData); - } + useAuthStore.getState().setIsLoggedIn(true); + useAuthStore.getState().setUser({ + uid: result.user.id, + email: result.user.email || "", + nickname: result.user.nickname, + createdAt: new Date(result.user.created_at), + subscribes: [], + isNoSpoilerMode: result.user.is_no_spoiler_mode, + profileUrl: result.user.profile_url, + }); setErrorMsg(""); if (close) close(); diff --git a/supabase/migrations/20260621122334_handle_new_user_trigger.sql b/supabase/migrations/20260621122334_handle_new_user_trigger.sql new file mode 100644 index 0000000..12c21fc --- /dev/null +++ b/supabase/migrations/20260621122334_handle_new_user_trigger.sql @@ -0,0 +1,30 @@ +-- 기존 함수 및 트리거 완전 초기화 +drop trigger if exists on_auth_user_created on auth.users; +drop function if exists public.handle_new_user(); + +-- 1. 새로운 유저가 가입할 때 실행될 함수(Function) 정의 +create or replace function public.handle_new_user() +returns trigger +language plpgsql +security definer -- 중요: 관리자 권한으로 실행하여 public.profiles에 쓸 수 있게 함 +as $$ +begin + insert into public.profiles (id, email, nickname, created_at, + is_no_spoiler_mode, + profile_url) + values ( + new.id, -- auth.users의 UUID + new.email, -- 유저 이메일 + coalesce(new.raw_user_meta_data->>'nickname', 'User_' || substr(new.id::text, 1, 8)), + now(), + false, + null + ); + return new; +end; +$$; + +-- 2. auth.users 테이블에 insert 이벤트가 발생하면 위 함수를 실행하는 트리거(Trigger) 생성 +create or replace trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); \ No newline at end of file From c1c39b910cf3de8cccbcbd6f0b76d45229d6fa44 Mon Sep 17 00:00:00 2001 From: Jade Date: Sun, 28 Jun 2026 20:10:50 +0900 Subject: [PATCH 4/4] feat: remove unused apiClient & api Routes, use server actions --- src/actions/auth.ts | 51 +++- src/actions/commentary.ts | 131 ++++++++++ src/actions/subscription.ts | 98 ++++++++ src/actions/user.ts | 33 +++ src/actions/work.ts | 82 ++++++ src/apis/category.ts | 59 ----- src/apis/commentaries.ts | 26 -- src/apis/commentary.ts | 86 ------- src/apis/subscribe.ts | 60 ----- src/apis/userData.ts | 43 ---- src/app/api/auth/signup/route.ts | 36 --- src/app/api/category/route.ts | 117 --------- src/app/api/category/search/route.ts | 63 ----- src/app/api/commentaries/route.ts | 14 -- src/app/api/commentary/[id]/route.ts | 98 -------- src/app/api/commentary/route.ts | 107 -------- src/app/api/subscribe/[uid]/route.ts | 235 ------------------ src/app/api/users/[uid]/route.ts | 62 ----- src/components/auth/AuthLayout.tsx | 85 +++++-- src/components/auth/LoginForm.tsx | 16 +- src/components/auth/SignUpForm.tsx | 3 - .../category/CategoryRecommender.tsx | 8 +- src/components/category/CategorySearch.tsx | 7 +- .../commentaries/FilteredCommentaryList.tsx | 11 +- .../commentary/CommentaryDetail.tsx | 3 +- src/components/commentary/CommentaryItem.tsx | 3 +- src/components/commentary/CommentaryList.tsx | 2 +- .../commentary/WriteCommentaryForm.tsx | 6 +- src/components/guide/Guide.tsx | 10 +- src/components/my/MyCommentariesSection.tsx | 4 +- src/components/my/NoSpoilerModeSection.tsx | 11 +- src/components/my/SubscribeSection.tsx | 5 +- src/components/my/UserProfileSection.tsx | 14 +- src/components/navigation/Navigation.tsx | 2 +- .../subscribe/SubscribeModalContent.tsx | 11 +- src/hooks/useImageUpload.tsx | 11 +- src/lib/admin.ts | 17 -- src/lib/apiClient.ts | 20 -- src/lib/firebase.ts | 19 -- src/lib/server/auth.ts | 24 -- src/lib/server/commentaries.ts | 66 ----- src/middleware.ts | 37 +++ src/store/authStore.ts | 10 +- src/types/commentary.ts | 22 +- src/types/subscription.ts | 7 +- src/types/work.ts | 11 +- 46 files changed, 586 insertions(+), 1260 deletions(-) create mode 100644 src/actions/commentary.ts create mode 100644 src/actions/subscription.ts create mode 100644 src/actions/user.ts create mode 100644 src/actions/work.ts delete mode 100644 src/apis/category.ts delete mode 100644 src/apis/commentaries.ts delete mode 100644 src/apis/commentary.ts delete mode 100644 src/apis/subscribe.ts delete mode 100644 src/apis/userData.ts delete mode 100644 src/app/api/auth/signup/route.ts delete mode 100644 src/app/api/category/route.ts delete mode 100644 src/app/api/category/search/route.ts delete mode 100644 src/app/api/commentaries/route.ts delete mode 100644 src/app/api/commentary/[id]/route.ts delete mode 100644 src/app/api/commentary/route.ts delete mode 100644 src/app/api/subscribe/[uid]/route.ts delete mode 100644 src/app/api/users/[uid]/route.ts delete mode 100644 src/lib/admin.ts delete mode 100644 src/lib/apiClient.ts delete mode 100644 src/lib/firebase.ts delete mode 100644 src/lib/server/auth.ts delete mode 100644 src/lib/server/commentaries.ts create mode 100644 src/middleware.ts diff --git a/src/actions/auth.ts b/src/actions/auth.ts index 9a6d0f3..6760a31 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -30,9 +30,6 @@ export const signUpWithEmail = async ( data: { nickname: nickname, }, - // 이메일 인증(Confirm)을 켜두셨다면 인증 메일이 발송됩니다. - // 대시보드에서 이메일 인증을 끄면 가입 즉시 로그인 상태가 됩니다. - // emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:3000"}/auth/callback`, }, }); @@ -53,12 +50,7 @@ export const signUpWithEmail = async ( .single(); if (profileError || !profileData) { - // console.error("프로필 로드 실패:", profileError.message); - console.error("❌ 프로필 로드 진짜 에러 원인:", { - message: profileError?.message, - details: profileError?.details, - hint: profileError?.hint, - }); + console.error("프로필 로드 실패:", profileError?.message); // 프로필 조회 실패 시 가입은 되었으므로 최소한의 유저 정보라도 리턴 return { success: true, @@ -96,12 +88,45 @@ export const signInWithEmail = async (email: string, password: string) => { password, }); - if (error) { - console.error("로그인 에러:", error.message); - return { success: false, error: error.message }; + if (error || !data.user) { + console.error("로그인 에러:", error?.message); + return { success: false, error: error?.message || "Login Failed" }; } - return { success: true, data }; + const { data: profileData, error: profileError } = await supabase + .from("profiles") + .select("*") + .eq("id", data.user.id) + .single(); + + if (profileError || !profileData) { + console.error("프로필 로드 실패:", profileError?.message); + return { + success: true, + user: { + id: data.user.id, + email: data.user.email || "", + nickname: data.user.user_metadata?.nickname || "", + created_at: new Date().toISOString(), + is_no_spoiler_mode: false, + profile_url: null, + updated_at: new Date().toISOString(), + }, + }; + } + + return { + success: true, + user: { + id: profileData.id, + email: profileData.email || "", + nickname: profileData.nickname, + created_at: profileData.created_at, + is_no_spoiler_mode: profileData.is_no_spoiler_mode, + profile_url: profileData.profile_url, + updated_at: profileData.updated_at, + }, + }; }; export const signOut = async () => { diff --git a/src/actions/commentary.ts b/src/actions/commentary.ts new file mode 100644 index 0000000..e70da82 --- /dev/null +++ b/src/actions/commentary.ts @@ -0,0 +1,131 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { Commentary } from "@/types/commentary"; +import { TablesUpdate } from "@/types/database.types"; + +const SELECT_COMMENTARY_FIELDS = ` + id, content, episode, img_urls, is_spoiler, created_at, updated_at, author_id, work_id, + works(title), + profiles!commentaries_author_id_fkey(nickname, profile_url) +`; + +const mapRowToCommentary = (item: any): Commentary => { + const work = item.works as { title: string } | null; + const profile = item.profiles as { nickname: string; profile_url: string | null } | null; + + return { + id: item.id, + content: item.content, + authorId: item.author_id, + authorNickName: profile?.nickname ?? "", + authorProfileUrl: profile?.profile_url ?? null, + categoryTitle: work?.title ?? "", + categoryId: item.work_id, + imgUrlList: item.img_urls, + isSpoiler: item.is_spoiler, + episode: item.episode ?? undefined, + createdAt: new Date(item.created_at), + updatedAt: new Date(item.updated_at), + }; +}; + +export const getMyCommentaries = async (authorId: string): Promise => { + const supabase = await createClient(); + + const { data, error } = await supabase + .from("commentaries") + .select(SELECT_COMMENTARY_FIELDS) + .eq("author_id", authorId) + .order("created_at", { ascending: false }); + + if (error || !data) { + console.error("코멘터리 목록 조회 실패:", error?.message); + return []; + } + + return data.map(mapRowToCommentary); +}; + +export const getCommentariesByWorkIds = async (workIds: string[]): Promise => { + if (!workIds.length) return []; + + const supabase = await createClient(); + + const { data, error } = await supabase + .from("commentaries") + .select(SELECT_COMMENTARY_FIELDS) + .in("work_id", workIds) + .order("created_at", { ascending: false }); + + if (error || !data) { + console.error("코멘터리 목록 조회 실패:", error?.message); + return []; + } + + return data.map(mapRowToCommentary); +}; + +export const getCommentary = async (commentaryId: string): Promise => { + const supabase = await createClient(); + + const { data, error } = await supabase + .from("commentaries") + .select(SELECT_COMMENTARY_FIELDS) + .eq("id", commentaryId) + .single(); + + if (error || !data) { + console.error("코멘터리 조회 실패:", error?.message); + return null; + } + + return mapRowToCommentary(data); +}; + +export const createCommentary = async ( + body: Omit +): Promise => { + const supabase = await createClient(); + + const { error } = await supabase.from("commentaries").insert({ + author_id: body.authorId, + work_id: body.categoryId, + content: body.content, + episode: body.episode ?? null, + img_urls: body.imgUrlList ?? [], + is_spoiler: body.isSpoiler ?? false, + }); + + if (error) throw new Error(error.message); +}; + +export const editCommentary = async ( + body: { id: string } & Partial< + Omit + > +): Promise => { + const supabase = await createClient(); + + const updates: TablesUpdate<"commentaries"> = {}; + if (body.content !== undefined) updates.content = body.content; + if (body.episode !== undefined) updates.episode = body.episode ?? null; + if (body.imgUrlList !== undefined) updates.img_urls = body.imgUrlList; + if (body.isSpoiler !== undefined) updates.is_spoiler = body.isSpoiler; + if (body.categoryId !== undefined) updates.work_id = body.categoryId; + + const { error } = await supabase + .from("commentaries") + .update(updates) + .eq("id", body.id); + + if (error) throw new Error(error.message); +}; + +export const deleteCommentary = async (commentaryId: string): Promise => { + const supabase = await createClient(); + + const { error } = await supabase.from("commentaries").delete().eq("id", commentaryId); + + if (error) throw new Error(error.message); +}; diff --git a/src/actions/subscription.ts b/src/actions/subscription.ts new file mode 100644 index 0000000..42653cc --- /dev/null +++ b/src/actions/subscription.ts @@ -0,0 +1,98 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { SubscribeCategory } from "@/types/subscription"; + +export const getUserSubscriptions = async (userId: string): Promise => { + const supabase = await createClient(); + + const { data, error } = await supabase + .from("subscriptions") + .select("id, episode, works(id, title, author, subscribe_count, usage_count, created_at)") + .eq("user_id", userId); + + if (error || !data) { + console.error("구독 목록 조회 실패:", error?.message); + return []; + } + + return data.map(sub => { + const work = sub.works as { + id: string; + title: string; + author: string; + subscribe_count: number; + usage_count: number; + created_at: string; + }; + + return { + id: sub.id, + episode: sub.episode, + detail: { + id: work.id, + title: work.title, + author: work.author, + createdAt: new Date(work.created_at), + subscribeCount: work.subscribe_count, + usageCount: work.usage_count, + }, + }; + }); +}; + +export const addSubscription = async ( + userId: string, + body: { id: string; episode: number | null } +): Promise => { + const supabase = await createClient(); + + const { data: existing } = await supabase + .from("subscriptions") + .select("id") + .eq("user_id", userId) + .eq("work_id", body.id) + .maybeSingle(); + + if (existing) { + throw new Error("Already Subscribed"); + } + + const { error } = await supabase.from("subscriptions").insert({ + user_id: userId, + work_id: body.id, + episode: body.episode, + }); + + if (error) throw new Error(error.message); +}; + +export const updateSubscription = async ( + userId: string, + body: { id: string; episode: number | null } +): Promise => { + const supabase = await createClient(); + + const { error } = await supabase + .from("subscriptions") + .update({ episode: body.episode }) + .eq("user_id", userId) + .eq("work_id", body.id); + + if (error) throw new Error(error.message); +}; + +export const deleteSubscription = async ( + userId: string, + subscriptionId: string +): Promise => { + const supabase = await createClient(); + + const { error } = await supabase + .from("subscriptions") + .delete() + .eq("id", subscriptionId) + .eq("user_id", userId); + + if (error) throw new Error(error.message); +}; diff --git a/src/actions/user.ts b/src/actions/user.ts new file mode 100644 index 0000000..231db66 --- /dev/null +++ b/src/actions/user.ts @@ -0,0 +1,33 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { TablesUpdate } from "@/types/database.types"; + +export interface UserProfileUpdates { + profileUrl?: string | null; + isNoSpoilerMode?: boolean; + nickname?: string; +} + +export const updateUserProfile = async ( + userId: string, + updates: UserProfileUpdates +): Promise<{ profile_url: string | null; is_no_spoiler_mode: boolean; nickname: string }> => { + const supabase = await createClient(); + + const dbUpdates: TablesUpdate<"profiles"> = {}; + if (updates.profileUrl !== undefined) dbUpdates.profile_url = updates.profileUrl; + if (updates.isNoSpoilerMode !== undefined) dbUpdates.is_no_spoiler_mode = updates.isNoSpoilerMode; + if (updates.nickname !== undefined) dbUpdates.nickname = updates.nickname; + + const { data, error } = await supabase + .from("profiles") + .update(dbUpdates) + .eq("id", userId) + .select("profile_url, is_no_spoiler_mode, nickname") + .single(); + + if (error || !data) throw new Error(error?.message || "프로필 업데이트 실패"); + + return data; +}; diff --git a/src/actions/work.ts b/src/actions/work.ts new file mode 100644 index 0000000..017182a --- /dev/null +++ b/src/actions/work.ts @@ -0,0 +1,82 @@ +"use server"; + +import { createClient } from "@/lib/supabase/server"; +import { Category } from "@/types/work"; + +const mapRowToWork = (row: { + id: string; + title: string; + author: string; + created_at: string; + usage_count: number; + subscribe_count: number; +}): Category => ({ + id: row.id, + title: row.title, + author: row.author, + createdAt: new Date(row.created_at), + usageCount: row.usage_count, + subscribeCount: row.subscribe_count, +}); + +export const searchWorks = async (query: string): Promise => { + const supabase = await createClient(); + + const { data, error } = await supabase + .from("works") + .select("id, title, author, created_at, usage_count, subscribe_count") + .or(`title.ilike.%${query}%,author.ilike.%${query}%`) + .order("usage_count", { ascending: false }) + .limit(20); + + if (error || !data) { + console.error("작품 검색 실패:", error?.message); + return []; + } + + return data.map(mapRowToWork); +}; + +export const addWork = async ({ + title, + author, +}: { + title: string; + author: string; +}): Promise => { + const supabase = await createClient(); + + const { data, error } = await supabase + .from("works") + .insert({ title, author }) + .select("id, title, author, created_at, usage_count, subscribe_count") + .single(); + + if (error || !data) { + throw new Error(error?.message || "작품 등록 실패"); + } + + return mapRowToWork(data); +}; + +export const getWorksByCount = async ( + type: "usage" | "subscribe", + limit: number = 5 +): Promise => { + const supabase = await createClient(); + + const orderColumn = type === "usage" ? "usage_count" : "subscribe_count"; + + const { data, error } = await supabase + .from("works") + .select("id, title, author, created_at, usage_count, subscribe_count") + .order(orderColumn, { ascending: false }) + .limit(limit); + + if (error || !data) { + console.error("작품 목록 조회 실패:", error?.message); + return []; + } + + return data.map(mapRowToWork); +}; diff --git a/src/apis/category.ts b/src/apis/category.ts deleted file mode 100644 index 4fbbbf2..0000000 --- a/src/apis/category.ts +++ /dev/null @@ -1,59 +0,0 @@ -import apiClient from "@/lib/apiClient"; - -export interface Category { - id: string; - title: string; - author: string; - createdAt: Date; - usageCount: number; - subscribeCount: number; -} - -//작품 추가 -export const addCategory = async ({ title, author }: { title: string; author: string }) => { - try { - const res = await apiClient.post("/category", { title, author }); - - if (res.status !== 200 && res.status !== 201) { - console.warn("Failed to add category"); - return null; - } - - return res.data; - } catch (error) { - console.error("Error add commentary data:", error); - return null; - } -}; - -//작품 검색 -export const searchCategoryList = async (search: string): Promise => { - try { - const res = await apiClient.get("/category/search", { - params: { q: search }, - }); - return res.data.categories; - } catch (error) { - console.error("카테고리 검색 실패:", error); - return null; - } -}; - -export const getCategoriesByCount = async ( - type: "usage" | "subscribe", - limit: number = 5 -): Promise => { - try { - const res = await apiClient.get(`/category`, { - params: { type, limit }, - }); - - if (res.data.success) { - return res.data.categories; - } - return null; - } catch (error) { - console.error("카테고리 조회 실패:", error); - return null; - } -}; diff --git a/src/apis/commentaries.ts b/src/apis/commentaries.ts deleted file mode 100644 index 8095658..0000000 --- a/src/apis/commentaries.ts +++ /dev/null @@ -1,26 +0,0 @@ -import apiClient from "@/lib/apiClient"; -import { Commentary } from "./commentary"; -import { Subscribe } from "@/store/authStore"; - -export const getCommentaryList = async ( - categoryIds?: string[], - authorId?: string, - subscribes?: Subscribe[] -): Promise => { - try { - const res = await apiClient.post(`/commentaries`, { - categoryIds, - authorId, - subscribes, - }); - - if (res.status !== 200 || !res.data) { - console.warn(`CommentaryList not found`); - return null; - } - return res.data || []; - } catch (error) { - console.error("Error fetching user data:", error); - return []; - } -}; diff --git a/src/apis/commentary.ts b/src/apis/commentary.ts deleted file mode 100644 index 474cbe2..0000000 --- a/src/apis/commentary.ts +++ /dev/null @@ -1,86 +0,0 @@ -import apiClient from "@/lib/apiClient"; - -export interface Commentary { - id: string; - imgUrlList?: string[]; - content: string; - authorId: string; - authorNickName: string; - authorProfileUrl: string | null; - categoryTitle: string; - categoryId: string; - isSpoiler?: boolean; - episode?: number; - createdAt: Date; - updatedAt: Date; -} - -export const getCommentary = async (commentaryId: string): Promise => { - try { - const res = await apiClient.get(`/commentary/${commentaryId}`); - - if (res.status !== 200 && res.status !== 201) { - console.warn("Failed to get commentary"); - return null; - } - - return res.data.data; - } catch (error) { - console.error("Error get commentary data:", error); - return null; - } -}; - -export const createCommentary = async ( - body: Omit -) => { - try { - const res = await apiClient.post("/commentary", body); - - if (res.status !== 200 && res.status !== 201) { - console.warn("Failed to create commentary"); - return null; - } - - return res.data; - } catch (error) { - console.error("Error add commentary data:", error); - return null; - } -}; - -export const deleteCommentary = async (commentaryId: string) => { - try { - const res = await apiClient.delete(`/commentary?id=${commentaryId}`); - - if (res.status !== 200 && res.status !== 201) { - console.warn("Failed to delete commentary"); - return null; - } - - return res.data.success; - } catch (error) { - console.error("Error delete commentary data:", error); - return null; - } -}; - -export const editCommentary = async ( - body: { - id: string; - } & Partial> -) => { - try { - const res = await apiClient.patch(`/commentary/${body.id}`, body); - - if (res.status !== 200 && res.status !== 201) { - console.warn("Failed to edit commentary"); - return null; - } - - return res.data.success; - } catch (error) { - console.error("Error edit commentary data:", error); - return null; - } -}; diff --git a/src/apis/subscribe.ts b/src/apis/subscribe.ts deleted file mode 100644 index 803b2cc..0000000 --- a/src/apis/subscribe.ts +++ /dev/null @@ -1,60 +0,0 @@ -import apiClient from "@/lib/apiClient"; -import { Category } from "./category"; -import { Subscribe } from "@/store/authStore"; - -export type SubscribeCategory = Subscribe & { detail: Category }; - -export const getSubscribeCategoryList = async (uid: string): Promise => { - try { - const { data } = await apiClient.get(`/subscribe/${uid}`); - - return data.data || []; - } catch (error) { - console.error("Error fetching user sub data:", error); - return []; - } -}; - -export const addSubscription = async ( - uid: string, - body: { - id: string; - episode: number | null; - } -) => { - try { - const res = await apiClient.post(`/subscribe/${uid}`, body); - - return res; - } catch (error) { - console.error("Error add subscribe:", error); - throw error; - } -}; - -export const updateSubscription = async ( - uid: string, - body: { - id: string; - episode: number | null; - } -) => { - try { - const res = await apiClient.patch(`/subscribe/${uid}`, body); - - return res; - } catch (error) { - console.error("Error add subscribe:", error); - return null; - } -}; - -export const deleteSubscription = async (uid: string, id: string) => { - try { - const res = await apiClient.delete(`/subscribe/${uid}`, { data: { id } }); - return res; - } catch (error) { - console.error("Error delete subscribe:", error); - return null; - } -}; diff --git a/src/apis/userData.ts b/src/apis/userData.ts deleted file mode 100644 index f2db10d..0000000 --- a/src/apis/userData.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { UserData, useAuthStore } from "@/store/authStore"; -import apiClient from "@/lib/apiClient"; - -// 유저 데이터 조회 -export const fetchUserData = async (uid: string): Promise => { - const setUser = useAuthStore.getState().setUser; - - try { - const res = await apiClient.get(`/users/${uid}`); - - if (res.status !== 200 || !res.data) { - console.warn(`User ${uid} not found`); - return null; - } - - setUser(res.data as UserData); - return res.data as UserData; - } catch (error) { - console.error("Error fetching user data:", error); - return null; - } -}; - -export const updateUserData = async ( - uid: string, - changeData: Partial -): Promise => { - const setUser = useAuthStore.getState().setUser; - try { - const res = await apiClient.patch(`/users/${uid}`, changeData); - - if (res.status !== 200 || !res.data) { - console.warn(`User ${uid} data update fail`); - return null; - } - - setUser(res.data.data as UserData); - return res.data.data as UserData; - } catch (error) { - console.error("Error fetching user data:", error); - return null; - } -}; diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts deleted file mode 100644 index cbfd37f..0000000 --- a/src/app/api/auth/signup/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -export const runtime = "nodejs"; - -import { NextRequest, NextResponse } from "next/server"; -import { adminDb, adminAuth } from "@/lib/admin"; - -export async function POST(req: NextRequest) { - try { - const { email, password, nickname } = await req.json(); - - if (!email || !password || !nickname) { - return NextResponse.json({ error: "Invalid input" }, { status: 400 }); - } - - // Firebase Auth 유저 생성 - const userRecord = await adminAuth.createUser({ email, password }); - - // Firestore 유저 문서 생성 - await adminDb.collection("users").doc(userRecord.uid).set({ - uid: userRecord.uid, - email, - nickname, - createdAt: new Date().toISOString(), - subscribes: [], - isNoSpoilerMode: false, - profileUrl: null, - }); - - return NextResponse.json({ uid: userRecord.uid }); - } catch (error: any) { - console.error("회원가입 실패:", error); - let message = "Server error"; - if (error.code === "auth/email-already-exists") message = "이미 등록된 이메일입니다."; - if (error.code === "auth/weak-password") message = "비밀번호는 6자 이상이어야 합니다."; - return NextResponse.json({ success: false, error: message }, { status: 400 }); - } -} diff --git a/src/app/api/category/route.ts b/src/app/api/category/route.ts deleted file mode 100644 index 034885d..0000000 --- a/src/app/api/category/route.ts +++ /dev/null @@ -1,117 +0,0 @@ -export const runtime = "nodejs"; - -import { NextRequest, NextResponse } from "next/server"; -import { Timestamp } from "firebase-admin/firestore"; -import { adminDb } from "@/lib/admin"; -import { verifyAuth } from "@/lib/server/auth"; - -const normalizeForComparison = (str: string) => str.replace(/\s+/g, "").toLowerCase(); - -export async function POST(req: NextRequest) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - - try { - const { title, author } = await req.json(); - - if (!title || !author) { - return NextResponse.json( - { success: false, error: "제목과 작가명을 입력해주세요." }, - { status: 400 } - ); - } - - // 저장할 값 (앞뒤 공백만 제거) - const trimmedTitle = title.trim(); - const trimmedAuthor = author.trim(); - - // 비교용 값 (모든 공백 제거 + 소문자 변환) - const normalizedTitle = normalizeForComparison(trimmedTitle); - const normalizedAuthor = normalizeForComparison(trimmedAuthor); - - const snapshot = await adminDb - .collection("categories") - .where("title", "==", trimmedTitle) - .get(); - - const isDuplicate = snapshot.docs.some(doc => { - const data = doc.data(); - const existingTitle = normalizeForComparison(data.title); - const existingAuthor = normalizeForComparison(data.author); - return existingTitle === normalizedTitle && existingAuthor === normalizedAuthor; - }); - - if (isDuplicate) { - return NextResponse.json( - { success: false, error: "이미 동일한 제목과 작가명의 작품이 존재합니다." }, - { status: 409 } - ); - } - - const newDoc = { - title: trimmedTitle, - author: trimmedAuthor, - createdAt: Timestamp.now(), - usageCount: 0, - subscribeCount: 0, - }; - - const docRef = await adminDb.collection("categories").add(newDoc); - - return NextResponse.json( - { - success: true, - id: docRef.id, - ...newDoc, - }, - { status: 201 } - ); - } catch (error) { - console.error("Error creating category:", error); - return NextResponse.json({ success: false, error: "작품 등록 실패" }, { status: 500 }); - } -} - -export async function GET(req: NextRequest) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - - try { - const { searchParams } = new URL(req.url); - const type = searchParams.get("type") || "usage"; // "usage" | "subscribe" - const limitCount = Number(searchParams.get("limit") || 5); - - let query; - - if (type === "subscribe") { - query = adminDb.collection("categories").orderBy("subscribeCount", "desc").limit(limitCount); - } else { - query = adminDb.collection("categories").orderBy("usageCount", "desc").limit(limitCount); - } - - const snapshot = await query.get(); - - const categories = snapshot.docs.map(doc => ({ - id: doc.id, - ...(doc.data() as any), - })); - - return NextResponse.json( - { - success: true, - categories, - }, - { status: 200 } - ); - } catch (error) { - console.error("추천 카테고리 조회 실패:", error); - return NextResponse.json( - { - success: false, - error: "추천 카테고리 조회 중 오류가 발생했습니다.", - categories: [], - }, - { status: 500 } - ); - } -} diff --git a/src/app/api/category/search/route.ts b/src/app/api/category/search/route.ts deleted file mode 100644 index 39b1b78..0000000 --- a/src/app/api/category/search/route.ts +++ /dev/null @@ -1,63 +0,0 @@ -export const runtime = "nodejs"; - -import { NextRequest, NextResponse } from "next/server"; -import { adminDb } from "@/lib/admin"; -import { verifyAuth } from "@/lib/server/auth"; - -export async function GET(req: NextRequest) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - - try { - const { searchParams } = new URL(req.url); - const keyword = (searchParams.get("q") || "").trim(); - - if (!keyword) { - return NextResponse.json({ success: true, categories: [] }, { status: 200 }); - } - - // title 검색 - const titleSnap = await adminDb - .collection("categories") - .where("title", ">=", keyword) - .where("title", "<=", keyword + "\uf8ff") - .get(); - - // author 검색 - const authorSnap = await adminDb - .collection("categories") - .where("author", ">=", keyword) - .where("author", "<=", keyword + "\uf8ff") - .get(); - - const titleResults = titleSnap.docs.map(doc => ({ - id: doc.id, - ...(doc.data() as any), - })); - - const authorResults = authorSnap.docs - .filter(doc => !titleSnap.docs.find(t => t.id === doc.id)) - .map(doc => ({ - id: doc.id, - ...(doc.data() as any), - })); - - return NextResponse.json( - { - success: true, - categories: [...titleResults, ...authorResults], - }, - { status: 200 } - ); - } catch (error) { - console.error("검색 실패:", error); - return NextResponse.json( - { - success: false, - error: "검색 중 오류가 발생했습니다.", - categories: [], - }, - { status: 500 } - ); - } -} diff --git a/src/app/api/commentaries/route.ts b/src/app/api/commentaries/route.ts deleted file mode 100644 index 106e500..0000000 --- a/src/app/api/commentaries/route.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const runtime = "nodejs"; - -import { NextRequest, NextResponse } from "next/server"; -import { fetchCommentaryList } from "@/lib/server/commentaries"; -import { verifyAuth } from "@/lib/server/auth"; - -export async function POST(req: NextRequest) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - - const { authorId, categoryIds, subscribes } = await req.json(); - const commentaries = await fetchCommentaryList(authorId, categoryIds, subscribes); - return NextResponse.json(commentaries); -} diff --git a/src/app/api/commentary/[id]/route.ts b/src/app/api/commentary/[id]/route.ts deleted file mode 100644 index 60c64c3..0000000 --- a/src/app/api/commentary/[id]/route.ts +++ /dev/null @@ -1,98 +0,0 @@ -export const runtime = "nodejs"; - -import { NextRequest, NextResponse } from "next/server"; -import { adminDb } from "@/lib/admin"; -import { Timestamp } from "firebase-admin/firestore"; -import { verifyAuth } from "@/lib/server/auth"; - -export async function GET(req: NextRequest, context: any) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - try { - const { id: commentaryId } = await context.params; - - if (!commentaryId) { - return NextResponse.json( - { success: false, error: "commentaryId is required" }, - { status: 400 } - ); - } - - const docRef = adminDb.collection("commentaries").doc(commentaryId); - const docSnap = await docRef.get(); - - if (!docSnap.exists) { - return NextResponse.json({ success: false, error: "Commentary not found" }, { status: 404 }); - } - - const data = docSnap.data() as any; - let authorNickName = null; - let authorProfileUrl = null; - - if (data?.authorId) { - const userSnap = await adminDb.collection("users").doc(data.authorId).get(); - if (userSnap.exists) { - const userData = userSnap.data() as any; - authorNickName = userData.nickname ?? null; - authorProfileUrl = userData.profileUrl ?? null; - } - } - - return NextResponse.json( - { - success: true, - data: { - id: docSnap.id, - ...data, - authorNickName, - authorProfileUrl, - createdAt: data.createdAt.toDate().toISOString(), - updatedAt: data.updatedAt.toDate().toISOString(), - }, - }, - { status: 200 } - ); - } catch (error) { - console.error("Error fetching commentary:", error); - return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); - } -} - -export async function PATCH(req: NextRequest) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - try { - const body = await req.json(); - const { id, content, imgUrlList, isSpoiler, episode } = body; - - if (!id) { - return NextResponse.json({ success: false, error: "Missing commentaryId" }, { status: 400 }); - } - - const commentaryRef = adminDb.collection("commentaries").doc(id); - const commentaryDoc = await commentaryRef.get(); - - if (!commentaryDoc.exists) { - return NextResponse.json({ success: false, error: "Commentary not found" }, { status: 404 }); - } - - const updatedData: any = { - updatedAt: Timestamp.now(), - }; - - if (content !== undefined) updatedData.content = content; - if (imgUrlList !== undefined) updatedData.imgUrlList = imgUrlList; - if (isSpoiler !== undefined) updatedData.isSpoiler = isSpoiler; - if (episode !== undefined) updatedData.episode = episode; - - await commentaryRef.update(updatedData); - - return NextResponse.json( - { success: true, message: "Commentary updated successfully" }, - { status: 200 } - ); - } catch (error) { - console.error("Error updating commentary:", error); - return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); - } -} diff --git a/src/app/api/commentary/route.ts b/src/app/api/commentary/route.ts deleted file mode 100644 index c809b66..0000000 --- a/src/app/api/commentary/route.ts +++ /dev/null @@ -1,107 +0,0 @@ -export const runtime = "nodejs"; - -import { NextRequest, NextResponse } from "next/server"; -import { adminDb } from "@/lib/admin"; -import { FieldValue, Timestamp } from "firebase-admin/firestore"; -import { verifyAuth } from "@/lib/server/auth"; - -export async function POST(req: NextRequest) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - - try { - const body = await req.json(); - - const { - imgUrlList, - content, - authorId, - categoryId, - categoryTitle, - isSpoiler = false, - episode, - } = body; - - if (!content || !authorId || !categoryId || !categoryTitle) { - return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); - } - - const now = Timestamp.now(); - - const newCommentary = { - imgUrlList: imgUrlList || null, - content, - authorId, - categoryId, - categoryTitle, - isSpoiler, - episode: episode || null, - createdAt: now, - updatedAt: now, - }; - - await adminDb.runTransaction(async t => { - const categoryRef = adminDb.collection("categories").doc(categoryId); - - const categoryDoc = await t.get(categoryRef); - if (!categoryDoc.exists) { - throw new Error("Category not found"); - } - - // 코멘터리 등록 - const commentaryRef = adminDb.collection("commentaries").doc(); - t.set(commentaryRef, newCommentary); - - // usageCount 증가 - t.update(categoryRef, { - usageCount: FieldValue.increment(1), - }); - }); - - return NextResponse.json({ data: newCommentary }, { status: 201 }); - } catch (error) { - console.error("Error creating commentary:", error); - return NextResponse.json({ error: "Server error" }, { status: 500 }); - } -} - -export async function DELETE(req: NextRequest) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - try { - const searchParams = req.nextUrl.searchParams; - const commentaryId = searchParams.get("id"); - - if (!commentaryId) { - return NextResponse.json({ success: false, error: "Missing commentaryId" }, { status: 400 }); - } - - await adminDb.runTransaction(async t => { - const commentaryRef = adminDb.collection("commentaries").doc(commentaryId); - const commentaryDoc = await t.get(commentaryRef); - - if (!commentaryDoc.exists) { - throw new Error("Commentary not found"); - } - - const { categoryId } = commentaryDoc.data() as { categoryId: string }; - - // 코멘터리 삭제 - t.delete(commentaryRef); - - // usageCount 감소 - const categoryRef = adminDb.collection("categories").doc(categoryId); - t.update(categoryRef, { - usageCount: FieldValue.increment(-1), - }); - }); - - return NextResponse.json( - { success: true, message: "Commentary deleted successfully" }, - { status: 200 } - ); - } catch (error) { - console.error("Error deleting commentary:", error); - return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); - } -} diff --git a/src/app/api/subscribe/[uid]/route.ts b/src/app/api/subscribe/[uid]/route.ts deleted file mode 100644 index 234a01d..0000000 --- a/src/app/api/subscribe/[uid]/route.ts +++ /dev/null @@ -1,235 +0,0 @@ -export const runtime = "nodejs"; - -import { NextRequest, NextResponse } from "next/server"; -import { adminDb } from "@/lib/admin"; -import { UserData } from "@/store/authStore"; -import { Timestamp } from "firebase-admin/firestore"; -import { verifyAuth } from "@/lib/server/auth"; - -export async function GET(req: NextRequest, context: any) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - try { - const { uid } = await context.params; - - const userDoc = await adminDb.collection("users").doc(uid).get(); - - if (!userDoc.exists) { - return NextResponse.json({ success: false, error: "User not found" }, { status: 404 }); - } - - const userData = userDoc.data() as UserData; - - const subscribes = userData.subscribes || []; - - if (subscribes.length === 0) { - return NextResponse.json({ success: true, data: [] }, { status: 200 }); - } - - // 구독 정보 + 상세 카테고리 정보 병합 - const data = await Promise.all( - subscribes.map(async subscribe => { - const categoryDoc = await adminDb.collection("categories").doc(subscribe.id).get(); - const detail = categoryDoc.exists ? { id: categoryDoc.id, ...categoryDoc.data() } : null; - return { ...subscribe, detail }; - }) - ); - - return NextResponse.json({ success: true, data }, { status: 200 }); - } catch (error) { - console.error("Error fetching categories:", error); - return NextResponse.json({ error: "Server error" }, { status: 500 }); - } -} - -export async function POST(req: NextRequest, context: any) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - try { - const { uid } = await context.params; - const body = await req.json(); - - const { id, episode } = body; - - if (!id) { - return NextResponse.json( - { success: false, error: "Missing required fields" }, - { status: 400 } - ); - } - - const userRef = adminDb.collection("users").doc(uid); - const categoryRef = adminDb.collection("categories").doc(id); - - await adminDb.runTransaction(async t => { - const userDoc = await t.get(userRef); - const categoryDoc = await t.get(categoryRef); - - if (!userDoc.exists) { - throw new Error("User not found"); - } - if (!categoryDoc.exists) { - throw new Error("Category not found"); - } - - const now = Timestamp.now(); - const userData = userDoc.data() || {}; - const subscribes = (userData.subscribes || []) as any[]; - - if (subscribes.find(s => s.id === id)) { - throw new Error("ALREADY_SUBSCRIBED"); - } - - if (subscribes.length >= 10) { - throw new Error("LIMIT_EXCEEDED"); - } - - // 새로운 구독 추가 - subscribes.push({ - id, - episode, - createdAt: now, - updatedAt: now, - }); - - // 카테고리 구독자 수 증가 - t.update(categoryRef, { - subscribeCount: (categoryDoc.data()?.subscribeCount || 0) + 1, - }); - - t.update(userRef, { subscribes }); - }); - - return NextResponse.json({ success: true }); - } catch (error: any) { - console.error("Error subscribing category:", error); - - if (error.message === "ALREADY_SUBSCRIBED") { - return NextResponse.json({ success: false, error: "Already Subscribed" }, { status: 400 }); - } - - if (error.message === "LIMIT_EXCEEDED") { - return NextResponse.json( - { success: false, error: "Subscribe limit exceeded (max 10)" }, - { status: 400 } - ); - } - - if (error.message === "User not found" || error.message === "Category not found") { - return NextResponse.json({ success: false, error: error.message }, { status: 404 }); - } - - return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); - } -} - -export async function PATCH(req: NextRequest, context: any) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - try { - const { uid } = await context.params; - const body = await req.json(); - const { id, episode } = body; - - if (!id) { - return NextResponse.json( - { success: false, error: "Missing required fields" }, - { status: 400 } - ); - } - - const userRef = adminDb.collection("users").doc(uid); - const categoryRef = adminDb.collection("categories").doc(id); - - await adminDb.runTransaction(async t => { - const userDoc = await t.get(userRef); - const categoryDoc = await t.get(categoryRef); - - if (!userDoc.exists) throw new Error("User not found"); - if (!categoryDoc.exists) throw new Error("Category not found"); - - const userData = userDoc.data() || {}; - const subscribes = (userData.subscribes || []) as any[]; - - const index = subscribes.findIndex(s => s.id === id); - if (index === -1) throw new Error("SUBSCRIBE_NOT_FOUND"); - - // 구독 정보 수정 (episode 업데이트) - subscribes[index] = { - ...subscribes[index], - episode, - updatedAt: Timestamp.now(), - }; - - t.update(userRef, { subscribes }); - }); - - return NextResponse.json({ success: true }); - } catch (error: any) { - console.error("Error updating subscription:", error); - - if (error.message === "User not found" || error.message === "Category not found") { - return NextResponse.json({ success: false, error: error.message }, { status: 404 }); - } - if (error.message === "SUBSCRIBE_NOT_FOUND") { - return NextResponse.json({ success: false, error: "Subscribe not found" }, { status: 400 }); - } - - return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); - } -} - -export async function DELETE(req: NextRequest, context: any) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - try { - const { uid } = await context.params; - const body = await req.json(); - const { id } = body; - - if (!id) { - return NextResponse.json( - { success: false, error: "Missing required fields" }, - { status: 400 } - ); - } - - const userRef = adminDb.collection("users").doc(uid); - const categoryRef = adminDb.collection("categories").doc(id); - - await adminDb.runTransaction(async t => { - const userDoc = await t.get(userRef); - const categoryDoc = await t.get(categoryRef); - - if (!userDoc.exists) throw new Error("User not found"); - if (!categoryDoc.exists) throw new Error("Category not found"); - - const userData = userDoc.data() || {}; - const subscribes = (userData.subscribes || []) as any[]; - - const index = subscribes.findIndex(s => s.id === id); - if (index === -1) throw new Error("SUBSCRIBE_NOT_FOUND"); - - // 구독 제거 - subscribes.splice(index, 1); - - // 카테고리 구독자 수 감소 - const newCount = Math.max((categoryDoc.data()?.subscribeCount || 1) - 1, 0); - t.update(categoryRef, { subscribeCount: newCount }); - t.update(userRef, { subscribes }); - }); - - return NextResponse.json({ success: true }); - } catch (error: any) { - console.error("Error deleting subscription:", error); - - if (error.message === "User not found" || error.message === "Category not found") { - return NextResponse.json({ success: false, error: error.message }, { status: 404 }); - } - if (error.message === "SUBSCRIBE_NOT_FOUND") { - return NextResponse.json({ success: false, error: "Subscribe not found" }, { status: 400 }); - } - - return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); - } -} diff --git a/src/app/api/users/[uid]/route.ts b/src/app/api/users/[uid]/route.ts deleted file mode 100644 index 9147534..0000000 --- a/src/app/api/users/[uid]/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -export const runtime = "nodejs"; - -import { NextRequest, NextResponse } from "next/server"; -import { adminDb } from "@/lib/admin"; -import { verifyAuth } from "@/lib/server/auth"; - -export async function GET(req: NextRequest, context: any) { - const { uid, error } = await verifyAuth(req); - console.log(uid); - if (!uid) return error!; - try { - const { uid } = await context.params; - const userDoc = await adminDb.collection("users").doc(uid).get(); - - if (!userDoc.exists) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - - return NextResponse.json(userDoc.data()); - } catch (error) { - console.error("Error fetching user data:", error); - return NextResponse.json({ error: "Server error" }, { status: 500 }); - } -} - -const IMMUTABLE_FIELDS = ["id", "uid", "createdAt"]; - -export async function PATCH(req: NextRequest, context: any) { - const { uid, error } = await verifyAuth(req); - if (!uid) return error!; - try { - const { uid } = await context.params; - const body = await req.json(); - - // 수정 불가 필드는 제거 - const updates = Object.fromEntries( - Object.entries(body).filter(([key]) => !IMMUTABLE_FIELDS.includes(key)) - ); - - if (Object.keys(updates).length === 0) { - return NextResponse.json( - { success: false, error: "No valid fields to update" }, - { status: 400 } - ); - } - - const userRef = adminDb.collection("users").doc(uid); - const userDoc = await userRef.get(); - - if (!userDoc.exists) { - return NextResponse.json({ success: false, error: "User not found" }, { status: 404 }); - } - - await userRef.update(updates); - - const updatedDoc = await userRef.get(); - return NextResponse.json({ success: true, data: updatedDoc.data() }, { status: 200 }); - } catch (error) { - console.error("Error updating user data:", error); - return NextResponse.json({ success: false, error: "Server error" }, { status: 500 }); - } -} diff --git a/src/components/auth/AuthLayout.tsx b/src/components/auth/AuthLayout.tsx index 009ab9d..f544830 100644 --- a/src/components/auth/AuthLayout.tsx +++ b/src/components/auth/AuthLayout.tsx @@ -1,50 +1,89 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; import { useAuthStore } from "@/store/authStore"; -import { auth } from "@/lib/firebase"; -import { browserSessionPersistence, onAuthStateChanged, setPersistence } from "firebase/auth"; +import { createClient } from "@/lib/supabase/client"; import { usePathname } from "next/navigation"; -import { fetchUserData } from "@/apis/userData"; -import { toast } from "sonner"; import { useRouteModal } from "@/hooks/useRouteModal"; export default function AuthLayout({ children }: { children: React.ReactNode }) { const setIsLoggedIn = useAuthStore(state => state.setIsLoggedIn); const setUser = useAuthStore(state => state.setUser); const pathname = usePathname(); + const pathnameRef = useRef(pathname); const { openRouteModal } = useRouteModal(); const publicPath = ["/", "/login", "/signup"]; + // 콜백 안에서 DB 쿼리 시 Supabase 내부 deadlock 발생 + // → 콜백에서는 userId만 저장하고, 별도 effect에서 fetch + const [sessionUserId, setSessionUserId] = useState(undefined); + useEffect(() => { - // 세션 Persistence 설정 (브라우저 종료 시 로그아웃, 새로고침 시 유지) - setPersistence(auth, browserSessionPersistence).catch(err => { - console.error("Firebase persistence error:", err); - }); + pathnameRef.current = pathname; + }, [pathname]); + + // 1단계: auth 상태 변화 감지 → userId만 저장 + useEffect(() => { + const supabase = createClient(); - const unsubscribe = onAuthStateChanged(auth, async user => { - if (user) { + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((event, session) => { + if (session?.user) { setIsLoggedIn(true); - const userData = await fetchUserData(user.uid); - if (userData) { - setUser(userData); - } else { - toast.error("유저 정보를 찾지 못했습니다. 로그인 페이지로 이동합니다."); - setIsLoggedIn(false); - setUser(null); - openRouteModal("/login"); - } + setSessionUserId(session.user.id); } else { setIsLoggedIn(false); setUser(null); - if (!publicPath.includes(pathname)) { + setSessionUserId(null); + if (event !== "SIGNED_OUT" && !publicPath.includes(pathnameRef.current)) { openRouteModal("/login"); } } }); - return () => unsubscribe(); - }, [pathname]); + return () => subscription.unsubscribe(); + }, []); + + // 2단계: userId 확정 후 DB fetch + useEffect(() => { + if (!sessionUserId) return; + + const fetchUserData = async () => { + const supabase = createClient(); + + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("*") + .eq("id", sessionUserId) + .single(); + + if (profileError || !profile) { + console.error("AuthLayout: 프로필 조회 실패", profileError?.message); + setIsLoggedIn(false); + setUser(null); + openRouteModal("/login"); + return; + } + + const { data: subsData } = await supabase + .from("subscriptions") + .select("id, episode, work_id") + .eq("user_id", sessionUserId); + + setUser({ + uid: profile.id, + email: profile.email, + nickname: profile.nickname, + createdAt: new Date(profile.created_at), + subscribes: (subsData ?? []).map(s => ({ id: s.id, episode: s.episode })), + isNoSpoilerMode: profile.is_no_spoiler_mode, + profileUrl: profile.profile_url, + }); + }; + + fetchUserData(); + }, [sessionUserId]); return <>{children}; } diff --git a/src/components/auth/LoginForm.tsx b/src/components/auth/LoginForm.tsx index 228e7b7..9020c8c 100644 --- a/src/components/auth/LoginForm.tsx +++ b/src/components/auth/LoginForm.tsx @@ -8,8 +8,8 @@ import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { useRouteModal } from "@/hooks/useRouteModal"; import { useLoadingStore } from "@/store/loadingStore"; +import { useAuthStore } from "@/store/authStore"; import { Eye, EyeOff } from "lucide-react"; -import { auth } from "@/lib/firebase"; import { signInWithEmail } from "@/actions/auth"; export default function LoginForm({ close }: { close?: () => void }) { @@ -33,11 +33,21 @@ export default function LoginForm({ close }: { close?: () => void }) { const result = await signInWithEmail(values.email, values.password); - if (!result.success) { - // 액션 에러 리턴 시 캐치문으로 던짐 + if (!result.success || !result.user) { throw new Error(result.error); } + useAuthStore.getState().setIsLoggedIn(true); + useAuthStore.getState().setUser({ + uid: result.user.id, + email: result.user.email || "", + nickname: result.user.nickname, + createdAt: new Date(result.user.created_at), + subscribes: [], + isNoSpoilerMode: result.user.is_no_spoiler_mode, + profileUrl: result.user.profile_url, + }); + setErrorMsg(""); if (close) { close(); diff --git a/src/components/auth/SignUpForm.tsx b/src/components/auth/SignUpForm.tsx index 8467b83..4a7dd4b 100644 --- a/src/components/auth/SignUpForm.tsx +++ b/src/components/auth/SignUpForm.tsx @@ -6,11 +6,8 @@ import { Label } from "@/components/ui/label"; import { Button } from "../ui/button"; import { useForm } from "@/hooks/useForm"; import { useState } from "react"; -import { auth } from "@/lib/firebase"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; -import axios from "axios"; -import { fetchUserData } from "@/apis/userData"; import { useAuthStore } from "@/store/authStore"; import { checkEmailFormat, checkPasswordFormat } from "@/utils/validation"; import { signUpWithEmail } from "@/actions/auth"; diff --git a/src/components/category/CategoryRecommender.tsx b/src/components/category/CategoryRecommender.tsx index a84608a..fafb45d 100644 --- a/src/components/category/CategoryRecommender.tsx +++ b/src/components/category/CategoryRecommender.tsx @@ -1,8 +1,8 @@ "use client"; -import { getCategoriesByCount } from "@/apis/category"; +import { getWorksByCount } from "@/actions/work"; import { Badge } from "../ui/badge"; -import type { Category } from "@/apis/category"; +import type { Category } from "@/types/work"; import { Flame, ChevronDown, ChevronUp } from "lucide-react"; import { useSimpleModal } from "@/hooks/useSimpleModal"; import { SubscribeModalContent } from "../subscribe/SubscribeModalContent"; @@ -26,13 +26,13 @@ export const CategoryRecommender = () => { const { data: usageTop5 = [] } = useQuery({ queryKey: ["categories", "usageTop5"], - queryFn: () => getCategoriesByCount("usage"), + queryFn: () => getWorksByCount("usage"), staleTime: 1000 * 60 * 5, // 5분 캐싱 }); const { data: subscribeTop5 = [] } = useQuery({ queryKey: ["categories", "subscribeTop5"], - queryFn: () => getCategoriesByCount("subscribe"), + queryFn: () => getWorksByCount("subscribe"), staleTime: 1000 * 60 * 5, }); diff --git a/src/components/category/CategorySearch.tsx b/src/components/category/CategorySearch.tsx index 171b19b..1721a5b 100644 --- a/src/components/category/CategorySearch.tsx +++ b/src/components/category/CategorySearch.tsx @@ -3,7 +3,8 @@ import { useEffect, useRef, useState } from "react"; import { Input } from "../ui/input"; import { ListPlus, Plus, Search } from "lucide-react"; import { Button } from "../ui/button"; -import { Category, addCategory, searchCategoryList } from "@/apis/category"; +import { Category } from "@/types/work"; +import { searchWorks, addWork } from "@/actions/work"; import { useSimpleModal } from "@/hooks/useSimpleModal"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; @@ -102,7 +103,7 @@ const AddCategoryModalContent = ({ close }: { close: () => void }) => { const { mutate: mutateCategory } = useMutation({ mutationFn: async () => { const { title, author } = values; - return await addCategory({ title, author }); + return await addWork({ title, author }); }, onMutate: () => startLoading(), onSettled: () => stopLoading(), @@ -186,7 +187,7 @@ export const CategorySearch = ({ const { data: categoryList = [], isFetching } = useQuery({ queryKey: ["categorySearch", triggerSearch], - queryFn: () => searchCategoryList(triggerSearch), + queryFn: () => searchWorks(triggerSearch), enabled: !!triggerSearch, }); diff --git a/src/components/commentaries/FilteredCommentaryList.tsx b/src/components/commentaries/FilteredCommentaryList.tsx index 5270945..78a0d35 100644 --- a/src/components/commentaries/FilteredCommentaryList.tsx +++ b/src/components/commentaries/FilteredCommentaryList.tsx @@ -5,9 +5,10 @@ import { CommentaryFilter, Filter } from "../commentary/CommentaryFilter"; import { CommentaryList } from "../commentary/CommentaryList"; import { useAuthStore } from "@/store/authStore"; import { useQuery } from "@tanstack/react-query"; -import { SubscribeCategory, getSubscribeCategoryList } from "@/apis/subscribe"; -import { Commentary } from "@/apis/commentary"; -import { getCommentaryList } from "@/apis/commentaries"; +import { SubscribeCategory } from "@/types/subscription"; +import { Commentary } from "@/types/commentary"; +import { getUserSubscriptions } from "@/actions/subscription"; +import { getCommentariesByWorkIds } from "@/actions/commentary"; export const FilteredCommentaryList = () => { const [unselectedFilterIds, setUnselectedFilterIds] = useState>(() => new Set()); @@ -15,7 +16,7 @@ export const FilteredCommentaryList = () => { const { data: subscribeList = [] } = useQuery({ queryKey: ["subscribeList", user?.uid], - queryFn: () => getSubscribeCategoryList(user!.uid), + queryFn: () => getUserSubscriptions(user!.uid), enabled: !!user?.uid, }); @@ -50,7 +51,7 @@ export const FilteredCommentaryList = () => { const filterIds = filterList.filter(item => item.isSelected).map(filter => filter.id); const { data: commentaryList = [], isFetching } = useQuery({ queryKey: ["commentaryList", filterIds], // 필터 값이 바뀌면 자동으로 refetch - queryFn: () => getCommentaryList(filterIds, undefined, user?.subscribes), + queryFn: () => getCommentariesByWorkIds(filterIds), enabled: filterIds.length > 0, // 필터 초기화가 끝난 후 실행 }); diff --git a/src/components/commentary/CommentaryDetail.tsx b/src/components/commentary/CommentaryDetail.tsx index 7b82ad3..7ebc68d 100644 --- a/src/components/commentary/CommentaryDetail.tsx +++ b/src/components/commentary/CommentaryDetail.tsx @@ -5,7 +5,8 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { ArrowLeft, EllipsisVertical, XIcon } from "lucide-react"; import { useRouter } from "next/navigation"; -import { Commentary, getCommentary } from "@/apis/commentary"; +import { Commentary } from "@/types/commentary"; +import { getCommentary } from "@/actions/commentary"; import { useQuery } from "@tanstack/react-query"; import { Spinner } from "../ui/spinner"; import { Image } from "../ui/image"; diff --git a/src/components/commentary/CommentaryItem.tsx b/src/components/commentary/CommentaryItem.tsx index 12fbd99..41b229e 100644 --- a/src/components/commentary/CommentaryItem.tsx +++ b/src/components/commentary/CommentaryItem.tsx @@ -5,7 +5,8 @@ import { Badge } from "../ui/badge"; import { EllipsisVertical, User } from "lucide-react"; import { Button } from "../ui/button"; import { useRouteModal } from "@/hooks/useRouteModal"; -import { Commentary, deleteCommentary } from "@/apis/commentary"; +import { Commentary } from "@/types/commentary"; +import { deleteCommentary } from "@/actions/commentary"; import { DropdownMenu, DropdownMenuContent, diff --git a/src/components/commentary/CommentaryList.tsx b/src/components/commentary/CommentaryList.tsx index f0aa263..b270e73 100644 --- a/src/components/commentary/CommentaryList.tsx +++ b/src/components/commentary/CommentaryList.tsx @@ -1,6 +1,6 @@ "use client"; -import { Commentary } from "@/apis/commentary"; +import { Commentary } from "@/types/commentary"; import { CommentaryItem } from "./CommentaryItem"; import { useAuthStore } from "@/store/authStore"; diff --git a/src/components/commentary/WriteCommentaryForm.tsx b/src/components/commentary/WriteCommentaryForm.tsx index 82fc990..09413f3 100644 --- a/src/components/commentary/WriteCommentaryForm.tsx +++ b/src/components/commentary/WriteCommentaryForm.tsx @@ -7,12 +7,12 @@ import { Button } from "../ui/button"; import { useEffect, useState } from "react"; import { CategorySearch } from "../category/CategorySearch"; import { Badge } from "../ui/badge"; -import { Commentary, createCommentary, editCommentary } from "@/apis/commentary"; -import { Category } from "@/apis/category"; +import { Commentary } from "@/types/commentary"; +import { Category } from "@/types/work"; import { useAuthStore } from "@/store/authStore"; import { toast } from "sonner"; import { NumberInput } from "../ui/numberInput"; -import { getCommentary } from "@/apis/commentary"; +import { getCommentary, createCommentary, editCommentary } from "@/actions/commentary"; import { useParams } from "next/navigation"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useLoadingStore } from "@/store/loadingStore"; diff --git a/src/components/guide/Guide.tsx b/src/components/guide/Guide.tsx index 8192fb2..945ec84 100644 --- a/src/components/guide/Guide.tsx +++ b/src/components/guide/Guide.tsx @@ -78,15 +78,7 @@ export const Guide = ({ children }: { children?: React.ReactNode }) => { return (
-
- Commenta logo -
+

COMMENTA

다양한 사람들과 코멘터리를 나눠보세요 diff --git a/src/components/my/MyCommentariesSection.tsx b/src/components/my/MyCommentariesSection.tsx index 7fb8bdc..3d9a426 100644 --- a/src/components/my/MyCommentariesSection.tsx +++ b/src/components/my/MyCommentariesSection.tsx @@ -3,14 +3,14 @@ import { useQuery } from "@tanstack/react-query"; import { CommentaryList } from "../commentary/CommentaryList"; import { useAuthStore } from "@/store/authStore"; -import { getCommentaryList } from "@/apis/commentaries"; +import { getMyCommentaries } from "@/actions/commentary"; export const MyCommentariesSection = () => { const { user, logout } = useAuthStore(); const { data: commentaryList, isFetching } = useQuery({ queryKey: ["commentaryList", user?.uid], - queryFn: () => getCommentaryList(undefined, user?.uid, user?.subscribes), + queryFn: () => getMyCommentaries(user!.uid), enabled: !!user?.uid, }); diff --git a/src/components/my/NoSpoilerModeSection.tsx b/src/components/my/NoSpoilerModeSection.tsx index 97525f4..d46f596 100644 --- a/src/components/my/NoSpoilerModeSection.tsx +++ b/src/components/my/NoSpoilerModeSection.tsx @@ -3,25 +3,26 @@ import { useState } from "react"; import { Switch } from "../ui/switch"; import { useAuthStore } from "@/store/authStore"; -import { updateUserData } from "@/apis/userData"; +import { updateUserProfile } from "@/actions/user"; import { toast } from "sonner"; import { useMutation } from "@tanstack/react-query"; import { useLoadingStore } from "@/store/loadingStore"; export const NoSpoilerModeSection = () => { - const { user } = useAuthStore(); + const { user, setUser } = useAuthStore(); const [isNoSpoilerMode, setIsNoSpoilerMode] = useState(user?.isNoSpoilerMode ?? false); const { startLoading, stopLoading } = useLoadingStore(); const { mutate: updateMode, isPending } = useMutation({ mutationFn: async (mode: boolean) => { if (!user) throw new Error("로그인 정보가 없습니다."); - return updateUserData(user.uid, { isNoSpoilerMode: mode }); + return updateUserProfile(user.uid, { isNoSpoilerMode: mode }); }, onMutate: () => startLoading(), onSettled: () => stopLoading(), - onSuccess: () => { - toast(`스포일러 방지 모드가 ${isNoSpoilerMode ? "켜졌어요" : "꺼졌어요"}`); + onSuccess: (data, mode) => { + if (user) setUser({ ...user, isNoSpoilerMode: data.is_no_spoiler_mode }); + toast(`스포일러 방지 모드가 ${mode ? "켜졌어요" : "꺼졌어요"}`); }, onError: (error: any) => { toast.error(error.message || "모드 변경 실패"); diff --git a/src/components/my/SubscribeSection.tsx b/src/components/my/SubscribeSection.tsx index b58a04e..43701d9 100644 --- a/src/components/my/SubscribeSection.tsx +++ b/src/components/my/SubscribeSection.tsx @@ -2,7 +2,8 @@ import { Flame } from "lucide-react"; import { Badge } from "../ui/badge"; import { useSimpleModal } from "@/hooks/useSimpleModal"; -import { SubscribeCategory, getSubscribeCategoryList } from "@/apis/subscribe"; +import { SubscribeCategory } from "@/types/subscription"; +import { getUserSubscriptions } from "@/actions/subscription"; import { useAuthStore } from "@/store/authStore"; import { useQuery } from "@tanstack/react-query"; import { SubscribeModalContent } from "../subscribe/SubscribeModalContent"; @@ -13,7 +14,7 @@ export const SubscibeSection = () => { const { data: subscribeList = [] } = useQuery({ queryKey: ["subscribeList", user?.uid], - queryFn: () => (user?.uid ? getSubscribeCategoryList(user.uid) : Promise.resolve([])), + queryFn: () => (user?.uid ? getUserSubscriptions(user.uid) : Promise.resolve([])), enabled: !!user?.uid, // 로그인 한 경우에만 실행 }); diff --git a/src/components/my/UserProfileSection.tsx b/src/components/my/UserProfileSection.tsx index 6a1dbd9..ca7104c 100644 --- a/src/components/my/UserProfileSection.tsx +++ b/src/components/my/UserProfileSection.tsx @@ -3,20 +3,22 @@ import { useAuthStore } from "@/store/authStore"; import { Profile } from "../ui/profile"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { updateUserData } from "@/apis/userData"; +import { updateUserProfile } from "@/actions/user"; import { useLoadingStore } from "@/store/loadingStore"; import { toast } from "sonner"; export const UserProfileSection = () => { const queryClient = useQueryClient(); - const { user, logout } = useAuthStore(); + const { user, setUser } = useAuthStore(); const { startLoading, stopLoading } = useLoadingStore(); - const { mutate: updateUserProfile } = useMutation({ - mutationFn: async (url: string | null) => await updateUserData(user!.uid, { profileUrl: url }), + const { mutate: mutateProfile } = useMutation({ + mutationFn: async (url: string | null) => + await updateUserProfile(user!.uid, { profileUrl: url }), onMutate: () => startLoading(), onSettled: () => stopLoading(), - onSuccess: () => { + onSuccess: data => { + if (user) setUser({ ...user, profileUrl: data.profile_url }); toast.success("프로필이 업데이트되었습니다!"); queryClient.invalidateQueries({ queryKey: ["commentaryList"] }); }, @@ -27,7 +29,7 @@ export const UserProfileSection = () => { return (
- +
{user?.nickname} {user?.email} diff --git a/src/components/navigation/Navigation.tsx b/src/components/navigation/Navigation.tsx index df1a7fd..3ae9e43 100644 --- a/src/components/navigation/Navigation.tsx +++ b/src/components/navigation/Navigation.tsx @@ -7,7 +7,7 @@ import { useAuthStore } from "@/store/authStore"; import { CategorySearch } from "../category/CategorySearch"; import { useSimpleModal } from "@/hooks/useSimpleModal"; import { SubscribeModalContent } from "../subscribe/SubscribeModalContent"; -import { Category } from "@/apis/category"; +import { Category } from "@/types/work"; export const Navigation = () => { const pathname = usePathname(); diff --git a/src/components/subscribe/SubscribeModalContent.tsx b/src/components/subscribe/SubscribeModalContent.tsx index 181656b..cfd620c 100644 --- a/src/components/subscribe/SubscribeModalContent.tsx +++ b/src/components/subscribe/SubscribeModalContent.tsx @@ -1,14 +1,14 @@ import { useForm } from "@/hooks/useForm"; import { Button } from "../ui/button"; -import { Category } from "@/apis/category"; +import { Category } from "@/types/work"; import { NumberInput } from "../ui/numberInput"; import { useAuthStore } from "@/store/authStore"; +import { SubscribeCategory } from "@/types/subscription"; import { - SubscribeCategory, addSubscription, - deleteSubscription, updateSubscription, -} from "@/apis/subscribe"; + deleteSubscription, +} from "@/actions/subscription"; import { toast } from "sonner"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useSimpleModal } from "@/hooks/useSimpleModal"; @@ -65,9 +65,8 @@ export const SubscribeModalContent = ({ close(); }, onError: (error: any) => { - const errorMsg = error.response.data.error; let message = subscribeData ? "구독 수정 실패" : "구독 등록 실패"; - if (errorMsg === "Already Subscribed") { + if (error.message === "Already Subscribed") { message = "이미 구독한 작품입니다"; } toast.error(message); diff --git a/src/hooks/useImageUpload.tsx b/src/hooks/useImageUpload.tsx index 811307e..2e25015 100644 --- a/src/hooks/useImageUpload.tsx +++ b/src/hooks/useImageUpload.tsx @@ -4,10 +4,10 @@ import { useState } from "react"; import { getStorage, ref, uploadBytes, getDownloadURL, deleteObject } from "firebase/storage"; import { v4 as uuidv4 } from "uuid"; import { useAuthStore } from "@/store/authStore"; -import { updateUserData } from "@/apis/userData"; +import { updateUserProfile } from "@/actions/user"; export function useImageUpload() { - const { user } = useAuthStore(); + const { user, setUser } = useAuthStore(); const [uploading, setUploading] = useState(false); const [deleting, setDeleting] = useState(false); const [error, setError] = useState(null); @@ -64,11 +64,8 @@ export function useImageUpload() { const deleted = await deleteImageFromStorage(user.profileUrl); if (!deleted) return; - // DB에서 프로필 이미지 제거 - const res = await updateUserData(user.uid, { profileUrl: null }); - if (res) { - console.log("Profile image removed successfully"); - } + await updateUserProfile(user.uid, { profileUrl: null }); + if (user) setUser({ ...user, profileUrl: null }); }; return { diff --git a/src/lib/admin.ts b/src/lib/admin.ts deleted file mode 100644 index 371f92a..0000000 --- a/src/lib/admin.ts +++ /dev/null @@ -1,17 +0,0 @@ -import "server-only"; - -import admin from "firebase-admin"; - -if (!admin.apps.length) { - const serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT_KEY as string); - - // key 안의 \\n을 실제 줄바꿈으로 바꿔줘야 함 - serviceAccount.private_key = serviceAccount.private_key.replace(/\\n/g, "\n"); - - admin.initializeApp({ - credential: admin.credential.cert(serviceAccount), - }); -} - -export const adminDb = admin.firestore(); -export const adminAuth = admin.auth(); diff --git a/src/lib/apiClient.ts b/src/lib/apiClient.ts deleted file mode 100644 index 221a316..0000000 --- a/src/lib/apiClient.ts +++ /dev/null @@ -1,20 +0,0 @@ -import axios from "axios"; -import { getAuth } from "firebase/auth"; - -const apiClient = axios.create({ - baseURL: "/api", -}); - -apiClient.interceptors.request.use(async config => { - const auth = getAuth(); - const user = auth.currentUser; - - if (user) { - const token = await user.getIdToken(); // Firebase ID Token - config.headers.Authorization = `Bearer ${token}`; - } - - return config; -}); - -export default apiClient; diff --git a/src/lib/firebase.ts b/src/lib/firebase.ts deleted file mode 100644 index 3bc49db..0000000 --- a/src/lib/firebase.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { initializeApp } from "firebase/app"; -import { getAuth } from "firebase/auth"; -import { getFirestore } from "firebase/firestore"; -import { getStorage } from "firebase/storage"; - -const firebaseConfig = { - apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY!, - authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN!, - projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!, - storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET!, - messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MSG_ID!, - appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID!, -}; - -const app = initializeApp(firebaseConfig); - -export const auth = getAuth(app); -export const db = getFirestore(app); -export const storage = getStorage(app); diff --git a/src/lib/server/auth.ts b/src/lib/server/auth.ts deleted file mode 100644 index 2ad46dd..0000000 --- a/src/lib/server/auth.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { adminAuth } from "@/lib/admin"; -import { NextRequest, NextResponse } from "next/server"; - -export async function verifyAuth(req: NextRequest) { - const authHeader = req.headers.get("authorization"); - if (!authHeader?.startsWith("Bearer ")) { - return { - uid: null, - error: NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 }), - }; - } - - const token = authHeader.split(" ")[1]; - try { - const decoded = await adminAuth.verifyIdToken(token); - return { uid: decoded.uid, error: null }; - } catch (e) { - console.error("Invalid token:", e); - return { - uid: null, - error: NextResponse.json({ success: false, error: "Invalid token" }, { status: 401 }), - }; - } -} diff --git a/src/lib/server/commentaries.ts b/src/lib/server/commentaries.ts deleted file mode 100644 index 74e58a1..0000000 --- a/src/lib/server/commentaries.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { adminDb } from "@/lib/admin"; -import { Commentary } from "@/apis/commentary"; -import { FieldPath } from "firebase-admin/firestore"; -import { Subscribe } from "@/store/authStore"; - -export async function fetchCommentaryList( - authorId?: string | null, - categoryIds?: string[], - subscribes?: Subscribe[] -): Promise { - let query: FirebaseFirestore.Query = adminDb.collection("commentaries"); - - if (categoryIds?.length) { - query = query.where("categoryId", "in", categoryIds.slice(0, 10)); - } - - if (authorId) { - query = query.where("authorId", "==", authorId); - } - - const snapshot = await query.orderBy("createdAt", "desc").get(); - - let commentaries = snapshot.docs.map(doc => ({ - id: doc.id, - ...(doc.data() as any), - })); - - if (commentaries.length === 0) return []; - - // 구독 episode 정보 기반 필터링 - if (subscribes?.length) { - const subMap = new Map(subscribes.map(s => [s.id, s.episode])); - commentaries = commentaries.filter(c => { - const myEpisode = subMap.get(c.categoryId); - if (myEpisode == null || myEpisode === 0) return true; //구독 정보 없으면 그냥 통과 - return c.episode <= myEpisode; // 스포일러 컷 - }); - } - - // 고유 authorId 추출 - const authorIds = [...new Set(commentaries.map(c => c.authorId))]; - - // Firestore `in` 은 30개 제한 → 나눠서 쿼리 - const userDocs: FirebaseFirestore.QueryDocumentSnapshot[] = []; - for (let i = 0; i < authorIds.length; i += 30) { - const batchIds = authorIds.slice(i, i + 30); - const snap = await adminDb - .collection("users") - .where(FieldPath.documentId(), "in", batchIds) - .get(); - userDocs.push(...snap.docs); - } - - const usersMap = new Map(userDocs.map(doc => [doc.id, doc.data()])); - - return commentaries.map(c => { - const author = usersMap.get(c.authorId); - return { - ...c, - authorNickName: author?.nickname ?? null, - authorProfileUrl: author?.profileUrl ?? null, - createdAt: c.createdAt.toDate().toISOString(), - updatedAt: c.updatedAt.toDate().toISOString(), - } as Commentary; - }); -} diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 0000000..41fb9e0 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,37 @@ +import { createServerClient } from "@supabase/ssr"; +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +export async function middleware(request: NextRequest) { + let response = NextResponse.next({ request }); + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)); + response = NextResponse.next({ request }); + cookiesToSet.forEach(({ name, value, options }) => + response.cookies.set(name, value, options) + ); + }, + }, + } + ); + + // 세션 갱신 (access token 만료 시 refresh token으로 자동 갱신) + await supabase.auth.getUser(); + + return response; +} + +export const config = { + matcher: [ + "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", + ], +}; diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 123c94d..30b04f8 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; -import { signOut } from "firebase/auth"; -import { auth } from "@/lib/firebase"; +import { createClient } from "@/lib/supabase/client"; +import { signOut as serverSignOut } from "@/actions/auth"; import { toast } from "sonner"; export type Subscribe = { id: string; episode: number | null }; @@ -29,7 +29,11 @@ export const useAuthStore = create(set => ({ setIsLoggedIn: loggedIn => set({ isLoggedIn: loggedIn }), logout: async () => { try { - await signOut(auth); + // 서버 측 세션 쿠키 삭제 + await serverSignOut(); + // 브라우저 측 세션 상태 초기화 + const supabase = createClient(); + await supabase.auth.signOut(); set({ user: null, isLoggedIn: false }); toast("로그아웃 성공"); return true; diff --git a/src/types/commentary.ts b/src/types/commentary.ts index 0b11491..07779f9 100644 --- a/src/types/commentary.ts +++ b/src/types/commentary.ts @@ -1,13 +1,11 @@ import { Tables, TablesInsert } from "./database.types"; import { Profile } from "./profile"; -export type Commentary = Tables<"commentaries">; +export type CommentaryRow = Tables<"commentaries">; export type CommentaryInsert = TablesInsert<"commentaries">; export type CommentaryTag = Tables<"commentary_tags">; -// 예시 : 프론트엔드 화면(피드, 상세페이지)에서 가장 많이 쓰일 조인 타입 정의 -export interface CommentaryDetail extends Commentary { - // 릴레이션십에 의해 조인되어 들어오는 데이터 구조 매핑 +export interface CommentaryDetail extends CommentaryRow { profiles: Pick | null; tags: { id: string; @@ -15,3 +13,19 @@ export interface CommentaryDetail extends Commentary { type: string; }[]; } + +// 프론트엔드 피드/UI용 camelCase 타입 (조인 데이터 포함) +export interface Commentary { + id: string; + imgUrlList?: string[]; + content: string; + authorId: string; + authorNickName: string; + authorProfileUrl: string | null; + categoryTitle: string; + categoryId: string; + isSpoiler?: boolean; + episode?: number; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/types/subscription.ts b/src/types/subscription.ts index baac8db..0a66ee8 100644 --- a/src/types/subscription.ts +++ b/src/types/subscription.ts @@ -1,11 +1,14 @@ import { Tables, TablesInsert, TablesUpdate } from "./database.types"; -import { Work } from "./work"; +import { Work, Category } from "./work"; +import { Subscribe } from "@/store/authStore"; export type Subscription = Tables<"subscriptions">; export type SubscriptionInsert = TablesInsert<"subscriptions">; export type SubscriptionUpdate = TablesUpdate<"subscriptions">; -// 확장 예시: 마이 페이지나 '내가 구독한 작품 목록' 탭에서 작품의 썸네일, 제목 등을 함께 보여줄 때 사용합니다. export interface SubscriptionWithWork extends Subscription { works: Pick | null; } + +// 프론트엔드 구독 목록 UI용 타입 (Subscribe + 작품 상세) +export type SubscribeCategory = Subscribe & { detail: Category }; diff --git a/src/types/work.ts b/src/types/work.ts index 9dd9290..517b71c 100644 --- a/src/types/work.ts +++ b/src/types/work.ts @@ -3,8 +3,17 @@ import { Tables, TablesInsert } from "./database.types"; export type Work = Tables<"works">; export type WorkInsert = TablesInsert<"works">; -// 가독성을 위한 확장 예시 export interface WorkWithDetail extends Work { is_subscribed: boolean; tags: string[]; } + +// 프론트엔드 피드/UI용 camelCase 타입 (works 테이블 row 기반) +export interface Category { + id: string; + title: string; + author: string; + createdAt: Date; + usageCount: number; + subscribeCount: number; +}