@nativesquare/upwork 0.1.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.
Files changed (69) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +160 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/index.d.ts +40 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +82 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/client/types.d.ts +34 -0
  12. package/dist/client/types.d.ts.map +1 -0
  13. package/dist/client/types.js +3 -0
  14. package/dist/client/types.js.map +1 -0
  15. package/dist/component/_generated/api.d.ts +38 -0
  16. package/dist/component/_generated/api.d.ts.map +1 -0
  17. package/dist/component/_generated/api.js +31 -0
  18. package/dist/component/_generated/api.js.map +1 -0
  19. package/dist/component/_generated/component.d.ts +84 -0
  20. package/dist/component/_generated/component.d.ts.map +1 -0
  21. package/dist/component/_generated/component.js +11 -0
  22. package/dist/component/_generated/component.js.map +1 -0
  23. package/dist/component/_generated/dataModel.d.ts +46 -0
  24. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  25. package/dist/component/_generated/dataModel.js +11 -0
  26. package/dist/component/_generated/dataModel.js.map +1 -0
  27. package/dist/component/_generated/server.d.ts +121 -0
  28. package/dist/component/_generated/server.d.ts.map +1 -0
  29. package/dist/component/_generated/server.js +78 -0
  30. package/dist/component/_generated/server.js.map +1 -0
  31. package/dist/component/convex.config.d.ts +3 -0
  32. package/dist/component/convex.config.d.ts.map +1 -0
  33. package/dist/component/convex.config.js +3 -0
  34. package/dist/component/convex.config.js.map +1 -0
  35. package/dist/component/crons.d.ts +3 -0
  36. package/dist/component/crons.d.ts.map +1 -0
  37. package/dist/component/crons.js +6 -0
  38. package/dist/component/crons.js.map +1 -0
  39. package/dist/component/private.d.ts +36 -0
  40. package/dist/component/private.d.ts.map +1 -0
  41. package/dist/component/private.js +99 -0
  42. package/dist/component/private.js.map +1 -0
  43. package/dist/component/public.d.ts +68 -0
  44. package/dist/component/public.d.ts.map +1 -0
  45. package/dist/component/public.js +241 -0
  46. package/dist/component/public.js.map +1 -0
  47. package/dist/component/schema.d.ts +59 -0
  48. package/dist/component/schema.d.ts.map +1 -0
  49. package/dist/component/schema.js +30 -0
  50. package/dist/component/schema.js.map +1 -0
  51. package/dist/react/index.d.ts +2 -0
  52. package/dist/react/index.d.ts.map +1 -0
  53. package/dist/react/index.js +6 -0
  54. package/dist/react/index.js.map +1 -0
  55. package/package.json +108 -0
  56. package/src/client/_generated/_ignore.ts +1 -0
  57. package/src/client/index.ts +131 -0
  58. package/src/client/types.ts +50 -0
  59. package/src/component/_generated/api.ts +54 -0
  60. package/src/component/_generated/component.ts +121 -0
  61. package/src/component/_generated/dataModel.ts +60 -0
  62. package/src/component/_generated/server.ts +156 -0
  63. package/src/component/convex.config.ts +3 -0
  64. package/src/component/crons.ts +13 -0
  65. package/src/component/private.ts +108 -0
  66. package/src/component/public.ts +315 -0
  67. package/src/component/schema.ts +31 -0
  68. package/src/react/index.ts +7 -0
  69. package/src/test.ts +18 -0
@@ -0,0 +1,315 @@
1
+ import { query, action } from "./_generated/server.js";
2
+ import { api } from "./_generated/api.js";
3
+ import { v } from "convex/values";
4
+
5
+ function buildJobPostingsQuery(opts: {
6
+ searchQuery?: string;
7
+ sortField?: string;
8
+ }): { query: string; variables: Record<string, unknown> } {
9
+ const filterArg = opts.searchQuery
10
+ ? `marketPlaceJobFilter: $marketPlaceJobFilter,`
11
+ : "";
12
+
13
+ const sortField = opts.sortField ?? "RECENCY";
14
+
15
+ const variableDefs = opts.searchQuery
16
+ ? `($marketPlaceJobFilter: MarketplaceJobPostingsSearchFilter)`
17
+ : "";
18
+
19
+ const variables: Record<string, unknown> = {};
20
+ if (opts.searchQuery) {
21
+ variables.marketPlaceJobFilter = {
22
+ searchTerm_eq: { andTerms_all: opts.searchQuery },
23
+ };
24
+ }
25
+
26
+ const query = `
27
+ query ${variableDefs} {
28
+ marketplaceJobPostingsSearch(
29
+ ${filterArg}
30
+ searchType: USER_JOBS_SEARCH
31
+ sortAttributes: { field: ${sortField} }
32
+ ) {
33
+ totalCount
34
+ edges {
35
+ node {
36
+ id
37
+ title
38
+ description
39
+ category { id name }
40
+ subcategory { id name }
41
+ skills { name }
42
+ experienceLevel
43
+ duration
44
+ budget { amount currency }
45
+ createdDateTime
46
+ publishedDateTime
47
+ client { totalHires companyName }
48
+ }
49
+ }
50
+ pageInfo { hasNextPage endCursor }
51
+ }
52
+ }`;
53
+
54
+ return { query, variables };
55
+ }
56
+
57
+ type JobPostingRow = {
58
+ upworkId: string;
59
+ title: string;
60
+ description: string;
61
+ category?: string;
62
+ subcategory?: string;
63
+ skills: Array<{ name: string }>;
64
+ experienceLevel: string;
65
+ duration?: string;
66
+ budgetAmount?: string;
67
+ budgetCurrency?: string;
68
+ createdDateTime: string;
69
+ publishedDateTime: string;
70
+ clientTotalHires?: number;
71
+ clientCompanyName?: string;
72
+ };
73
+
74
+ type SearchResult = {
75
+ totalCount: number;
76
+ postings: JobPostingRow[];
77
+ hasNextPage: boolean;
78
+ };
79
+
80
+ export const refreshAccessToken = action({
81
+ args: {
82
+ clientId: v.string(),
83
+ clientSecret: v.string(),
84
+ baseUrl: v.string(),
85
+ },
86
+ handler: async (ctx, args) => {
87
+ const tokens = await ctx.runQuery(api.private.getTokens, {});
88
+ if (!tokens) {
89
+ throw new Error("No stored tokens found. Complete OAuth authorization first.");
90
+ }
91
+
92
+ const tokenUrl = `${args.baseUrl}/api/v3/oauth2/token`;
93
+
94
+ const response = await fetch(tokenUrl, {
95
+ method: "POST",
96
+ headers: {
97
+ "Content-Type": "application/x-www-form-urlencoded",
98
+ Accept: "application/json",
99
+ },
100
+ body: new URLSearchParams({
101
+ grant_type: "refresh_token",
102
+ client_id: args.clientId,
103
+ client_secret: args.clientSecret,
104
+ refresh_token: tokens.refreshToken,
105
+ }).toString(),
106
+ });
107
+
108
+ if (!response.ok) {
109
+ const text = await response.text();
110
+ throw new Error(`Token refresh failed (${response.status}): ${text}`);
111
+ }
112
+
113
+ const data = await response.json();
114
+ const expiresAt = Date.now() + (data.expires_in ?? 86400) * 1000;
115
+
116
+ await ctx.runMutation(api.private.storeTokens, {
117
+ accessToken: data.access_token,
118
+ refreshToken: data.refresh_token ?? tokens.refreshToken,
119
+ expiresAt,
120
+ tokenType: data.token_type ?? "Bearer",
121
+ });
122
+ },
123
+ });
124
+
125
+ export const exchangeAuthCode = action({
126
+ args: {
127
+ clientId: v.string(),
128
+ clientSecret: v.string(),
129
+ code: v.string(),
130
+ redirectUri: v.string(),
131
+ baseUrl: v.string(),
132
+ },
133
+ handler: async (ctx, args): Promise<void> => {
134
+ const tokenUrl = `${args.baseUrl}/api/v3/oauth2/token`;
135
+
136
+ const body = new URLSearchParams({
137
+ grant_type: "authorization_code",
138
+ client_id: args.clientId,
139
+ client_secret: args.clientSecret,
140
+ code: args.code,
141
+ redirect_uri: args.redirectUri,
142
+ }).toString();
143
+
144
+ console.log("[exchangeAuthCode] POST", tokenUrl);
145
+ console.log("[exchangeAuthCode] redirect_uri:", args.redirectUri);
146
+ console.log("[exchangeAuthCode] client_id:", args.clientId);
147
+ console.log("[exchangeAuthCode] code:", args.code);
148
+
149
+ const response = await fetch(tokenUrl, {
150
+ method: "POST",
151
+ headers: {
152
+ "Content-Type": "application/x-www-form-urlencoded",
153
+ Accept: "application/json",
154
+ },
155
+ body,
156
+ });
157
+
158
+ if (!response.ok) {
159
+ const text = await response.text();
160
+ throw new Error(`OAuth token exchange failed (${response.status}): ${text}`);
161
+ }
162
+
163
+ const data = await response.json();
164
+ const expiresAt = Date.now() + (data.expires_in ?? 86400) * 1000;
165
+
166
+ await ctx.runMutation(api.private.storeTokens, {
167
+ accessToken: data.access_token,
168
+ refreshToken: data.refresh_token,
169
+ expiresAt,
170
+ tokenType: data.token_type ?? "Bearer",
171
+ });
172
+ },
173
+ });
174
+
175
+ export const searchJobPostings = action({
176
+ args: {
177
+ clientId: v.string(),
178
+ clientSecret: v.string(),
179
+ baseUrl: v.string(),
180
+ searchQuery: v.optional(v.string()),
181
+ sortField: v.optional(v.string()),
182
+ },
183
+ handler: async (ctx, args): Promise<SearchResult> => {
184
+ let tokens = await ctx.runQuery(api.private.getTokens, {});
185
+ if (!tokens) {
186
+ throw new Error("Not connected to Upwork. Complete OAuth authorization first.");
187
+ }
188
+
189
+ 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
+ tokens = await ctx.runQuery(api.private.getTokens, {});
196
+ if (!tokens) {
197
+ throw new Error("Failed to refresh token.");
198
+ }
199
+ }
200
+
201
+ const graphqlUrl = `${args.baseUrl}/graphql`;
202
+
203
+ const { query, variables } = buildJobPostingsQuery({
204
+ searchQuery: args.searchQuery,
205
+ sortField: args.sortField,
206
+ });
207
+
208
+ const response = await fetch(graphqlUrl, {
209
+ method: "POST",
210
+ headers: {
211
+ "Content-Type": "application/json",
212
+ Authorization: `Bearer ${tokens.accessToken}`,
213
+ },
214
+ body: JSON.stringify({ query, variables }),
215
+ });
216
+
217
+ if (!response.ok) {
218
+ const text = await response.text();
219
+ throw new Error(`Upwork GraphQL request failed (${response.status}): ${text}`);
220
+ }
221
+
222
+ const result: {
223
+ data?: { marketplaceJobPostingsSearch?: {
224
+ totalCount?: number;
225
+ edges?: Array<{ node: Record<string, unknown> }>;
226
+ pageInfo?: { hasNextPage?: boolean; endCursor?: string };
227
+ }};
228
+ errors?: Array<{ message: string }>;
229
+ } = await response.json();
230
+
231
+ if (result.errors?.length) {
232
+ throw new Error(
233
+ `Upwork GraphQL errors: ${result.errors.map((e) => e.message).join(", ")}`,
234
+ );
235
+ }
236
+
237
+ const connection = result.data?.marketplaceJobPostingsSearch;
238
+ if (!connection) {
239
+ return { totalCount: 0, postings: [], hasNextPage: false };
240
+ }
241
+
242
+ const postings: JobPostingRow[] = (connection.edges ?? []).map(
243
+ (edge) => {
244
+ const node = edge.node;
245
+ const budget = node.budget as { amount?: string; currency?: string } | null;
246
+ const client = node.client as {
247
+ totalHires?: number;
248
+ companyName?: string;
249
+ } | null;
250
+ const category = node.category as { name?: string } | null;
251
+ const subcategory = node.subcategory as { name?: string } | null;
252
+ const skills = (node.skills as Array<{ name: string }>) ?? [];
253
+
254
+ return {
255
+ upworkId: String(node.id),
256
+ title: String(node.title ?? ""),
257
+ description: String(node.description ?? ""),
258
+ category: category?.name ?? undefined,
259
+ subcategory: subcategory?.name ?? undefined,
260
+ skills: skills.map((s) => ({ name: s.name })),
261
+ experienceLevel: String(node.experienceLevel ?? ""),
262
+ duration: node.duration != null ? String(node.duration) : undefined,
263
+ budgetAmount: budget?.amount != null ? String(budget.amount) : undefined,
264
+ budgetCurrency: budget?.currency != null ? String(budget.currency) : undefined,
265
+ createdDateTime: String(node.createdDateTime ?? ""),
266
+ publishedDateTime: String(node.publishedDateTime ?? ""),
267
+ clientTotalHires: client?.totalHires ?? undefined,
268
+ clientCompanyName: client?.companyName ?? undefined,
269
+ };
270
+ },
271
+ );
272
+
273
+ if (postings.length > 0) {
274
+ await ctx.runMutation(api.private.upsertJobPostings, { postings });
275
+ }
276
+
277
+ return {
278
+ totalCount: connection.totalCount ?? 0,
279
+ postings,
280
+ hasNextPage: connection.pageInfo?.hasNextPage ?? false,
281
+ };
282
+ },
283
+ });
284
+
285
+ const TWENTY_THREE_HOURS_MS = 23 * 60 * 60 * 1000;
286
+
287
+ export const listJobPostings = query({
288
+ args: {
289
+ limit: v.optional(v.number()),
290
+ },
291
+ handler: async (ctx, args) => {
292
+ const limit = args.limit ?? 50;
293
+ const cutoff = Date.now() - TWENTY_THREE_HOURS_MS;
294
+ const postings = await ctx.db
295
+ .query("jobPostings")
296
+ .withIndex("byCachedAt", (q) => q.gte("cachedAt", cutoff))
297
+ .order("desc")
298
+ .take(limit);
299
+ return postings;
300
+ },
301
+ });
302
+
303
+ export const getAuthStatus = query({
304
+ args: {},
305
+ handler: async (ctx) => {
306
+ const tokens = await ctx.db.query("oauthTokens").first();
307
+ if (!tokens) {
308
+ return "disconnected" as const;
309
+ }
310
+ if (tokens.expiresAt < Date.now()) {
311
+ return "expired" as const;
312
+ }
313
+ return "connected" as const;
314
+ },
315
+ });
@@ -0,0 +1,31 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ export default defineSchema({
5
+ oauthTokens: defineTable({
6
+ accessToken: v.string(),
7
+ refreshToken: v.string(),
8
+ expiresAt: v.number(),
9
+ tokenType: v.string(),
10
+ }),
11
+
12
+ jobPostings: defineTable({
13
+ upworkId: v.string(),
14
+ title: v.string(),
15
+ description: v.string(),
16
+ category: v.optional(v.string()),
17
+ subcategory: v.optional(v.string()),
18
+ skills: v.array(v.object({ name: v.string() })),
19
+ experienceLevel: v.string(),
20
+ duration: v.optional(v.string()),
21
+ budgetAmount: v.optional(v.string()),
22
+ budgetCurrency: v.optional(v.string()),
23
+ createdDateTime: v.string(),
24
+ publishedDateTime: v.string(),
25
+ clientTotalHires: v.optional(v.number()),
26
+ clientCompanyName: v.optional(v.string()),
27
+ cachedAt: v.number(),
28
+ })
29
+ .index("byUpworkId", ["upworkId"])
30
+ .index("byCachedAt", ["cachedAt"]),
31
+ });
@@ -0,0 +1,7 @@
1
+ "use client";
2
+
3
+ // This is where React components / hooks go.
4
+
5
+ export const useMyComponent = () => {
6
+ return {};
7
+ };
package/src/test.ts ADDED
@@ -0,0 +1,18 @@
1
+ /// <reference types="vite/client" />
2
+ import type { TestConvex } from "convex-test";
3
+ import type { GenericSchema, SchemaDefinition } from "convex/server";
4
+ import schema from "./component/schema.js";
5
+ const modules = import.meta.glob("./component/**/*.ts");
6
+
7
+ /**
8
+ * Register the component with the test convex instance.
9
+ * @param t - The test convex instance, e.g. from calling `convexTest`.
10
+ * @param name - The name of the component, as registered in convex.config.ts.
11
+ */
12
+ export function register(
13
+ t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
14
+ name: string = "upwork",
15
+ ) {
16
+ t.registerComponent(name, schema, modules);
17
+ }
18
+ export default { register, schema, modules };