@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.
@@ -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
- clientId: v.string(),
83
- clientSecret: v.string(),
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 = `${args.baseUrl}/api/v3/oauth2/token`;
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: args.clientId,
103
- client_secret: args.clientSecret,
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 tokenUrl = `${args.baseUrl}/api/v3/oauth2/token`;
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: args.clientId,
139
- client_secret: args.clientSecret,
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:", args.clientId);
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 = `${args.baseUrl}/graphql`;
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({