albabot-mcp 1.2.0 β†’ 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/Todo.md CHANGED
@@ -1,22 +1,25 @@
1
- # AlbaBot MCP β€” Todo
2
-
3
- ## πŸ”΄ λ―Έκ΅¬ν˜„ (High Priority)
4
- - [x] **Twitter/X API 연동** β€” ν΄λ ˆμž„ 성곡 μ‹œ νŠΈμœ— μžλ™ κ²Œμ‹œ
5
- - [ ] Twitter Developer Portalμ—μ„œ μ•± 생성 및 API ν‚€ λ°œκΈ‰
6
- - [ ] Vercel ν™˜κ²½λ³€μˆ˜μ— Twitter API ν‚€ 등둝 (`TWITTER_API_KEY`, `TWITTER_API_SECRET`, `TWITTER_ACCESS_TOKEN`, `TWITTER_ACCESS_TOKEN_SECRET`)
7
- - [x] Twitter API v2 κ²Œμ‹œ μ½”λ“œ κ΅¬ν˜„ (`api/lib/twitter.ts` + `api/claim.ts`에 연동)
8
- - [x] κ²Œμ‹œ λ‚΄μš© ν…œν”Œλ¦Ώ μž‘μ„± (예: "πŸ€– {agent_name} μ—μ΄μ „νŠΈκ°€ @{owner} 에 μ˜ν•΄ ν™œμ„±ν™”λ˜μ—ˆμŠ΅λ‹ˆλ‹€! #AlbaBot")
9
-
10
- ## 🟑 κ°œμ„  사항 (Medium Priority)
11
- - [ ] Twitter OAuth μ‹€μ œ 인증 κ΅¬ν˜„ (ν˜„μž¬λŠ” ν•Έλ“€ 직접 μž…λ ₯ 방식)
12
- - [x] 디버그 μ—”λ“œν¬μΈνŠΈ 정리/제거 (`api/v1/debug/`)
13
- - [x] μ—λŸ¬ μ‘λ‹΅μ—μ„œ `debug` ν•„λ“œ ν”„λ‘œλ•μ…˜ ν™˜κ²½μ—μ„œ 제거
14
-
15
- ## 🟒 μ™„λ£Œ
16
- - [x] μ—μ΄μ „νŠΈ 등둝 (`register_agent`)
17
- - [x] ν΄λ ˆμž„ μƒνƒœ 확인 (`check_claim_status`)
18
- - [x] ν”„λ‘œν•„ 쑰회/μˆ˜μ • (`get_agent_profile`, `update_agent_profile`)
19
- - [x] μž‘μ—… 검색 (`search_jobs`)
20
- - [x] μž‘μ—… 지원/λ”œ (`apply_job`)
21
- - [x] ν΄λ ˆμž„ URL 및 μ†Œμœ κΆŒ 확인 νŽ˜μ΄μ§€ (`/claim/:token`)
22
- - [x] Swagger λ¬Έμ„œ μ—…λ°μ΄νŠΈ
1
+ # AlbaBot MCP β€” Todo
2
+
3
+ ## πŸ”΄ λ―Έκ΅¬ν˜„ (High Priority)
4
+ - [x] Twitter/X API 연동 β€” ν΄λ ˆμž„ 성곡 μ‹œ νŠΈμœ— μžλ™ κ²Œμ‹œ
5
+ - [x] **봇 μž…λ ₯ μŠ€ν‚€λ§ˆ (`input_schema`) 지원** (PRISM 연동 ν•„μˆ˜)
6
+ - [x] `agents` ν…Œμ΄λΈ”μ— `input_schema` (JSONB) 컬럼 μΆ”κ°€
7
+ - [x] `register_agent`, `update_agent_profile` API에 `input_schema` νŒŒλΌλ―Έν„° μΆ”κ°€
8
+ - [x] 의뒰 ν”„λ‘œμ„ΈμŠ€μ—μ„œ μŠ€ν‚€λ§ˆ 기반 μž…λ ₯ 폼 λ Œλ”λ§ 및 `job.metadata` μ €μž₯ 둜직 섀계
9
+ - [x] **μž‘μ—… μ§„ν–‰ μƒνƒœ 및 κ²°κ³Ό 전달 API κ°œμ„ ** (PRISM 연동 ν•„μˆ˜)
10
+ - [x] `PUT /jobs/{id}/status`: μ—μ΄μ „νŠΈκ°€ 직접 μƒνƒœ λ³€κ²½ κ°€λŠ₯ν•˜κ²Œ κ΅¬ν˜„
11
+ - [x] `POST /jobs/{id}/deliver`: κ²°κ³Όλ¬Ό(HTML/PDF URL λ“±) 전달 μ „μš© API κ΅¬ν˜„ (apply와 뢄리)
12
+
13
+ ## 🟑 κ°œμ„  사항 (Medium Priority)
14
+ - [ ] Twitter OAuth μ‹€μ œ 인증 κ΅¬ν˜„ (ν˜„μž¬λŠ” ν•Έλ“€ 직접 μž…λ ₯ 방식)
15
+ - [x] 디버그 μ—”λ“œν¬μΈνŠΈ 정리/제거 (`api/v1/debug/`)
16
+ - [x] μ—λŸ¬ μ‘λ‹΅μ—μ„œ `debug` ν•„λ“œ ν”„λ‘œλ•μ…˜ ν™˜κ²½μ—μ„œ 제거
17
+ - [x] **Webhook μ•Œλ¦Ό 방식 지원** (μ™„λ£Œ)
18
+ - [x] 봇 섀정에 `webhook_url` ν•„λ“œ μΆ”κ°€
19
+ - [x] μƒˆ μž‘μ—… λ°œμƒ μ‹œ λ“±λ‘λœ URL둜 POST μš”μ²­ 전솑
20
+ - [x] **κ²°κ³Όλ¬Ό 파일 직접 μ—…λ‘œλ“œ API** (μ™„λ£Œ)
21
+ - [x] POST `/api/v1/jobs/upload` API κ΅¬ν˜„
22
+ - [x] `upload_job_result` MCP 도ꡬ μΆ”κ°€
23
+ - [x] μ €μž₯μ†Œ 버킷 생성 (`results`, `uploads`)
24
+ - [x] λ°μ΄ν„°λ² μ΄μŠ€ μŠ€ν‚€λ§ˆ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ μ™„λ£Œ
25
+ - [x] Swagger 및 MCP Tool μ—…λ°μ΄νŠΈ μ™„λ£Œ
@@ -89,6 +89,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
89
89
  status: agent.status,
90
90
  owner_x_handle: agent.owner_x_handle || null,
91
91
  manager_phone: agent.manager_phone || null,
92
+ input_schema: agent.input_schema || null,
93
+ webhook_url: agent.webhook_url || null,
94
+ youtube_url: agent.youtube_url || null,
92
95
  ethics_agreed_at: agent.ethics_agreed_at || null,
93
96
  profile_url: `https://albabot-mcp.vercel.app/agent/${agent.name}`,
94
97
  created_at: agent.created_at,
@@ -109,13 +112,16 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
109
112
  if (req.body?.monthly_price !== undefined) updates.monthly_price = req.body.monthly_price ?? null;
110
113
  if (req.body?.monthly_task_limit !== undefined) updates.monthly_task_limit = req.body.monthly_task_limit ?? null;
111
114
  if (req.body?.manager_phone) updates.manager_phone = req.body.manager_phone;
115
+ if (req.body?.input_schema !== undefined) updates.input_schema = req.body.input_schema || null;
116
+ if (req.body?.webhook_url !== undefined) updates.webhook_url = req.body.webhook_url || null;
117
+ if (req.body?.youtube_url !== undefined) updates.youtube_url = req.body.youtube_url || null;
112
118
  if (req.body?.ai_collab !== undefined) updates.ai_collab = req.body.ai_collab === true;
113
119
 
114
120
  if (Object.keys(updates).length === 0) {
115
121
  return res.status(400).json({
116
122
  success: false,
117
123
  error: "μˆ˜μ •ν•  ν•­λͺ©μ΄ μ—†μŠ΅λ‹ˆλ‹€.",
118
- hint: "name, description, skills, image_url, solana_wallet, price_per_task, monthly_price, monthly_task_limit, manager_phone 쀑 ν•˜λ‚˜ 이상 μ „λ‹¬ν•˜μ„Έμš”.",
124
+ hint: "name, description, skills, image_url, solana_wallet, price_per_task, monthly_price, monthly_task_limit, manager_phone, input_schema 쀑 ν•˜λ‚˜ 이상 μ „λ‹¬ν•˜μ„Έμš”.",
119
125
  });
120
126
  }
121
127
 
@@ -154,6 +160,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
154
160
  status: updated.status,
155
161
  owner_x_handle: updated.owner_x_handle || null,
156
162
  manager_phone: updated.manager_phone || null,
163
+ input_schema: updated.input_schema || null,
164
+ webhook_url: updated.webhook_url || null,
165
+ youtube_url: updated.youtube_url || null,
157
166
  ethics_agreed_at: updated.ethics_agreed_at || null,
158
167
  profile_url: `https://albabot-mcp.vercel.app/agent/${updated.name}`,
159
168
  created_at: updated.created_at,
@@ -0,0 +1,149 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ import type { VercelRequest, VercelResponse } from "@vercel/node";
3
+
4
+ function getSupabase() {
5
+ const url = process.env.SUPABASE_URL;
6
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
7
+ if (!url || !key) throw new Error("Supabase ν™˜κ²½λ³€μˆ˜κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.");
8
+ return createClient(url, key);
9
+ }
10
+
11
+ function extractApiKey(req: VercelRequest): string | null {
12
+ const auth = req.headers.authorization;
13
+ if (!auth?.startsWith("Bearer ")) return null;
14
+ return auth.slice(7).trim();
15
+ }
16
+
17
+ export default async function handler(req: VercelRequest, res: VercelResponse) {
18
+ res.setHeader("Access-Control-Allow-Origin", "*");
19
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
20
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
21
+ if (req.method === "OPTIONS") return res.status(200).end();
22
+
23
+ try {
24
+ const apiKey = extractApiKey(req);
25
+ if (!apiKey) {
26
+ return res.status(401).json({ success: false, error: "Authorization 헀더가 ν•„μš”ν•©λ‹ˆλ‹€." });
27
+ }
28
+
29
+ const supabase = getSupabase();
30
+
31
+ // 1. μ—μ΄μ „νŠΈ 인증
32
+ const { data: agent, error: authError } = await supabase
33
+ .from("agents")
34
+ .select("id")
35
+ .eq("api_key", apiKey)
36
+ .single();
37
+
38
+ if (authError || !agent) {
39
+ return res.status(401).json({ success: false, error: "μœ νš¨ν•˜μ§€ μ•Šμ€ API ν‚€μž…λ‹ˆλ‹€." });
40
+ }
41
+
42
+ // --- GET: 결과물 리슀트 쑰회 ---
43
+ if (req.method === "GET") {
44
+ const { data, error } = await supabase
45
+ .from("agent_outcomes")
46
+ .select("*")
47
+ .eq("agent_id", agent.id)
48
+ .order("created_at", { ascending: false });
49
+
50
+ if (error) return res.status(500).json({ success: false, error: error.message });
51
+ return res.status(200).json({ success: true, outcomes: data });
52
+ }
53
+
54
+ // --- POST: μƒˆ κ²°κ³Όλ¬Ό 이미지 μ—…λ‘œλ“œ ---
55
+ if (req.method === "POST") {
56
+ const { fileData, fileName, contentType, description } = req.body || {};
57
+
58
+ if (!fileData || !fileName) {
59
+ return res.status(400).json({ success: false, error: "fileData(base64)와 fileName은 ν•„μˆ˜μž…λ‹ˆλ‹€." });
60
+ }
61
+
62
+ // Base64 λ””μ½”λ”©
63
+ const base64Content = fileData.includes("base64,") ? fileData.split("base64,")[1] : fileData;
64
+ const buffer = Buffer.from(base64Content, "base64");
65
+
66
+ // Storage μ—…λ‘œλ“œ (버킷: outcomes)
67
+ const storagePath = `${agent.id}/${Date.now()}_${fileName}`;
68
+ const { data: uploadData, error: uploadError } = await supabase.storage
69
+ .from("outcomes")
70
+ .upload(storagePath, buffer, {
71
+ contentType: contentType || "image/png",
72
+ upsert: true
73
+ });
74
+
75
+ if (uploadError) {
76
+ return res.status(500).json({
77
+ success: false,
78
+ error: `Storage μ—…λ‘œλ“œ μ‹€νŒ¨: ${uploadError.message}`,
79
+ hint: "Supabase에 'outcomes' 퍼블릭 버킷이 μƒμ„±λ˜μ–΄ μžˆλŠ”μ§€ ν™•μΈν•˜μ„Έμš”."
80
+ });
81
+ }
82
+
83
+ // Public URL
84
+ const { data: { publicUrl } } = supabase.storage.from("outcomes").getPublicUrl(storagePath);
85
+
86
+ // DB 기둝
87
+ const { data: dbData, error: dbError } = await supabase
88
+ .from("agent_outcomes")
89
+ .insert({
90
+ agent_id: agent.id,
91
+ image_url: publicUrl,
92
+ storage_path: storagePath,
93
+ description: description || null
94
+ })
95
+ .select()
96
+ .single();
97
+
98
+ if (dbError) {
99
+ // DB 기둝 μ‹€νŒ¨ μ‹œ Storage 파일 μ‚­μ œ μ‹œλ„
100
+ await supabase.storage.from("outcomes").remove([storagePath]);
101
+ return res.status(500).json({ success: false, error: `DB μ €μž₯ μ‹€νŒ¨: ${dbError.message}` });
102
+ }
103
+
104
+ return res.status(201).json({ success: true, outcome: dbData });
105
+ }
106
+
107
+ // --- DELETE: κ²°κ³Όλ¬Ό μ‚­μ œ ---
108
+ if (req.method === "DELETE") {
109
+ const { id } = req.body || {};
110
+ if (!id) return res.status(400).json({ success: false, error: "μ‚­μ œν•  outcome idκ°€ ν•„μš”ν•©λ‹ˆλ‹€." });
111
+
112
+ // ν•΄λ‹Ή ν•­λͺ© 쑰회 및 μ†Œμœ κΆŒ 확인
113
+ const { data: outcome, error: fetchError } = await supabase
114
+ .from("agent_outcomes")
115
+ .select("*")
116
+ .eq("id", id)
117
+ .eq("agent_id", agent.id)
118
+ .single();
119
+
120
+ if (fetchError || !outcome) {
121
+ return res.status(404).json({ success: false, error: "ν•΄λ‹Ή 결과물을 찾을 수 μ—†κ±°λ‚˜ κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€." });
122
+ }
123
+
124
+ // Storageμ—μ„œ μ‚­μ œ
125
+ const { error: storageDeleteError } = await supabase.storage
126
+ .from("outcomes")
127
+ .remove([outcome.storage_path]);
128
+
129
+ // DBμ—μ„œ μ‚­μ œ
130
+ const { error: dbDeleteError } = await supabase
131
+ .from("agent_outcomes")
132
+ .delete()
133
+ .eq("id", id);
134
+
135
+ if (dbDeleteError) return res.status(500).json({ success: false, error: dbDeleteError.message });
136
+
137
+ return res.status(200).json({ success: true, message: "κ²°κ³Όλ¬Ό 이미지가 μ‚­μ œλ˜μ—ˆμŠ΅λ‹ˆλ‹€." });
138
+ }
139
+
140
+ return res.status(405).json({ success: false, error: "Method not allowed" });
141
+
142
+ } catch (err) {
143
+ console.error("outcomes handler error:", err);
144
+ return res.status(500).json({
145
+ success: false,
146
+ error: err instanceof Error ? err.message : "Internal server error",
147
+ });
148
+ }
149
+ }
@@ -43,7 +43,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
43
43
  if (req.method === "POST") {
44
44
  const supabase = getSupabase();
45
45
 
46
- const { name, description, skills, image_url, solana_wallet, manager_phone, ethics_agreed, price_per_task, monthly_price, monthly_task_limit } = req.body || {};
46
+ const { name, description, skills, image_url, solana_wallet, manager_phone, input_schema, webhook_url, youtube_url, ethics_agreed, price_per_task, monthly_price, monthly_task_limit } = req.body || {};
47
47
  if (!name || !description || !image_url || !solana_wallet || !manager_phone) {
48
48
  return res.status(400).json({
49
49
  success: false,
@@ -82,6 +82,9 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
82
82
  api_key,
83
83
  claim_token,
84
84
  verification_code,
85
+ input_schema: input_schema || null,
86
+ webhook_url: webhook_url || null,
87
+ youtube_url: youtube_url || null,
85
88
  })
86
89
  .select()
87
90
  .single();
@@ -51,7 +51,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
51
51
  });
52
52
  }
53
53
 
54
- const { title, description, jobType, category, price, priceType, location, requiredSkills } = req.body || {};
54
+ const { title, description, jobType, category, price, priceType, location, requiredSkills, metadata } = req.body || {};
55
55
 
56
56
  if (!title || !description || price === undefined) {
57
57
  return res.status(400).json({
@@ -71,6 +71,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
71
71
  price_type: priceType || "per_task",
72
72
  location: location || "remote",
73
73
  required_skills: requiredSkills || [],
74
+ metadata: metadata || null,
74
75
  client_id: agent.id,
75
76
  })
76
77
  .select()
@@ -80,6 +81,43 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
80
81
  return res.status(500).json({ success: false, error: insertError.message });
81
82
  }
82
83
 
84
+ // --- Webhook μ•Œλ¦Ό 전솑 (λ°°κ²½) ---
85
+ // 봇 νƒ€μž…μ΄κ³  webhook_url이 μžˆλŠ” μ—μ΄μ „νŠΈ 쑰회
86
+ const { data: bots } = await supabase
87
+ .from("agents")
88
+ .select("name, webhook_url")
89
+ .eq("agent_type", "bot")
90
+ .not("webhook_url", "is", null);
91
+
92
+ if (bots && bots.length > 0) {
93
+ const payload = {
94
+ event: "job.created",
95
+ job: {
96
+ id: job.id,
97
+ title: job.title,
98
+ description: job.description,
99
+ job_type: job.job_type,
100
+ category: job.category,
101
+ price: job.price,
102
+ price_type: job.price_type,
103
+ location: job.location,
104
+ required_skills: job.required_skills,
105
+ metadata: job.metadata,
106
+ created_at: job.created_at,
107
+ },
108
+ source: "AlbaBot central",
109
+ };
110
+
111
+ // λͺ¨λ“  λ΄‡μ—κ²Œ μ•Œλ¦Ό 전솑 (μ‹€νŒ¨ν•΄λ„ μ‘λ‹΅μ—λŠ” 영ν–₯ μ—†μŒ)
112
+ Promise.allSettled(bots.map(bot =>
113
+ fetch(bot.webhook_url as string, {
114
+ method: "POST",
115
+ headers: { "Content-Type": "application/json" },
116
+ body: JSON.stringify(payload),
117
+ }).catch(err => console.error(`Webhook failed for ${bot.name}:`, err))
118
+ )).catch(err => console.error("Webhook promise error:", err));
119
+ }
120
+
83
121
  return res.status(201).json({
84
122
  success: true,
85
123
  message: "μž‘μ—…μ΄ λ“±λ‘λ˜μ—ˆμŠ΅λ‹ˆλ‹€. μ—μŠ€ν¬λ‘œ 결제 ν›„ ꡬ인이 μ‹œμž‘λ©λ‹ˆλ‹€.",
@@ -93,6 +131,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
93
131
  price_type: job.price_type,
94
132
  location: job.location,
95
133
  required_skills: job.required_skills,
134
+ metadata: job.metadata,
96
135
  status: job.status,
97
136
  created_at: job.created_at,
98
137
  },
@@ -0,0 +1,114 @@
1
+ import { createClient } from "@supabase/supabase-js";
2
+ import type { VercelRequest, VercelResponse } from "@vercel/node";
3
+
4
+ function getSupabase() {
5
+ const url = process.env.SUPABASE_URL;
6
+ const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
7
+ if (!url || !key) throw new Error("Supabase ν™˜κ²½λ³€μˆ˜κ°€ μ„€μ •λ˜μ§€ μ•Šμ•˜μŠ΅λ‹ˆλ‹€.");
8
+ return createClient(url, key);
9
+ }
10
+
11
+ function extractApiKey(req: VercelRequest): string | null {
12
+ const auth = req.headers.authorization;
13
+ if (!auth?.startsWith("Bearer ")) return null;
14
+ return auth.slice(7).trim();
15
+ }
16
+
17
+ export default async function handler(req: VercelRequest, res: VercelResponse) {
18
+ res.setHeader("Access-Control-Allow-Origin", "*");
19
+ res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
20
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
21
+ if (req.method === "OPTIONS") return res.status(200).end();
22
+
23
+ if (req.method !== "POST") {
24
+ return res.status(405).json({ success: false, error: "Method not allowed" });
25
+ }
26
+
27
+ try {
28
+ const apiKey = extractApiKey(req);
29
+ if (!apiKey) {
30
+ return res.status(401).json({
31
+ success: false,
32
+ error: "Authorization 헀더가 ν•„μš”ν•©λ‹ˆλ‹€.",
33
+ hint: "Authorization: Bearer YOUR_API_KEY",
34
+ });
35
+ }
36
+
37
+ const supabase = getSupabase();
38
+
39
+ // API ν‚€ μœ νš¨μ„± 확인
40
+ const { data: agent, error: authError } = await supabase
41
+ .from("agents")
42
+ .select("id")
43
+ .eq("api_key", apiKey)
44
+ .single();
45
+
46
+ if (authError || !agent) {
47
+ return res.status(404).json({
48
+ success: false,
49
+ error: "μœ νš¨ν•˜μ§€ μ•Šμ€ API ν‚€μž…λ‹ˆλ‹€.",
50
+ });
51
+ }
52
+
53
+ const { jobId, resultUrl, resultMessage } = req.body || {};
54
+
55
+ if (!jobId || (!resultUrl && !resultMessage)) {
56
+ return res.status(400).json({
57
+ success: false,
58
+ error: "jobId와 (resultUrl λ˜λŠ” resultMessage)κ°€ ν•„μš”ν•©λ‹ˆλ‹€.",
59
+ });
60
+ }
61
+
62
+ // μž‘μ—… κΆŒν•œ 및 μƒνƒœ 확인
63
+ const { data: job, error: jobError } = await supabase
64
+ .from("jobs")
65
+ .select("worker_id, status")
66
+ .eq("id", jobId)
67
+ .single();
68
+
69
+ if (jobError || !job) {
70
+ return res.status(404).json({ success: false, error: "μž‘μ—…μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." });
71
+ }
72
+
73
+ if (job.worker_id !== agent.id) {
74
+ return res.status(403).json({
75
+ success: false,
76
+ error: "μž‘μ—… μˆ˜ν–‰μžλ§Œ κ²°κ³Όλ₯Ό 전달할 수 μžˆμŠ΅λ‹ˆλ‹€.",
77
+ });
78
+ }
79
+
80
+ // κ²°κ³Ό μ—…λ°μ΄νŠΈ 및 μƒνƒœ μ™„λ£Œ λ³€κ²½
81
+ const { data: updated, error: updateError } = await supabase
82
+ .from("jobs")
83
+ .update({
84
+ status: "completed",
85
+ result_url: resultUrl || null,
86
+ result_message: resultMessage || null,
87
+ completed_at: new Date().toISOString(),
88
+ })
89
+ .eq("id", jobId)
90
+ .select()
91
+ .single();
92
+
93
+ if (updateError) {
94
+ return res.status(500).json({ success: false, error: updateError.message });
95
+ }
96
+
97
+ return res.status(200).json({
98
+ success: true,
99
+ message: "μž‘μ—… κ²°κ³Όκ°€ μ „λ‹¬λ˜μ—ˆμœΌλ©° μž‘μ—…μ΄ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.",
100
+ job: {
101
+ id: updated.id,
102
+ status: updated.status,
103
+ result_url: updated.result_url,
104
+ result_message: updated.result_message,
105
+ },
106
+ });
107
+ } catch (err) {
108
+ console.error("jobs/deliver handler error:", err);
109
+ return res.status(500).json({
110
+ success: false,
111
+ error: err instanceof Error ? err.message : "Internal server error",
112
+ });
113
+ }
114
+ }
@@ -16,14 +16,10 @@ function extractApiKey(req: VercelRequest): string | null {
16
16
 
17
17
  export default async function handler(req: VercelRequest, res: VercelResponse) {
18
18
  res.setHeader("Access-Control-Allow-Origin", "*");
19
- res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
19
+ res.setHeader("Access-Control-Allow-Methods", "GET, PUT, OPTIONS");
20
20
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
21
21
  if (req.method === "OPTIONS") return res.status(200).end();
22
22
 
23
- if (req.method !== "GET") {
24
- return res.status(405).json({ success: false, error: "Method not allowed" });
25
- }
26
-
27
23
  try {
28
24
  const apiKey = extractApiKey(req);
29
25
  if (!apiKey) {
@@ -51,52 +47,104 @@ export default async function handler(req: VercelRequest, res: VercelResponse) {
51
47
  });
52
48
  }
53
49
 
54
- const jobId = req.query.jobId as string;
50
+ const jobId = (req.query.jobId as string) || (req.body?.jobId as string);
55
51
  if (!jobId) {
56
52
  return res.status(400).json({
57
53
  success: false,
58
- error: "jobId 쿼리 νŒŒλΌλ―Έν„°κ°€ ν•„μš”ν•©λ‹ˆλ‹€.",
54
+ error: "jobIdκ°€ ν•„μš”ν•©λ‹ˆλ‹€. (query λ˜λŠ” body)",
59
55
  });
60
56
  }
61
57
 
62
- // μž‘μ—… 상세 쑰회
63
- const { data: job, error: jobError } = await supabase
64
- .from("jobs")
65
- .select("*")
66
- .eq("id", jobId)
67
- .single();
58
+ // --- GET: μž‘μ—… 상세 쑰회 ---
59
+ if (req.method === "GET") {
60
+ const { data: job, error: jobError } = await supabase
61
+ .from("jobs")
62
+ .select("*")
63
+ .eq("id", jobId)
64
+ .single();
68
65
 
69
- if (jobError || !job) {
70
- return res.status(404).json({
71
- success: false,
72
- error: "μž‘μ—…μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.",
66
+ if (jobError || !job) {
67
+ return res.status(404).json({
68
+ success: false,
69
+ error: "μž‘μ—…μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€.",
70
+ });
71
+ }
72
+
73
+ const { data: applications } = await supabase
74
+ .from("applications")
75
+ .select("id, applicant_id, message, proposed_rate, status, created_at")
76
+ .eq("job_id", jobId)
77
+ .order("created_at", { ascending: false });
78
+
79
+ return res.status(200).json({
80
+ success: true,
81
+ job: {
82
+ id: job.id,
83
+ title: job.title,
84
+ description: job.description,
85
+ job_type: job.job_type,
86
+ category: job.category,
87
+ price: job.price,
88
+ status: job.status,
89
+ metadata: job.metadata || null,
90
+ result_url: job.result_url || null,
91
+ result_message: job.result_message || null,
92
+ client_id: job.client_id,
93
+ worker_id: job.worker_id,
94
+ created_at: job.created_at,
95
+ },
96
+ applications: applications || [],
97
+ applicationCount: applications?.length || 0,
73
98
  });
74
99
  }
75
100
 
76
- // ν•΄λ‹Ή μž‘μ—…μ˜ 지원 λͺ©λ‘ 쑰회
77
- const { data: applications } = await supabase
78
- .from("applications")
79
- .select("id, applicant_id, message, proposed_rate, status, created_at")
80
- .eq("job_id", jobId)
81
- .order("created_at", { ascending: false });
82
-
83
- return res.status(200).json({
84
- success: true,
85
- job: {
86
- id: job.id,
87
- title: job.title,
88
- description: job.description,
89
- job_type: job.job_type,
90
- category: job.category,
91
- price: job.price,
92
- status: job.status,
93
- client_id: job.client_id,
94
- worker_id: job.worker_id,
95
- created_at: job.created_at,
96
- },
97
- applications: applications || [],
98
- applicationCount: applications?.length || 0,
99
- });
101
+ // --- PUT: μž‘μ—… μƒνƒœ λ³€κ²½ ---
102
+ if (req.method === "PUT") {
103
+ const { status } = req.body || {};
104
+ if (!status) {
105
+ return res.status(400).json({
106
+ success: false,
107
+ error: "λ³€κ²½ν•  statusκ°€ ν•„μš”ν•©λ‹ˆλ‹€.",
108
+ });
109
+ }
110
+
111
+ // μž‘μ—… κΆŒν•œ 확인 (ν•΄λ‹Ή μž‘μ—…μ˜ μˆ˜ν–‰μžλ§Œ λ³€κ²½ κ°€λŠ₯)
112
+ const { data: job, error: jobError } = await supabase
113
+ .from("jobs")
114
+ .select("worker_id, status")
115
+ .eq("id", jobId)
116
+ .single();
117
+
118
+ if (jobError || !job) {
119
+ return res.status(404).json({ success: false, error: "μž‘μ—…μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." });
120
+ }
121
+
122
+ if (job.worker_id !== agent.id) {
123
+ return res.status(403).json({
124
+ success: false,
125
+ error: "μž‘μ—… μˆ˜ν–‰μžλ§Œ μƒνƒœλ₯Ό λ³€κ²½ν•  수 μžˆμŠ΅λ‹ˆλ‹€.",
126
+ });
127
+ }
128
+
129
+ const { data: updated, error: updateError } = await supabase
130
+ .from("jobs")
131
+ .update({ status })
132
+ .eq("id", jobId)
133
+ .select()
134
+ .single();
135
+
136
+ if (updateError) {
137
+ return res.status(500).json({ success: false, error: updateError.message });
138
+ }
139
+
140
+ return res.status(200).json({
141
+ success: true,
142
+ message: "μž‘μ—… μƒνƒœκ°€ μ—…λ°μ΄νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€.",
143
+ status: updated.status,
144
+ });
145
+ }
146
+
147
+ return res.status(405).json({ success: false, error: "Method not allowed" });
100
148
  } catch (err) {
101
149
  console.error("jobs/status handler error:", err);
102
150
  return res.status(500).json({