diff --git a/.gitignore b/.gitignore index 5ef6a52..6ea27fd 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# swagger openapi.json 파일 +/openapi.json \ No newline at end of file diff --git a/package.json b/package.json index 618a6c3..903111e 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "lint:fix": "pnpm lint --fix", "format": "prettier --write .", "format:check": "prettier --check .", - "prepare": "husky" + "prepare": "husky", + "openapi:pull": "curl --fail --show-error --location -s \"$SUPABASE_URL/rest/v1/\" -H \"apikey: $SUPABASE_ANON_KEY\" -H \"Authorization: Bearer $SUPABASE_ANON_KEY\" -H \"Accept: application/openapi+json\" -o openapi.json", + "openapi:fix": "jq 'del(.components.parameters.preferParams.schema.enum?, .parameters.preferParams.enum?) | def walk(f): . as $in | if type==\"object\" then reduce keys[] as $k ({}; . + { ($k): ($in[$k] | walk(f)) }) | f elif type==\"array\" then map(walk(f)) | f else f end; walk(if (type==\"object\" and has(\"enum\") and (.enum|type)==\"array\" and (.enum|length)==0) then del(.enum) else . end)' openapi.json > openapi.json.tmp && mv openapi.json.tmp openapi.json", + "openapi:watch": "npx swagger-ui-watcher ./openapi.json" }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -27,6 +30,7 @@ "@radix-ui/react-slot": "^1.2.3", "@supabase/supabase-js": "^2.75.0", "@tanstack/react-query": "^5.90.2", + "axios": "^1.12.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.544.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 240933b..6675554 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,6 +19,9 @@ importers: "@tanstack/react-query": specifier: ^5.90.2 version: 5.90.2(react@19.1.0) + axios: + specifier: ^1.12.2 + version: 1.12.2 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1405,6 +1408,12 @@ packages: } engines: { node: ">= 0.4" } + asynckit@0.4.0: + resolution: + { + integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==, + } + available-typed-arrays@1.0.7: resolution: { @@ -1419,6 +1428,12 @@ packages: } engines: { node: ">=4" } + axios@1.12.2: + resolution: + { + integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==, + } + axobject-query@4.1.0: resolution: { @@ -1578,6 +1593,13 @@ packages: integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==, } + combined-stream@1.0.8: + resolution: + { + integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==, + } + engines: { node: ">= 0.8" } + commander@14.0.1: resolution: { @@ -1732,6 +1754,13 @@ packages: } engines: { node: ">= 0.4" } + delayed-stream@1.0.0: + resolution: + { + integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==, + } + engines: { node: ">=0.4.0" } + detect-libc@2.1.0: resolution: { @@ -2177,6 +2206,18 @@ packages: integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==, } + follow-redirects@1.15.11: + resolution: + { + integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==, + } + engines: { node: ">=4.0" } + peerDependencies: + debug: "*" + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: { @@ -2184,6 +2225,13 @@ packages: } engines: { node: ">= 0.4" } + form-data@4.0.4: + resolution: + { + integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==, + } + engines: { node: ">= 6" } + function-bind@1.1.2: resolution: { @@ -2989,6 +3037,20 @@ packages: } engines: { node: ">=8.6" } + mime-db@1.52.0: + resolution: + { + integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==, + } + engines: { node: ">= 0.6" } + + mime-types@2.1.35: + resolution: + { + integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==, + } + engines: { node: ">= 0.6" } + mimic-function@5.0.1: resolution: { @@ -3393,6 +3455,12 @@ packages: integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==, } + proxy-from-env@1.1.0: + resolution: + { + integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==, + } + punycode@2.3.1: resolution: { @@ -4930,12 +4998,22 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 axe-core@4.10.3: {} + axios@1.12.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} balanced-match@1.0.2: {} @@ -5026,6 +5104,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + commander@14.0.1: {} compare-func@2.0.0: @@ -5118,6 +5200,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + detect-libc@2.1.0: {} doctrine@2.1.0: @@ -5530,10 +5614,20 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -5965,6 +6059,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-function@5.0.1: {} minimatch@3.1.2: @@ -6160,6 +6260,8 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} queue-microtask@1.2.3: {} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..38ba9b7 --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,48 @@ +import { createClient } from "@supabase/supabase-js"; +import axios, { AxiosHeaders } from "axios"; + +// 1) Supabase Auth 전용 클라이언트(세션/토큰 자동 관리) +export const supabaseAuth = createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { auth: { persistSession: true, autoRefreshToken: true } }, +); + +// 2) Supabase REST 호출용 axios +const baseURL = `${process.env.NEXT_PUBLIC_SUPABASE_URL}/rest/v1`; +const timeout = Number(process.env.NEXT_PUBLIC_API_TIMEOUT || 10000); + +export const api = axios.create({ + baseURL, + timeout, + headers: new AxiosHeaders({ "Content-Type": "application/json" }), +}); + +// 3) 요청 인터셉터: apikey + (로그인 시) Authorization 자동 주입 +api.interceptors.request.use(async (config) => { + const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; + const { + data: { session }, + } = await supabaseAuth.auth.getSession(); + const access = session?.access_token; + + const headers = AxiosHeaders.from(config.headers); + headers.set("apikey", anon); + if (access) headers.set("Authorization", `Bearer ${access}`); + + // 필요한 기본값이 있다면 여기서 추가로: + // headers.set("Accept", "application/json"); + + config.headers = headers; + return config; +}); + +// 4) 응답/에러 표준화 +api.interceptors.response.use( + (res) => res, + (err) => { + const status = err?.response?.status; + const message = err?.response?.data?.message || err?.message || "Unexpected error"; + return Promise.reject({ status, message, raw: err }); + }, +); diff --git a/src/api/dashboard_layouts/dashboard_layout.query.ts b/src/api/dashboard_layouts/dashboard_layout.query.ts new file mode 100644 index 0000000..fc82af3 --- /dev/null +++ b/src/api/dashboard_layouts/dashboard_layout.query.ts @@ -0,0 +1,55 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + GetDashboardLayoutsParams, + PatchDashboardLayoutPayloadType, + PostDashboardLayoutPayloadType, +} from "./dashboard_layout.schema"; +import { DashboardLayoutService } from "./dashboard_layout.service"; + +const service = new DashboardLayoutService(); + +/** 캐시 키 */ +export const dashboardLayoutKeys = { + all: () => ["dashboard_layouts"] as const, + list: (params?: GetDashboardLayoutsParams["params"]) => + [...dashboardLayoutKeys.all(), "list", params ?? {}] as const, +}; + +/** 쿼리 옵션 (params로 select/order 등 전달 가능) */ +export const dashboardLayoutQuery = { + list: (params?: GetDashboardLayoutsParams["params"]) => + queryOptions({ + queryKey: dashboardLayoutKeys.list(params), + queryFn: () => service.getList({ params }), + staleTime: 60_000, + }), +}; + +/** 훅 */ +export function useDashboardLayoutsQuery(params?: GetDashboardLayoutsParams["params"]) { + return useQuery(dashboardLayoutQuery.list(params)); +} + +export function useCreateDashboardLayoutMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PostDashboardLayoutPayloadType) => service.post(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: dashboardLayoutKeys.all() }), + }); +} + +export function usePatchDashboardLayoutMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PatchDashboardLayoutPayloadType) => service.patch(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: dashboardLayoutKeys.all() }), + }); +} + +export function useDeleteDashboardLayoutMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => service.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: dashboardLayoutKeys.all() }), + }); +} diff --git a/src/api/dashboard_layouts/dashboard_layout.schema.ts b/src/api/dashboard_layouts/dashboard_layout.schema.ts new file mode 100644 index 0000000..ae41859 --- /dev/null +++ b/src/api/dashboard_layouts/dashboard_layout.schema.ts @@ -0,0 +1,29 @@ +import type { + DashboardLayout, + DashboardLayoutInsert, + DashboardLayoutUpdate, +} from "../types/domain"; + +/** 서버 응답 타입 */ +export type GetDashboardLayoutsResultType = DashboardLayout[]; +export type PostDashboardLayoutResultType = DashboardLayout; +export type PatchDashboardLayoutResultType = DashboardLayout; +export type DeleteDashboardLayoutResultType = void; + +/** 요청 DTO (database.d.ts 기준으로만 결정) */ +export interface GetDashboardLayoutsParams { + /** 필요 시 외부에서 select/order/filter 등 전달 */ + params?: Record; +} + +export interface PostDashboardLayoutPayloadType { + /** ← Insert 타입 그대로 (필수/옵셔널은 database.d.ts가 결정) */ + body: DashboardLayoutInsert; +} + +export interface PatchDashboardLayoutPayloadType { + /** ← id 타입도 database.d.ts에서 가져옴 */ + id: DashboardLayout["id"]; + /** ← Update 타입 그대로 */ + body: DashboardLayoutUpdate; +} diff --git a/src/api/dashboard_layouts/dashboard_layout.service.ts b/src/api/dashboard_layouts/dashboard_layout.service.ts new file mode 100644 index 0000000..f55914d --- /dev/null +++ b/src/api/dashboard_layouts/dashboard_layout.service.ts @@ -0,0 +1,59 @@ +import { HTTP_METHODS, fetcher, type ApiRequestParams } from "../http"; +import type { + DeleteDashboardLayoutResultType, + GetDashboardLayoutsParams, + GetDashboardLayoutsResultType, + PatchDashboardLayoutPayloadType, + PatchDashboardLayoutResultType, + PostDashboardLayoutPayloadType, + PostDashboardLayoutResultType, +} from "./dashboard_layout.schema"; + +export class DashboardLayoutService { + /** 목록 조회: GET /dashboard_layouts (select/order 등은 params로 외부에서 주입) */ + async getList(opts?: GetDashboardLayoutsParams): Promise { + const p = opts?.params ?? {}; + const r = await fetcher({ + url: "/dashboard_layouts", + method: HTTP_METHODS.GET, + // 기본값은 select=* 만, 나머지는 외부에서 결정 + params: { select: "*", ...p } as ApiRequestParams["params"], + }); + return r.data; + } + + /** 생성: POST /dashboard_layouts (Insert 타입 그대로) */ + async post({ body }: PostDashboardLayoutPayloadType): Promise { + const r = await fetcher({ + url: "/dashboard_layouts", + method: HTTP_METHODS.POST, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 수정: PATCH /dashboard_layouts?id=eq. (Update 타입 그대로) */ + async patch({ + id, + body, + }: PatchDashboardLayoutPayloadType): Promise { + const r = await fetcher({ + url: "/dashboard_layouts", + method: HTTP_METHODS.PATCH, + params: { id: `eq.${id}` }, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 삭제: DELETE /dashboard_layouts?id=eq. */ + async delete(id: string): Promise { + await fetcher({ + url: "/dashboard_layouts", + method: HTTP_METHODS.DELETE, + params: { id: `eq.${id}` }, + }); + } +} diff --git a/src/api/database.d.ts b/src/api/database.d.ts new file mode 100644 index 0000000..45b0c24 --- /dev/null +++ b/src/api/database.d.ts @@ -0,0 +1,450 @@ +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: "13.0.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: { + dashboard_layouts: { + Row: { + created_at: string; + id: string; + is_active: boolean; + layout: Json; + updated_at: string; + user_id: string; + version: number; + }; + Insert: { + created_at?: string; + id?: string; + is_active?: boolean; + layout: Json; + updated_at?: string; + user_id: string; + version?: number; + }; + Update: { + created_at?: string; + id?: string; + is_active?: boolean; + layout?: Json; + updated_at?: string; + user_id?: string; + version?: number; + }; + Relationships: []; + }; + events_daily: { + Row: { + all_day: boolean; + created_at: string; + end_ts: string | null; + id: string; + notes: string | null; + start_ts: string; + title: string; + updated_at: string; + user_id: string; + }; + Insert: { + all_day?: boolean; + created_at?: string; + end_ts?: string | null; + id?: string; + notes?: string | null; + start_ts: string; + title: string; + updated_at?: string; + user_id: string; + }; + Update: { + all_day?: boolean; + created_at?: string; + end_ts?: string | null; + id?: string; + notes?: string | null; + start_ts?: string; + title?: string; + updated_at?: string; + user_id?: string; + }; + Relationships: []; + }; + events_monthly: { + Row: { + created_at: string; + day: number; + end_time: string | null; + id: string; + month: number; + notes: string | null; + start_time: string | null; + title: string; + updated_at: string; + user_id: string; + year: number; + }; + Insert: { + created_at?: string; + day: number; + end_time?: string | null; + id?: string; + month: number; + notes?: string | null; + start_time?: string | null; + title: string; + updated_at?: string; + user_id: string; + year: number; + }; + Update: { + created_at?: string; + day?: number; + end_time?: string | null; + id?: string; + month?: number; + notes?: string | null; + start_time?: string | null; + title?: string; + updated_at?: string; + user_id?: string; + year?: number; + }; + Relationships: []; + }; + events_weekly: { + Row: { + created_at: string; + day_of_week: number; + end_time: string; + id: string; + notes: string | null; + start_time: string; + title: string; + updated_at: string; + user_id: string; + week_start: string; + }; + Insert: { + created_at?: string; + day_of_week: number; + end_time: string; + id?: string; + notes?: string | null; + start_time: string; + title: string; + updated_at?: string; + user_id: string; + week_start: string; + }; + Update: { + created_at?: string; + day_of_week?: number; + end_time?: string; + id?: string; + notes?: string | null; + start_time?: string; + title?: string; + updated_at?: string; + user_id?: string; + week_start?: string; + }; + Relationships: []; + }; + habits: { + Row: { + color: string | null; + created_at: string; + frequency: string; + id: string; + name: string; + target_per_period: number | null; + updated_at: string; + user_id: string; + }; + Insert: { + color?: string | null; + created_at?: string; + frequency: string; + id?: string; + name: string; + target_per_period?: number | null; + updated_at?: string; + user_id: string; + }; + Update: { + color?: string | null; + created_at?: string; + frequency?: string; + id?: string; + name?: string; + target_per_period?: number | null; + updated_at?: string; + user_id?: string; + }; + Relationships: []; + }; + notes: { + Row: { + content: string; + created_at: string; + id: string; + tags: string[]; + title: string | null; + updated_at: string; + user_id: string; + }; + Insert: { + content: string; + created_at?: string; + id?: string; + tags?: string[]; + title?: string | null; + updated_at?: string; + user_id: string; + }; + Update: { + content?: string; + created_at?: string; + id?: string; + tags?: string[]; + title?: string | null; + updated_at?: string; + user_id?: string; + }; + Relationships: []; + }; + profiles: { + Row: { + avatar_url: string | null; + created_at: string; + display_name: string | null; + id: string; + settings: Json; + updated_at: string; + }; + Insert: { + avatar_url?: string | null; + created_at?: string; + display_name?: string | null; + id: string; + settings?: Json; + updated_at?: string; + }; + Update: { + avatar_url?: string | null; + created_at?: string; + display_name?: string | null; + id?: string; + settings?: Json; + updated_at?: string; + }; + Relationships: []; + }; + todos: { + Row: { + completed_at: string | null; + created_at: string; + description: string | null; + due_date: string | null; + id: string; + priority: number | null; + status: string; + title: string; + updated_at: string; + user_id: string; + }; + Insert: { + completed_at?: string | null; + created_at?: string; + description?: string | null; + due_date?: string | null; + id?: string; + priority?: number | null; + status?: string; + title: string; + updated_at?: string; + user_id: string; + }; + Update: { + completed_at?: string | null; + created_at?: string; + description?: string | null; + due_date?: string | null; + id?: string; + priority?: number | null; + status?: string; + title?: string; + updated_at?: string; + user_id?: string; + }; + 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/api/events_daily/events_daily.query.ts b/src/api/events_daily/events_daily.query.ts new file mode 100644 index 0000000..cb2c272 --- /dev/null +++ b/src/api/events_daily/events_daily.query.ts @@ -0,0 +1,55 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + GetEventDailiesParams, + PatchEventDailyPayloadType, + PostEventDailyPayloadType, +} from "./events_daily.schema"; +import { EventDailyService } from "./events_daily.service"; + +const service = new EventDailyService(); + +/** 캐시 키 */ +export const eventDailyKeys = { + all: () => ["events_daily"] as const, + list: (params?: GetEventDailiesParams["params"]) => + [...eventDailyKeys.all(), "list", params ?? {}] as const, +}; + +/** 쿼리 옵션 */ +export const eventDailyQuery = { + list: (params?: GetEventDailiesParams["params"]) => + queryOptions({ + queryKey: eventDailyKeys.list(params), + queryFn: () => service.getList({ params }), + staleTime: 60_000, + }), +}; + +/** 훅 정의 */ +export function useEventDailiesQuery(params?: GetEventDailiesParams["params"]) { + return useQuery(eventDailyQuery.list(params)); +} + +export function useCreateEventDailyMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PostEventDailyPayloadType) => service.post(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: eventDailyKeys.all() }), + }); +} + +export function usePatchEventDailyMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PatchEventDailyPayloadType) => service.patch(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: eventDailyKeys.all() }), + }); +} + +export function useDeleteEventDailyMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => service.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: eventDailyKeys.all() }), + }); +} diff --git a/src/api/events_daily/events_daily.schema.ts b/src/api/events_daily/events_daily.schema.ts new file mode 100644 index 0000000..989d632 --- /dev/null +++ b/src/api/events_daily/events_daily.schema.ts @@ -0,0 +1,23 @@ +import type { EventDaily, EventDailyInsert, EventDailyUpdate } from "../types/domain"; + +/** 서버 응답 타입 */ +export type GetEventDailiesResultType = EventDaily[]; +export type PostEventDailyResultType = EventDaily; +export type PatchEventDailyResultType = EventDaily; +export type DeleteEventDailyResultType = void; + +/** 요청 DTO */ +export interface GetEventDailiesParams { + /** select / order / filter 등을 외부에서 주입 가능 */ + params?: Record; +} + +export interface PostEventDailyPayloadType { + /** database.d.ts의 Insert 타입 그대로 */ + body: EventDailyInsert; +} + +export interface PatchEventDailyPayloadType { + id: EventDaily["id"]; + body: EventDailyUpdate; +} diff --git a/src/api/events_daily/events_daily.service.ts b/src/api/events_daily/events_daily.service.ts new file mode 100644 index 0000000..391fd0a --- /dev/null +++ b/src/api/events_daily/events_daily.service.ts @@ -0,0 +1,55 @@ +import { HTTP_METHODS, fetcher, type ApiRequestParams } from "../http"; +import type { + DeleteEventDailyResultType, + GetEventDailiesParams, + GetEventDailiesResultType, + PatchEventDailyPayloadType, + PatchEventDailyResultType, + PostEventDailyPayloadType, + PostEventDailyResultType, +} from "./events_daily.schema"; + +export class EventDailyService { + /** 📥 목록 조회 */ + async getList(opts?: GetEventDailiesParams): Promise { + const p = opts?.params ?? {}; + const r = await fetcher({ + url: "/events_daily", + method: HTTP_METHODS.GET, + params: { select: "*", ...p } as ApiRequestParams["params"], + }); + return r.data; + } + + /** ➕ 생성 */ + async post({ body }: PostEventDailyPayloadType): Promise { + const r = await fetcher({ + url: "/events_daily", + method: HTTP_METHODS.POST, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** ✏️ 수정 */ + async patch({ id, body }: PatchEventDailyPayloadType): Promise { + const r = await fetcher({ + url: "/events_daily", + method: HTTP_METHODS.PATCH, + params: { id: `eq.${id}` }, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 🗑 삭제 */ + async delete(id: string): Promise { + await fetcher({ + url: "/events_daily", + method: HTTP_METHODS.DELETE, + params: { id: `eq.${id}` }, + }); + } +} diff --git a/src/api/events_monthly/events_monthly.query.ts b/src/api/events_monthly/events_monthly.query.ts new file mode 100644 index 0000000..97357b3 --- /dev/null +++ b/src/api/events_monthly/events_monthly.query.ts @@ -0,0 +1,55 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + GetEventMonthliesParams, + PatchEventMonthlyPayloadType, + PostEventMonthlyPayloadType, +} from "./events_monthly.schema"; +import { EventMonthlyService } from "./events_monthly.service"; + +const service = new EventMonthlyService(); + +/** 캐시 키 */ +export const eventMonthlyKeys = { + all: () => ["events_monthly"] as const, + list: (params?: GetEventMonthliesParams["params"]) => + [...eventMonthlyKeys.all(), "list", params ?? {}] as const, +}; + +/** 쿼리 옵션 */ +export const eventMonthlyQuery = { + list: (params?: GetEventMonthliesParams["params"]) => + queryOptions({ + queryKey: eventMonthlyKeys.list(params), + queryFn: () => service.getList({ params }), + staleTime: 60_000, + }), +}; + +/** 훅 정의 */ +export function useEventMonthliesQuery(params?: GetEventMonthliesParams["params"]) { + return useQuery(eventMonthlyQuery.list(params)); +} + +export function useCreateEventMonthlyMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PostEventMonthlyPayloadType) => service.post(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: eventMonthlyKeys.all() }), + }); +} + +export function usePatchEventMonthlyMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PatchEventMonthlyPayloadType) => service.patch(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: eventMonthlyKeys.all() }), + }); +} + +export function useDeleteEventMonthlyMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => service.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: eventMonthlyKeys.all() }), + }); +} diff --git a/src/api/events_monthly/events_monthly.schema.ts b/src/api/events_monthly/events_monthly.schema.ts new file mode 100644 index 0000000..308db56 --- /dev/null +++ b/src/api/events_monthly/events_monthly.schema.ts @@ -0,0 +1,21 @@ +import type { EventMonthly, EventMonthlyInsert, EventMonthlyUpdate } from "../types/domain"; + +/** 서버 응답 타입 */ +export type GetEventMonthliesResultType = EventMonthly[]; +export type PostEventMonthlyResultType = EventMonthly; +export type PatchEventMonthlyResultType = EventMonthly; +export type DeleteEventMonthlyResultType = void; + +/** 요청 DTO */ +export interface GetEventMonthliesParams { + params?: Record; +} + +export interface PostEventMonthlyPayloadType { + body: EventMonthlyInsert; +} + +export interface PatchEventMonthlyPayloadType { + id: EventMonthly["id"]; + body: EventMonthlyUpdate; +} diff --git a/src/api/events_monthly/events_monthly.service.ts b/src/api/events_monthly/events_monthly.service.ts new file mode 100644 index 0000000..3dd46f0 --- /dev/null +++ b/src/api/events_monthly/events_monthly.service.ts @@ -0,0 +1,55 @@ +import { HTTP_METHODS, fetcher, type ApiRequestParams } from "../http"; +import type { + DeleteEventMonthlyResultType, + GetEventMonthliesParams, + GetEventMonthliesResultType, + PatchEventMonthlyPayloadType, + PatchEventMonthlyResultType, + PostEventMonthlyPayloadType, + PostEventMonthlyResultType, +} from "./events_monthly.schema"; + +export class EventMonthlyService { + /** 📥 목록 조회 */ + async getList(opts?: GetEventMonthliesParams): Promise { + const p = opts?.params ?? {}; + const r = await fetcher({ + url: "/events_monthly", + method: HTTP_METHODS.GET, + params: { select: "*", ...p } as ApiRequestParams["params"], + }); + return r.data; + } + + /** ➕ 생성 */ + async post({ body }: PostEventMonthlyPayloadType): Promise { + const r = await fetcher({ + url: "/events_monthly", + method: HTTP_METHODS.POST, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** ✏️ 수정 */ + async patch({ id, body }: PatchEventMonthlyPayloadType): Promise { + const r = await fetcher({ + url: "/events_monthly", + method: HTTP_METHODS.PATCH, + params: { id: `eq.${id}` }, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 🗑 삭제 */ + async delete(id: string): Promise { + await fetcher({ + url: "/events_monthly", + method: HTTP_METHODS.DELETE, + params: { id: `eq.${id}` }, + }); + } +} diff --git a/src/api/events_weekly/events_weekly.query.ts b/src/api/events_weekly/events_weekly.query.ts new file mode 100644 index 0000000..4d6176d --- /dev/null +++ b/src/api/events_weekly/events_weekly.query.ts @@ -0,0 +1,55 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + GetEventWeekliesParams, + PatchEventWeeklyPayloadType, + PostEventWeeklyPayloadType, +} from "./events_weekly.schema"; +import { EventWeeklyService } from "./events_weekly.service"; + +const service = new EventWeeklyService(); + +/** 캐시 키 */ +export const eventWeeklyKeys = { + all: () => ["events_weekly"] as const, + list: (params?: GetEventWeekliesParams["params"]) => + [...eventWeeklyKeys.all(), "list", params ?? {}] as const, +}; + +/** 쿼리 옵션 */ +export const eventWeeklyQuery = { + list: (params?: GetEventWeekliesParams["params"]) => + queryOptions({ + queryKey: eventWeeklyKeys.list(params), + queryFn: () => service.getList({ params }), + staleTime: 60_000, + }), +}; + +/** 훅 정의 */ +export function useEventWeekliesQuery(params?: GetEventWeekliesParams["params"]) { + return useQuery(eventWeeklyQuery.list(params)); +} + +export function useCreateEventWeeklyMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PostEventWeeklyPayloadType) => service.post(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: eventWeeklyKeys.all() }), + }); +} + +export function usePatchEventWeeklyMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PatchEventWeeklyPayloadType) => service.patch(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: eventWeeklyKeys.all() }), + }); +} + +export function useDeleteEventWeeklyMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => service.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: eventWeeklyKeys.all() }), + }); +} diff --git a/src/api/events_weekly/events_weekly.schema.ts b/src/api/events_weekly/events_weekly.schema.ts new file mode 100644 index 0000000..4bfaf5a --- /dev/null +++ b/src/api/events_weekly/events_weekly.schema.ts @@ -0,0 +1,21 @@ +import type { EventWeekly, EventWeeklyInsert, EventWeeklyUpdate } from "../types/domain"; + +/** 서버 응답 타입 */ +export type GetEventWeekliesResultType = EventWeekly[]; +export type PostEventWeeklyResultType = EventWeekly; +export type PatchEventWeeklyResultType = EventWeekly; +export type DeleteEventWeeklyResultType = void; + +/** 요청 DTO */ +export interface GetEventWeekliesParams { + params?: Record; +} + +export interface PostEventWeeklyPayloadType { + body: EventWeeklyInsert; +} + +export interface PatchEventWeeklyPayloadType { + id: EventWeekly["id"]; + body: EventWeeklyUpdate; +} diff --git a/src/api/events_weekly/events_weekly.service.ts b/src/api/events_weekly/events_weekly.service.ts new file mode 100644 index 0000000..4aed572 --- /dev/null +++ b/src/api/events_weekly/events_weekly.service.ts @@ -0,0 +1,55 @@ +import { HTTP_METHODS, fetcher, type ApiRequestParams } from "../http"; +import type { + DeleteEventWeeklyResultType, + GetEventWeekliesParams, + GetEventWeekliesResultType, + PatchEventWeeklyPayloadType, + PatchEventWeeklyResultType, + PostEventWeeklyPayloadType, + PostEventWeeklyResultType, +} from "./events_weekly.schema"; + +export class EventWeeklyService { + /** 📥 목록 조회 */ + async getList(opts?: GetEventWeekliesParams): Promise { + const p = opts?.params ?? {}; + const r = await fetcher({ + url: "/events_weekly", + method: HTTP_METHODS.GET, + params: { select: "*", ...p } as ApiRequestParams["params"], + }); + return r.data; + } + + /** ➕ 생성 */ + async post({ body }: PostEventWeeklyPayloadType): Promise { + const r = await fetcher({ + url: "/events_weekly", + method: HTTP_METHODS.POST, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** ✏️ 수정 */ + async patch({ id, body }: PatchEventWeeklyPayloadType): Promise { + const r = await fetcher({ + url: "/events_weekly", + method: HTTP_METHODS.PATCH, + params: { id: `eq.${id}` }, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 🗑 삭제 */ + async delete(id: string): Promise { + await fetcher({ + url: "/events_weekly", + method: HTTP_METHODS.DELETE, + params: { id: `eq.${id}` }, + }); + } +} diff --git a/src/api/habits/habits.query.ts b/src/api/habits/habits.query.ts new file mode 100644 index 0000000..1df4a77 --- /dev/null +++ b/src/api/habits/habits.query.ts @@ -0,0 +1,54 @@ +// src/api/habits/habit.query.ts +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { GetHabitsParams, PatchHabitPayloadType, PostHabitPayloadType } from "./habits.schema"; +import { HabitService } from "./habits.service"; + +const service = new HabitService(); + +/** 캐시 키 */ +export const habitKeys = { + all: () => ["habits"] as const, + list: (params?: GetHabitsParams["params"]) => [...habitKeys.all(), "list", params ?? {}] as const, +}; + +/** 쿼리 옵션 */ +export const habitQuery = { + list: (params?: GetHabitsParams["params"]) => + queryOptions({ + queryKey: habitKeys.list(params), + queryFn: () => service.getList({ params }), + staleTime: 60_000, + }), +}; + +/** 목록 조회 훅 */ +export function useHabitsQuery(params?: GetHabitsParams["params"]) { + return useQuery(habitQuery.list(params)); +} + +/** 생성 훅 */ +export function useCreateHabitMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PostHabitPayloadType) => service.post(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: habitKeys.all() }), + }); +} + +/** 수정 훅 */ +export function usePatchHabitMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PatchHabitPayloadType) => service.patch(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: habitKeys.all() }), + }); +} + +/** 삭제 훅 */ +export function useDeleteHabitMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => service.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: habitKeys.all() }), + }); +} diff --git a/src/api/habits/habits.schema.ts b/src/api/habits/habits.schema.ts new file mode 100644 index 0000000..07b18b8 --- /dev/null +++ b/src/api/habits/habits.schema.ts @@ -0,0 +1,21 @@ +import type { Habit, HabitInsert, HabitUpdate } from "../types/domain"; + +/** 서버 응답 타입 */ +export type GetHabitsResultType = Habit[]; +export type PostHabitResultType = Habit; +export type PatchHabitResultType = Habit; +export type DeleteHabitResultType = void; + +/** 요청 DTO */ +export interface GetHabitsParams { + params?: Record; +} + +export interface PostHabitPayloadType { + body: HabitInsert; +} + +export interface PatchHabitPayloadType { + id: Habit["id"]; + body: HabitUpdate; +} diff --git a/src/api/habits/habits.service.ts b/src/api/habits/habits.service.ts new file mode 100644 index 0000000..3df5347 --- /dev/null +++ b/src/api/habits/habits.service.ts @@ -0,0 +1,56 @@ +import { HTTP_METHODS, fetcher, type ApiRequestParams } from "../http"; +import type { + DeleteHabitResultType, + GetHabitsParams, + GetHabitsResultType, + PatchHabitPayloadType, + PatchHabitResultType, + PostHabitPayloadType, + PostHabitResultType, +} from "./habits.schema"; + +export class HabitService { + /** 📥 목록 조회: GET /habits */ + async getList(opts?: GetHabitsParams): Promise { + const p = opts?.params ?? {}; + const r = await fetcher({ + url: "/habits", + method: HTTP_METHODS.GET, + // database.d.ts 기준으로 select만 기본값으로 두고, 정렬/필터는 외부에서 주입 + params: { select: "*", ...p } as ApiRequestParams["params"], + }); + return r.data; + } + + /** ➕ 생성: POST /habits */ + async post({ body }: PostHabitPayloadType): Promise { + const r = await fetcher({ + url: "/habits", + method: HTTP_METHODS.POST, + data: body, // ← HabitInsert 타입 그대로 + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** ✏️ 수정: PATCH /habits?id=eq.{id} */ + async patch({ id, body }: PatchHabitPayloadType): Promise { + const r = await fetcher({ + url: "/habits", + method: HTTP_METHODS.PATCH, + params: { id: `eq.${id}` }, + data: body, // ← HabitUpdate 타입 그대로 (name/frequency/color/target_per_period 등) + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 🗑 삭제: DELETE /habits?id=eq.{id} */ + async delete(id: string): Promise { + await fetcher({ + url: "/habits", + method: HTTP_METHODS.DELETE, + params: { id: `eq.${id}` }, + }); + } +} diff --git a/src/api/http.ts b/src/api/http.ts new file mode 100644 index 0000000..c51f3f4 --- /dev/null +++ b/src/api/http.ts @@ -0,0 +1,30 @@ +// src/api/http.ts +import type { AxiosRequestConfig, AxiosResponse } from "axios"; +import { api } from "./client"; + +export const HTTP_METHODS = { GET: "GET", POST: "POST", PATCH: "PATCH", DELETE: "DELETE" } as const; +type Methods = keyof typeof HTTP_METHODS; + +// ✅ Axios가 허용하는 안전한 쿼리 파라미터 타입(원시값 + 배열) +type Primitive = string | number | boolean | null | undefined; +export type QueryParams = Record; + +// ✅ 안전한 헤더 타입 +export type HeaderMap = Record; + +export type ApiRequestParams = { + url: string; + method?: Methods; // default GET + params?: QueryParams; + data?: TBody; + headers?: HeaderMap; + config?: Omit; +}; + +/** ✅ 기본 fetcher (전역 api 인스턴스 사용) */ +export async function fetcher( + args: ApiRequestParams, +): Promise> { + const { url, method = "GET", params, data, headers, config } = args; + return api.request({ url, method, params, data, headers, ...config }); +} diff --git a/src/api/notes/notes.query.ts b/src/api/notes/notes.query.ts new file mode 100644 index 0000000..669f259 --- /dev/null +++ b/src/api/notes/notes.query.ts @@ -0,0 +1,53 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { GetNotesParams, PatchNotePayloadType, PostNotePayloadType } from "./notes.schema"; +import { NotesService } from "./notes.service"; + +const service = new NotesService(); + +/** 캐시 키 */ +export const notesKeys = { + all: () => ["notes"] as const, + list: (params?: GetNotesParams["params"]) => [...notesKeys.all(), "list", params ?? {}] as const, +}; + +/** 쿼리 옵션 */ +export const notesQuery = { + list: (params?: GetNotesParams["params"]) => + queryOptions({ + queryKey: notesKeys.list(params), + queryFn: () => service.getList({ params }), + staleTime: 60_000, + }), +}; + +/** 목록 훅 */ +export function useNotesQuery(params?: GetNotesParams["params"]) { + return useQuery(notesQuery.list(params)); +} + +/** 생성 훅 */ +export function useCreateNoteMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PostNotePayloadType) => service.post(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: notesKeys.all() }), + }); +} + +/** 수정 훅 */ +export function usePatchNoteMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PatchNotePayloadType) => service.patch(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: notesKeys.all() }), + }); +} + +/** 삭제 훅 */ +export function useDeleteNoteMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => service.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: notesKeys.all() }), + }); +} diff --git a/src/api/notes/notes.schema.ts b/src/api/notes/notes.schema.ts new file mode 100644 index 0000000..e59e6a1 --- /dev/null +++ b/src/api/notes/notes.schema.ts @@ -0,0 +1,24 @@ +import type { Note, NoteInsert, NoteUpdate } from "../types/domain"; + +/** 서버 응답 타입 */ +export type GetNotesResultType = Note[]; +export type PostNoteResultType = Note; +export type PatchNoteResultType = Note; +export type DeleteNoteResultType = void; + +/** 요청 DTO */ +export interface GetNotesParams { + /** select / order / filter 등을 외부에서 주입 */ + params?: Record; +} + +export interface PostNotePayloadType { + /** database.d.ts 의 Insert 타입 그대로 사용 */ + body: NoteInsert; +} + +export interface PatchNotePayloadType { + id: Note["id"]; + /** database.d.ts 의 Update 타입 그대로 사용 */ + body: NoteUpdate; +} diff --git a/src/api/notes/notes.service.ts b/src/api/notes/notes.service.ts new file mode 100644 index 0000000..f4e5d37 --- /dev/null +++ b/src/api/notes/notes.service.ts @@ -0,0 +1,56 @@ +import { HTTP_METHODS, fetcher, type ApiRequestParams } from "../http"; +import type { + DeleteNoteResultType, + GetNotesParams, + GetNotesResultType, + PatchNotePayloadType, + PatchNoteResultType, + PostNotePayloadType, + PostNoteResultType, +} from "./notes.schema"; + +export class NotesService { + /** 📥 목록 조회: GET /notes */ + async getList(opts?: GetNotesParams): Promise { + const p = opts?.params ?? {}; + const r = await fetcher({ + url: "/notes", + method: HTTP_METHODS.GET, + // 기본은 select=* 만, 정렬/필터는 호출부에서 params로 주입 + params: { select: "*", ...p } as ApiRequestParams["params"], + }); + return r.data; + } + + /** ➕ 생성: POST /notes */ + async post({ body }: PostNotePayloadType): Promise { + const r = await fetcher({ + url: "/notes", + method: HTTP_METHODS.POST, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** ✏️ 수정: PATCH /notes?id=eq.{id} */ + async patch({ id, body }: PatchNotePayloadType): Promise { + const r = await fetcher({ + url: "/notes", + method: HTTP_METHODS.PATCH, + params: { id: `eq.${id}` }, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 🗑 삭제: DELETE /notes?id=eq.{id} */ + async delete(id: string): Promise { + await fetcher({ + url: "/notes", + method: HTTP_METHODS.DELETE, + params: { id: `eq.${id}` }, + }); + } +} diff --git a/src/api/profiles/profiles.query.ts b/src/api/profiles/profiles.query.ts new file mode 100644 index 0000000..7a663de --- /dev/null +++ b/src/api/profiles/profiles.query.ts @@ -0,0 +1,58 @@ +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + GetProfilesParams, + PatchProfilePayloadType, + PostProfilePayloadType, +} from "./profiles.schema"; +import { ProfilesService } from "./profiles.service"; + +const service = new ProfilesService(); + +/** 캐시 키 */ +export const profilesKeys = { + all: () => ["profiles"] as const, + list: (params?: GetProfilesParams["params"]) => + [...profilesKeys.all(), "list", params ?? {}] as const, +}; + +/** 쿼리 옵션 */ +export const profilesQuery = { + list: (params?: GetProfilesParams["params"]) => + queryOptions({ + queryKey: profilesKeys.list(params), + queryFn: () => service.getList({ params }), + staleTime: 60_000, + }), +}; + +/** 목록 훅 */ +export function useProfilesQuery(params?: GetProfilesParams["params"]) { + return useQuery(profilesQuery.list(params)); +} + +/** 생성 훅 */ +export function useCreateProfileMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PostProfilePayloadType) => service.post(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: profilesKeys.all() }), + }); +} + +/** 수정 훅 */ +export function usePatchProfileMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PatchProfilePayloadType) => service.patch(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: profilesKeys.all() }), + }); +} + +/** 삭제 훅 */ +export function useDeleteProfileMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => service.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: profilesKeys.all() }), + }); +} diff --git a/src/api/profiles/profiles.schema.ts b/src/api/profiles/profiles.schema.ts new file mode 100644 index 0000000..4546093 --- /dev/null +++ b/src/api/profiles/profiles.schema.ts @@ -0,0 +1,24 @@ +import type { Profile, ProfileInsert, ProfileUpdate } from "../types/domain"; + +/** 서버 응답 타입 */ +export type GetProfilesResultType = Profile[]; +export type PostProfileResultType = Profile; +export type PatchProfileResultType = Profile; +export type DeleteProfileResultType = void; + +/** 요청 DTO */ +export interface GetProfilesParams { + /** select / order / filter 등을 외부에서 주입 */ + params?: Record; +} + +export interface PostProfilePayloadType { + /** database.d.ts 의 Insert 타입 그대로 사용 */ + body: ProfileInsert; +} + +export interface PatchProfilePayloadType { + id: Profile["id"]; + /** database.d.ts 의 Update 타입 그대로 사용 */ + body: ProfileUpdate; +} diff --git a/src/api/profiles/profiles.service.ts b/src/api/profiles/profiles.service.ts new file mode 100644 index 0000000..23dffb9 --- /dev/null +++ b/src/api/profiles/profiles.service.ts @@ -0,0 +1,56 @@ +import { HTTP_METHODS, fetcher, type ApiRequestParams } from "../http"; +import type { + DeleteProfileResultType, + GetProfilesParams, + GetProfilesResultType, + PatchProfilePayloadType, + PatchProfileResultType, + PostProfilePayloadType, + PostProfileResultType, +} from "./profiles.schema"; + +export class ProfilesService { + /** 📥 목록 조회: GET /profiles */ + async getList(opts?: GetProfilesParams): Promise { + const p = opts?.params ?? {}; + const r = await fetcher({ + url: "/profiles", + method: HTTP_METHODS.GET, + // 기본은 select=* 만. 정렬/필터는 호출부에서 params로 주입 + params: { select: "*", ...p } as ApiRequestParams["params"], + }); + return r.data; + } + + /** ➕ 생성: POST /profiles (RLS/트리거 구성에 따라 제한될 수 있음) */ + async post({ body }: PostProfilePayloadType): Promise { + const r = await fetcher({ + url: "/profiles", + method: HTTP_METHODS.POST, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** ✏️ 수정: PATCH /profiles?id=eq.{id} */ + async patch({ id, body }: PatchProfilePayloadType): Promise { + const r = await fetcher({ + url: "/profiles", + method: HTTP_METHODS.PATCH, + params: { id: `eq.${id}` }, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 🗑 삭제: DELETE /profiles?id=eq.{id} (프로덕션에선 보통 비활성화) */ + async delete(id: string): Promise { + await fetcher({ + url: "/profiles", + method: HTTP_METHODS.DELETE, + params: { id: `eq.${id}` }, + }); + } +} diff --git a/src/api/todos/todos.query.ts b/src/api/todos/todos.query.ts new file mode 100644 index 0000000..f812c2e --- /dev/null +++ b/src/api/todos/todos.query.ts @@ -0,0 +1,57 @@ +// src/api/todos/todo.query.ts +import { queryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + PatchTodoPayloadType, + PostTodoPayloadType, + ToggleTodoPayloadType, +} from "./todos.schema"; +import { TodoService } from "./todos.service"; + +const todoService = new TodoService(); + +export const todosQueryKeys = { + all: () => ["todos"] as const, + list: () => [...todosQueryKeys.all(), "list"] as const, +}; + +export const todosQuery = { + list: () => + queryOptions({ + queryKey: todosQueryKeys.list(), + queryFn: () => todoService.getList(), // 서비스가 data만 반환 + staleTime: 60_000, + }), +}; + +export function useTodosQuery() { + return useQuery(todosQuery.list()); +} + +export function useCreateTodoMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PostTodoPayloadType) => todoService.post(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: todosQueryKeys.all() }), + }); +} +export function usePatchTodoMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: PatchTodoPayloadType) => todoService.patch(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: todosQueryKeys.all() }), + }); +} +export function useToggleTodoMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: ToggleTodoPayloadType) => todoService.toggle(payload), + onSuccess: () => qc.invalidateQueries({ queryKey: todosQueryKeys.all() }), + }); +} +export function useDeleteTodoMutation() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => todoService.delete(id), + onSuccess: () => qc.invalidateQueries({ queryKey: todosQueryKeys.all() }), + }); +} diff --git a/src/api/todos/todos.schema.ts b/src/api/todos/todos.schema.ts new file mode 100644 index 0000000..8ecbcd5 --- /dev/null +++ b/src/api/todos/todos.schema.ts @@ -0,0 +1,19 @@ +import type { Todo, TodoInsert, TodoUpdate } from "../types/domain"; + +// 응답 타입 +export type GetTodosResultType = Todo[]; +export type PostTodoResultType = Todo; +export type PatchTodoResultType = Todo; +export type DeleteTodoResultType = void; + +// 요청 DTO +export interface PostTodoPayloadType { + body: Partial & Pick; +} +export interface PatchTodoPayloadType { + id: string; + body: TodoUpdate; +} +export interface ToggleTodoPayloadType { + id: string; +} diff --git a/src/api/todos/todos.service.ts b/src/api/todos/todos.service.ts new file mode 100644 index 0000000..29b6292 --- /dev/null +++ b/src/api/todos/todos.service.ts @@ -0,0 +1,82 @@ +import { HTTP_METHODS, fetcher, type ApiRequestParams } from "../http"; +import type { Todo, TodoUpdate } from "../types/domain"; +import type { + DeleteTodoResultType, + GetTodosResultType, + PatchTodoPayloadType, + PatchTodoResultType, + PostTodoPayloadType, + PostTodoResultType, + ToggleTodoPayloadType, +} from "./todos.schema"; + +export class TodoService { + /** 목록 조회: GET /todos?select=*&order=created_at.desc */ + async getList(params?: ApiRequestParams["params"]): Promise { + const r = await fetcher({ + url: "/todos", + method: HTTP_METHODS.GET, + params: { select: "*", order: "created_at.desc", ...params }, + }); + return r.data; + } + + /** 추가: POST /todos (Prefer: return=representation) */ + async post({ body }: PostTodoPayloadType): Promise { + const r = await fetcher({ + url: "/todos", + method: HTTP_METHODS.POST, + data: body, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 수정: PATCH /todos?id=eq. (Prefer) */ + async patch({ id, body }: PatchTodoPayloadType): Promise { + const r = await fetcher({ + url: "/todos", + method: HTTP_METHODS.PATCH, + data: body, + params: { id: `eq.${id}` }, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 상태 토글: status done↔todo, completed_at now/null */ + async toggle({ id }: ToggleTodoPayloadType): Promise { + // 1) 현재 행 조회 + const curRes = await fetcher({ + url: "/todos", + params: { select: "*", id: `eq.${id}`, limit: 1 }, + }); + const current = curRes.data[0]; + + // 2) 다음 상태 계산 + const nextDone = current?.status !== "done"; + const patch: TodoUpdate = { + status: nextDone ? "done" : "todo", + completed_at: nextDone ? new Date().toISOString() : null, + }; + + // 3) 업데이트 후 대표 행 반환 + const r = await fetcher({ + url: "/todos", + method: HTTP_METHODS.PATCH, + data: patch, + params: { id: `eq.${id}` }, + headers: { Prefer: "return=representation" }, + }); + return r.data[0]; + } + + /** 삭제: DELETE /todos?id=eq. */ + async delete(id: string): Promise { + await fetcher({ + url: "/todos", + method: HTTP_METHODS.DELETE, + params: { id: `eq.${id}` }, + }); + } +} diff --git a/src/api/types/domain.ts b/src/api/types/domain.ts new file mode 100644 index 0000000..abe490c --- /dev/null +++ b/src/api/types/domain.ts @@ -0,0 +1,39 @@ +import type { Tables, TablesInsert, TablesUpdate } from "../database"; + +// ── todos ───────────────────────────────────────────── +export type Todo = Tables<"todos">; +export type TodoInsert = TablesInsert<"todos">; +export type TodoUpdate = TablesUpdate<"todos">; + +// ── dashboard_layouts ──────────────────────────────── +export type DashboardLayout = Tables<"dashboard_layouts">; +export type DashboardLayoutInsert = TablesInsert<"dashboard_layouts">; +export type DashboardLayoutUpdate = TablesUpdate<"dashboard_layouts">; + +// ── notes ──────────────────────────────────────────── +export type Note = Tables<"notes">; +export type NoteInsert = TablesInsert<"notes">; +export type NoteUpdate = TablesUpdate<"notes">; + +// ── habits ─────────────────────────────────────────── +export type Habit = Tables<"habits">; +export type HabitInsert = TablesInsert<"habits">; +export type HabitUpdate = TablesUpdate<"habits">; + +// ── profiles ───────────────────────────────────────── +export type Profile = Tables<"profiles">; +export type ProfileInsert = TablesInsert<"profiles">; +export type ProfileUpdate = TablesUpdate<"profiles">; + +// ── events (daily / weekly / monthly) ─────────────── +export type EventDaily = Tables<"events_daily">; +export type EventDailyInsert = TablesInsert<"events_daily">; +export type EventDailyUpdate = TablesUpdate<"events_daily">; + +export type EventWeekly = Tables<"events_weekly">; +export type EventWeeklyInsert = TablesInsert<"events_weekly">; +export type EventWeeklyUpdate = TablesUpdate<"events_weekly">; + +export type EventMonthly = Tables<"events_monthly">; +export type EventMonthlyInsert = TablesInsert<"events_monthly">; +export type EventMonthlyUpdate = TablesUpdate<"events_monthly">;