@nativesquare/upwork 0.1.0 → 0.2.0
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/README.md +150 -94
- package/dist/client/index.d.ts +7 -18
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +38 -21
- package/dist/client/index.js.map +1 -1
- package/dist/client/types.d.ts +0 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/client/types.js +0 -1
- package/dist/client/types.js.map +1 -1
- package/dist/component/_generated/component.d.ts +7 -11
- package/dist/component/_generated/component.d.ts.map +1 -1
- package/dist/component/private.d.ts +2 -2
- package/dist/component/public.d.ts +28 -12
- package/dist/component/public.d.ts.map +1 -1
- package/dist/component/public.js +113 -25
- package/dist/component/public.js.map +1 -1
- package/dist/component/schema.d.ts +2 -2
- package/package.json +1 -1
- package/src/client/index.ts +46 -41
- package/src/client/types.ts +0 -2
- package/src/component/_generated/component.ts +17 -15
- package/src/component/public.ts +144 -25
package/src/component/public.ts
CHANGED
|
@@ -2,6 +2,19 @@ import { query, action } from "./_generated/server.js";
|
|
|
2
2
|
import { api } from "./_generated/api.js";
|
|
3
3
|
import { v } from "convex/values";
|
|
4
4
|
|
|
5
|
+
const BASE_URL = "https://upwork-mock-server.onrender.com";
|
|
6
|
+
|
|
7
|
+
function getCredentials(): { clientId: string; clientSecret: string } {
|
|
8
|
+
const clientId = process.env.UPWORK_CLIENT_ID;
|
|
9
|
+
const clientSecret = process.env.UPWORK_CLIENT_SECRET;
|
|
10
|
+
if (!clientId || !clientSecret) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
"Missing UPWORK_CLIENT_ID or UPWORK_CLIENT_SECRET environment variables.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
return { clientId, clientSecret };
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
function buildJobPostingsQuery(opts: {
|
|
6
19
|
searchQuery?: string;
|
|
7
20
|
sortField?: string;
|
|
@@ -78,18 +91,15 @@ type SearchResult = {
|
|
|
78
91
|
};
|
|
79
92
|
|
|
80
93
|
export const refreshAccessToken = action({
|
|
81
|
-
args: {
|
|
82
|
-
|
|
83
|
-
clientSecret
|
|
84
|
-
baseUrl: v.string(),
|
|
85
|
-
},
|
|
86
|
-
handler: async (ctx, args) => {
|
|
94
|
+
args: {},
|
|
95
|
+
handler: async (ctx) => {
|
|
96
|
+
const { clientId, clientSecret } = getCredentials();
|
|
87
97
|
const tokens = await ctx.runQuery(api.private.getTokens, {});
|
|
88
98
|
if (!tokens) {
|
|
89
99
|
throw new Error("No stored tokens found. Complete OAuth authorization first.");
|
|
90
100
|
}
|
|
91
101
|
|
|
92
|
-
const tokenUrl = `${
|
|
102
|
+
const tokenUrl = `${BASE_URL}/api/v3/oauth2/token`;
|
|
93
103
|
|
|
94
104
|
const response = await fetch(tokenUrl, {
|
|
95
105
|
method: "POST",
|
|
@@ -99,8 +109,8 @@ export const refreshAccessToken = action({
|
|
|
99
109
|
},
|
|
100
110
|
body: new URLSearchParams({
|
|
101
111
|
grant_type: "refresh_token",
|
|
102
|
-
client_id:
|
|
103
|
-
client_secret:
|
|
112
|
+
client_id: clientId,
|
|
113
|
+
client_secret: clientSecret,
|
|
104
114
|
refresh_token: tokens.refreshToken,
|
|
105
115
|
}).toString(),
|
|
106
116
|
});
|
|
@@ -124,26 +134,24 @@ export const refreshAccessToken = action({
|
|
|
124
134
|
|
|
125
135
|
export const exchangeAuthCode = action({
|
|
126
136
|
args: {
|
|
127
|
-
clientId: v.string(),
|
|
128
|
-
clientSecret: v.string(),
|
|
129
137
|
code: v.string(),
|
|
130
138
|
redirectUri: v.string(),
|
|
131
|
-
baseUrl: v.string(),
|
|
132
139
|
},
|
|
133
140
|
handler: async (ctx, args): Promise<void> => {
|
|
134
|
-
const
|
|
141
|
+
const { clientId, clientSecret } = getCredentials();
|
|
142
|
+
const tokenUrl = `${BASE_URL}/api/v3/oauth2/token`;
|
|
135
143
|
|
|
136
144
|
const body = new URLSearchParams({
|
|
137
145
|
grant_type: "authorization_code",
|
|
138
|
-
client_id:
|
|
139
|
-
client_secret:
|
|
146
|
+
client_id: clientId,
|
|
147
|
+
client_secret: clientSecret,
|
|
140
148
|
code: args.code,
|
|
141
149
|
redirect_uri: args.redirectUri,
|
|
142
150
|
}).toString();
|
|
143
151
|
|
|
144
152
|
console.log("[exchangeAuthCode] POST", tokenUrl);
|
|
145
153
|
console.log("[exchangeAuthCode] redirect_uri:", args.redirectUri);
|
|
146
|
-
console.log("[exchangeAuthCode] client_id:",
|
|
154
|
+
console.log("[exchangeAuthCode] client_id:", clientId);
|
|
147
155
|
console.log("[exchangeAuthCode] code:", args.code);
|
|
148
156
|
|
|
149
157
|
const response = await fetch(tokenUrl, {
|
|
@@ -174,9 +182,6 @@ export const exchangeAuthCode = action({
|
|
|
174
182
|
|
|
175
183
|
export const searchJobPostings = action({
|
|
176
184
|
args: {
|
|
177
|
-
clientId: v.string(),
|
|
178
|
-
clientSecret: v.string(),
|
|
179
|
-
baseUrl: v.string(),
|
|
180
185
|
searchQuery: v.optional(v.string()),
|
|
181
186
|
sortField: v.optional(v.string()),
|
|
182
187
|
},
|
|
@@ -187,18 +192,14 @@ export const searchJobPostings = action({
|
|
|
187
192
|
}
|
|
188
193
|
|
|
189
194
|
if (tokens.expiresAt < Date.now()) {
|
|
190
|
-
await ctx.runAction(api.public.refreshAccessToken, {
|
|
191
|
-
clientId: args.clientId,
|
|
192
|
-
clientSecret: args.clientSecret,
|
|
193
|
-
baseUrl: args.baseUrl,
|
|
194
|
-
});
|
|
195
|
+
await ctx.runAction(api.public.refreshAccessToken, {});
|
|
195
196
|
tokens = await ctx.runQuery(api.private.getTokens, {});
|
|
196
197
|
if (!tokens) {
|
|
197
198
|
throw new Error("Failed to refresh token.");
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
|
|
201
|
-
const graphqlUrl = `${
|
|
202
|
+
const graphqlUrl = `${BASE_URL}/graphql`;
|
|
202
203
|
|
|
203
204
|
const { query, variables } = buildJobPostingsQuery({
|
|
204
205
|
searchQuery: args.searchQuery,
|
|
@@ -282,6 +283,124 @@ export const searchJobPostings = action({
|
|
|
282
283
|
},
|
|
283
284
|
});
|
|
284
285
|
|
|
286
|
+
export const getJobPosting = query({
|
|
287
|
+
args: { upworkId: v.string() },
|
|
288
|
+
handler: async (ctx, args) => {
|
|
289
|
+
return (
|
|
290
|
+
(await ctx.db
|
|
291
|
+
.query("jobPostings")
|
|
292
|
+
.withIndex("byUpworkId", (q) => q.eq("upworkId", args.upworkId))
|
|
293
|
+
.first()) ?? null
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
function buildJobPostingQuery(upworkId: string): {
|
|
299
|
+
query: string;
|
|
300
|
+
variables: Record<string, unknown>;
|
|
301
|
+
} {
|
|
302
|
+
const query = `
|
|
303
|
+
query($id: ID!) {
|
|
304
|
+
marketplaceJobPosting(id: $id) {
|
|
305
|
+
id
|
|
306
|
+
title
|
|
307
|
+
description
|
|
308
|
+
category { id name }
|
|
309
|
+
subcategory { id name }
|
|
310
|
+
skills { name }
|
|
311
|
+
experienceLevel
|
|
312
|
+
duration
|
|
313
|
+
budget { amount currency }
|
|
314
|
+
createdDateTime
|
|
315
|
+
publishedDateTime
|
|
316
|
+
client { totalHires companyName }
|
|
317
|
+
}
|
|
318
|
+
}`;
|
|
319
|
+
return { query, variables: { id: upworkId } };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export const fetchJobPosting = action({
|
|
323
|
+
args: {
|
|
324
|
+
upworkId: v.string(),
|
|
325
|
+
},
|
|
326
|
+
handler: async (ctx, args): Promise<JobPostingRow | null> => {
|
|
327
|
+
let tokens = await ctx.runQuery(api.private.getTokens, {});
|
|
328
|
+
if (!tokens) {
|
|
329
|
+
throw new Error("Not connected to Upwork. Complete OAuth authorization first.");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (tokens.expiresAt < Date.now()) {
|
|
333
|
+
await ctx.runAction(api.public.refreshAccessToken, {});
|
|
334
|
+
tokens = await ctx.runQuery(api.private.getTokens, {});
|
|
335
|
+
if (!tokens) {
|
|
336
|
+
throw new Error("Failed to refresh token.");
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const graphqlUrl = `${BASE_URL}/graphql`;
|
|
341
|
+
|
|
342
|
+
const { query, variables } = buildJobPostingQuery(args.upworkId);
|
|
343
|
+
|
|
344
|
+
const response = await fetch(graphqlUrl, {
|
|
345
|
+
method: "POST",
|
|
346
|
+
headers: {
|
|
347
|
+
"Content-Type": "application/json",
|
|
348
|
+
Authorization: `Bearer ${tokens.accessToken}`,
|
|
349
|
+
},
|
|
350
|
+
body: JSON.stringify({ query, variables }),
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
if (!response.ok) {
|
|
354
|
+
const text = await response.text();
|
|
355
|
+
throw new Error(`Upwork GraphQL request failed (${response.status}): ${text}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const result: {
|
|
359
|
+
data?: { marketplaceJobPosting?: Record<string, unknown> };
|
|
360
|
+
errors?: Array<{ message: string }>;
|
|
361
|
+
} = await response.json();
|
|
362
|
+
|
|
363
|
+
if (result.errors?.length) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
`Upwork GraphQL errors: ${result.errors.map((e) => e.message).join(", ")}`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const node = result.data?.marketplaceJobPosting;
|
|
370
|
+
if (!node) return null;
|
|
371
|
+
|
|
372
|
+
const budget = node.budget as { amount?: string; currency?: string } | null;
|
|
373
|
+
const client = node.client as {
|
|
374
|
+
totalHires?: number;
|
|
375
|
+
companyName?: string;
|
|
376
|
+
} | null;
|
|
377
|
+
const category = node.category as { name?: string } | null;
|
|
378
|
+
const subcategory = node.subcategory as { name?: string } | null;
|
|
379
|
+
const skills = (node.skills as Array<{ name: string }>) ?? [];
|
|
380
|
+
|
|
381
|
+
const posting: JobPostingRow = {
|
|
382
|
+
upworkId: String(node.id),
|
|
383
|
+
title: String(node.title ?? ""),
|
|
384
|
+
description: String(node.description ?? ""),
|
|
385
|
+
category: category?.name ?? undefined,
|
|
386
|
+
subcategory: subcategory?.name ?? undefined,
|
|
387
|
+
skills: skills.map((s) => ({ name: s.name })),
|
|
388
|
+
experienceLevel: String(node.experienceLevel ?? ""),
|
|
389
|
+
duration: node.duration != null ? String(node.duration) : undefined,
|
|
390
|
+
budgetAmount: budget?.amount != null ? String(budget.amount) : undefined,
|
|
391
|
+
budgetCurrency: budget?.currency != null ? String(budget.currency) : undefined,
|
|
392
|
+
createdDateTime: String(node.createdDateTime ?? ""),
|
|
393
|
+
publishedDateTime: String(node.publishedDateTime ?? ""),
|
|
394
|
+
clientTotalHires: client?.totalHires ?? undefined,
|
|
395
|
+
clientCompanyName: client?.companyName ?? undefined,
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
await ctx.runMutation(api.private.upsertJobPostings, { postings: [posting] });
|
|
399
|
+
|
|
400
|
+
return posting;
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
285
404
|
const TWENTY_THREE_HOURS_MS = 23 * 60 * 60 * 1000;
|
|
286
405
|
|
|
287
406
|
export const listJobPostings = query({
|