naystack 1.1.17 → 1.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 +742 -24
- package/dist/auth/email/client.cjs.js +116 -0
- package/dist/auth/email/client.d.mts +10 -0
- package/dist/auth/email/client.d.ts +10 -0
- package/dist/auth/email/client.esm.js +92 -0
- package/dist/auth/email/index.cjs.js +1 -1
- package/dist/auth/email/index.esm.js +1 -1
- package/dist/auth/email/routes/delete.cjs.js +1 -1
- package/dist/auth/email/routes/delete.esm.js +1 -1
- package/dist/auth/index.cjs.js +1 -1
- package/dist/auth/index.esm.js +1 -1
- package/dist/client/hooks.d.mts +2 -2
- package/dist/client/hooks.d.ts +2 -2
- package/dist/client/index.cjs.js +180 -5
- package/dist/client/index.d.mts +5 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.esm.js +173 -4
- package/dist/graphql/client.cjs.js +129 -0
- package/dist/graphql/client.d.mts +26 -0
- package/dist/graphql/client.d.ts +26 -0
- package/dist/graphql/client.esm.js +104 -0
- package/dist/graphql/index.cjs.js +55 -0
- package/dist/graphql/index.d.mts +4 -0
- package/dist/graphql/index.d.ts +4 -0
- package/dist/graphql/index.esm.js +57 -0
- package/dist/graphql/server.cjs.js +80 -0
- package/dist/graphql/server.d.mts +28 -0
- package/dist/graphql/server.d.ts +28 -0
- package/dist/graphql/server.esm.js +58 -0
- package/package.json +4 -1
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ import {
|
|
|
43
43
|
|
|
44
44
|
Setup email-based authentication with JWT tokens and optional Turnstile captcha verification.
|
|
45
45
|
|
|
46
|
+
**Basic Example:**
|
|
47
|
+
|
|
46
48
|
```typescript
|
|
47
49
|
const emailAuth = getEmailAuthRoutes({
|
|
48
50
|
getUser: async (email: string) => { /* fetch user by email */ },
|
|
@@ -59,6 +61,58 @@ const emailAuth = getEmailAuthRoutes({
|
|
|
59
61
|
export const { GET, POST, PUT, DELETE, getUserIdFromRequest } = emailAuth;
|
|
60
62
|
```
|
|
61
63
|
|
|
64
|
+
**Real-World Example with Drizzle ORM:**
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
import { db } from "@/lib/db";
|
|
68
|
+
import { UserTable, WebPushSubscriptionTable } from "@/lib/db/schema";
|
|
69
|
+
import { eq } from "drizzle-orm";
|
|
70
|
+
import { getEmailAuthRoutes } from "naystack/auth";
|
|
71
|
+
import { waitUntil } from "@vercel/functions";
|
|
72
|
+
|
|
73
|
+
export const { GET, POST, PUT, DELETE, getUserIdFromRequest } =
|
|
74
|
+
getEmailAuthRoutes({
|
|
75
|
+
// Fetch user by email using Drizzle
|
|
76
|
+
getUser: (email: string) =>
|
|
77
|
+
db.query.UserTable.findFirst({
|
|
78
|
+
where: eq(UserTable.email, email)
|
|
79
|
+
}),
|
|
80
|
+
|
|
81
|
+
// Create new user
|
|
82
|
+
createUser: async (user) => {
|
|
83
|
+
const [newUser] = await db
|
|
84
|
+
.insert(UserTable)
|
|
85
|
+
.values(user)
|
|
86
|
+
.returning();
|
|
87
|
+
return newUser;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
signingKey: process.env.SIGNING_KEY!,
|
|
91
|
+
refreshKey: process.env.REFRESH_KEY!,
|
|
92
|
+
turnstileKey: process.env.TURNSTILE_KEY!,
|
|
93
|
+
|
|
94
|
+
// Send welcome email on signup
|
|
95
|
+
onSignUp: ({ id, email }) =>
|
|
96
|
+
waitUntil(
|
|
97
|
+
(async () => {
|
|
98
|
+
const link = await getVerificationLink(id);
|
|
99
|
+
if (link && email) {
|
|
100
|
+
await sendTemplateEmail(email, "WelcomeUser", {
|
|
101
|
+
verifyLink: link,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
})(),
|
|
105
|
+
),
|
|
106
|
+
|
|
107
|
+
// Clean up push subscriptions on logout
|
|
108
|
+
onLogout: async (endpoint: string) => {
|
|
109
|
+
await db
|
|
110
|
+
.delete(WebPushSubscriptionTable)
|
|
111
|
+
.where(eq(WebPushSubscriptionTable.endpoint, endpoint));
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
62
116
|
#### Options
|
|
63
117
|
|
|
64
118
|
| Option | Type | Required | Description |
|
|
@@ -93,20 +147,54 @@ interface UserOutput {
|
|
|
93
147
|
|
|
94
148
|
### Google Authentication
|
|
95
149
|
|
|
150
|
+
**Real-World Example:**
|
|
151
|
+
|
|
96
152
|
```typescript
|
|
97
|
-
|
|
153
|
+
import { db } from "@/lib/db";
|
|
154
|
+
import { UserTable } from "@/lib/db/schema";
|
|
155
|
+
import { eq } from "drizzle-orm";
|
|
156
|
+
import { initGoogleAuth } from "naystack/auth";
|
|
157
|
+
|
|
158
|
+
export const { GET } = initGoogleAuth({
|
|
98
159
|
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
99
160
|
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
100
|
-
authRoute:
|
|
161
|
+
authRoute: `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/google`,
|
|
101
162
|
successRedirectURL: "/dashboard",
|
|
102
163
|
errorRedirectURL: "/login?error=google",
|
|
103
|
-
refreshKey: process.env.
|
|
104
|
-
|
|
105
|
-
|
|
164
|
+
refreshKey: process.env.REFRESH_KEY!,
|
|
165
|
+
|
|
166
|
+
// Find existing user or create new one
|
|
167
|
+
getUserIdFromEmail: async ({ email, name }) => {
|
|
168
|
+
if (!email) return null;
|
|
169
|
+
|
|
170
|
+
// Update existing user's email verification status
|
|
171
|
+
const [existingUser] = await db
|
|
172
|
+
.update(UserTable)
|
|
173
|
+
.set({ emailVerified: true })
|
|
174
|
+
.where(eq(UserTable.email, email))
|
|
175
|
+
.returning({ id: UserTable.id });
|
|
176
|
+
|
|
177
|
+
if (existingUser) {
|
|
178
|
+
return existingUser.id;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Create new user if doesn't exist
|
|
182
|
+
if (name && email) {
|
|
183
|
+
const [newUser] = await db
|
|
184
|
+
.insert(UserTable)
|
|
185
|
+
.values({
|
|
186
|
+
email,
|
|
187
|
+
name,
|
|
188
|
+
emailVerified: true,
|
|
189
|
+
})
|
|
190
|
+
.returning({ id: UserTable.id });
|
|
191
|
+
|
|
192
|
+
return newUser?.id || null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return null;
|
|
106
196
|
},
|
|
107
197
|
});
|
|
108
|
-
|
|
109
|
-
export const { GET } = googleAuth;
|
|
110
198
|
```
|
|
111
199
|
|
|
112
200
|
#### Options
|
|
@@ -125,20 +213,65 @@ export const { GET } = googleAuth;
|
|
|
125
213
|
|
|
126
214
|
### Instagram Authentication
|
|
127
215
|
|
|
216
|
+
**Real-World Example:**
|
|
217
|
+
|
|
128
218
|
```typescript
|
|
129
|
-
|
|
130
|
-
|
|
219
|
+
import { db } from "@/lib/db";
|
|
220
|
+
import { InstagramDetails, UserTable } from "@/lib/db/schema";
|
|
221
|
+
import { and, eq, ne } from "drizzle-orm";
|
|
222
|
+
import { initInstagramAuth } from "naystack/auth";
|
|
223
|
+
|
|
224
|
+
export const { GET, getRefreshedAccessToken } = initInstagramAuth({
|
|
225
|
+
clientId: process.env.NEXT_PUBLIC_INSTAGRAM_CLIENT_ID!,
|
|
131
226
|
clientSecret: process.env.INSTAGRAM_CLIENT_SECRET!,
|
|
132
|
-
authRoute:
|
|
133
|
-
successRedirectURL: "/
|
|
134
|
-
errorRedirectURL: "/
|
|
135
|
-
refreshKey: process.env.
|
|
136
|
-
|
|
137
|
-
|
|
227
|
+
authRoute: `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/instagram`,
|
|
228
|
+
successRedirectURL: "/profile",
|
|
229
|
+
errorRedirectURL: "/signup",
|
|
230
|
+
refreshKey: process.env.REFRESH_KEY!,
|
|
231
|
+
|
|
232
|
+
// Verify and link Instagram account
|
|
233
|
+
onUser: async (instagramData, userId, accessToken) => {
|
|
234
|
+
if (!userId) return "You are not logged in";
|
|
235
|
+
|
|
236
|
+
const user = await db.query.UserTable.findFirst({
|
|
237
|
+
where: eq(UserTable.id, userId),
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (!user?.instagramDetails) {
|
|
241
|
+
return "Please connect Instagram first";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Update Instagram verification status
|
|
245
|
+
const [updated] = await db
|
|
246
|
+
.update(InstagramDetails)
|
|
247
|
+
.set({
|
|
248
|
+
isVerified: true,
|
|
249
|
+
accessToken: accessToken
|
|
250
|
+
})
|
|
251
|
+
.where(
|
|
252
|
+
and(
|
|
253
|
+
eq(InstagramDetails.id, user.instagramDetails),
|
|
254
|
+
eq(InstagramDetails.username, instagramData.username),
|
|
255
|
+
),
|
|
256
|
+
)
|
|
257
|
+
.returning({ username: InstagramDetails.username });
|
|
258
|
+
|
|
259
|
+
if (!updated) {
|
|
260
|
+
return "Please login with the same username as the connected account";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Disconnect from other users if linked
|
|
264
|
+
await db
|
|
265
|
+
.update(UserTable)
|
|
266
|
+
.set({ instagramDetails: null })
|
|
267
|
+
.where(
|
|
268
|
+
and(
|
|
269
|
+
ne(UserTable.id, userId),
|
|
270
|
+
eq(UserTable.instagramDetails, user.instagramDetails),
|
|
271
|
+
),
|
|
272
|
+
);
|
|
138
273
|
},
|
|
139
274
|
});
|
|
140
|
-
|
|
141
|
-
export const { GET, getRefreshedAccessToken } = instagramAuth;
|
|
142
275
|
```
|
|
143
276
|
|
|
144
277
|
#### Options
|
|
@@ -171,6 +304,8 @@ import type { Context, AuthorizedContext } from "naystack/graphql";
|
|
|
171
304
|
|
|
172
305
|
### Initialize GraphQL Server
|
|
173
306
|
|
|
307
|
+
**Basic Example:**
|
|
308
|
+
|
|
174
309
|
```typescript
|
|
175
310
|
const { GET, POST } = await initGraphQLServer({
|
|
176
311
|
resolvers: [UserResolver, PostResolver],
|
|
@@ -185,6 +320,43 @@ const { GET, POST } = await initGraphQLServer({
|
|
|
185
320
|
export { GET, POST };
|
|
186
321
|
```
|
|
187
322
|
|
|
323
|
+
**Real-World Example with Advanced Context:**
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
import { initGraphQLServer } from "naystack/graphql";
|
|
327
|
+
import { getUserIdFromRequest } from "@/api/(auth)/email/setup";
|
|
328
|
+
import { authChecker } from "@/lib/auth/context";
|
|
329
|
+
|
|
330
|
+
export const { GET, POST } = await initGraphQLServer({
|
|
331
|
+
resolvers: [
|
|
332
|
+
UserResolver,
|
|
333
|
+
PostResolver,
|
|
334
|
+
ApplicationResolver,
|
|
335
|
+
ChatResolver,
|
|
336
|
+
// ... more resolvers
|
|
337
|
+
],
|
|
338
|
+
authChecker,
|
|
339
|
+
context: async (req) => {
|
|
340
|
+
const res = getUserIdFromRequest(req);
|
|
341
|
+
if (!res) return { userId: null };
|
|
342
|
+
|
|
343
|
+
// Handle refresh token user ID
|
|
344
|
+
if (res.refreshUserID) {
|
|
345
|
+
const isMobile = req.headers.get("x-platform-is-mobile");
|
|
346
|
+
if (isMobile) return { userId: null };
|
|
347
|
+
return { userId: res.refreshUserID, onlyQuery: true };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Handle access token user ID
|
|
351
|
+
if (res.accessUserId) {
|
|
352
|
+
return { userId: res.accessUserId };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return { userId: null };
|
|
356
|
+
},
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
188
360
|
#### Options
|
|
189
361
|
|
|
190
362
|
| Option | Type | Required | Description |
|
|
@@ -196,6 +368,8 @@ export { GET, POST };
|
|
|
196
368
|
|
|
197
369
|
### Error Handling
|
|
198
370
|
|
|
371
|
+
**Basic Usage:**
|
|
372
|
+
|
|
199
373
|
```typescript
|
|
200
374
|
import { GQLError } from "naystack/graphql";
|
|
201
375
|
|
|
@@ -206,10 +380,64 @@ throw GQLError(400); // "Please provide all required inputs"
|
|
|
206
380
|
throw GQLError(); // "Server Error" (500)
|
|
207
381
|
```
|
|
208
382
|
|
|
383
|
+
**Real-World Example in Resolvers:**
|
|
384
|
+
|
|
385
|
+
```typescript
|
|
386
|
+
import { GQLError } from "naystack/graphql";
|
|
387
|
+
import { db } from "@/lib/db";
|
|
388
|
+
import { UserTable, PostingTable } from "@/lib/db/schema";
|
|
389
|
+
import { eq } from "drizzle-orm";
|
|
390
|
+
|
|
391
|
+
export async function createPosting(
|
|
392
|
+
ctx: Context,
|
|
393
|
+
input: NewPostingInput,
|
|
394
|
+
): Promise<number | null> {
|
|
395
|
+
// Authentication check
|
|
396
|
+
if (!ctx.userId) {
|
|
397
|
+
throw GQLError(400, "Please login to create posting");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const user = await db.query.UserTable.findFirst({
|
|
401
|
+
where: eq(UserTable.id, ctx.userId),
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Authorization check
|
|
405
|
+
if (!user || user.role === "CREATOR") {
|
|
406
|
+
throw GQLError(400, "Only onboarded users can create postings");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Validation check
|
|
410
|
+
if (!user.emailVerified) {
|
|
411
|
+
throw GQLError(400, "Please verify email to create posting");
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Rate limiting check
|
|
415
|
+
const yesterday = new Date();
|
|
416
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
417
|
+
const recentPostings = await db.query.PostingTable.findMany({
|
|
418
|
+
where: and(
|
|
419
|
+
eq(PostingTable.userId, ctx.userId),
|
|
420
|
+
gte(PostingTable.createdAt, yesterday),
|
|
421
|
+
),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
if (recentPostings.length >= MAXIMUM_POSTINGS_DAY) {
|
|
425
|
+
throw GQLError(
|
|
426
|
+
400,
|
|
427
|
+
`Only ${MAXIMUM_POSTINGS_DAY} allowed in 24 hours. Try again later.`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Create posting...
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
209
435
|
### Query & Field Helpers
|
|
210
436
|
|
|
211
437
|
Build resolvers functionally using `query`, `field`, `QueryLibrary`, and `FieldLibrary`:
|
|
212
438
|
|
|
439
|
+
**Basic Example:**
|
|
440
|
+
|
|
213
441
|
```typescript
|
|
214
442
|
import { query, QueryLibrary, field, FieldLibrary } from "naystack/graphql";
|
|
215
443
|
|
|
@@ -255,6 +483,62 @@ const fields = {
|
|
|
255
483
|
const UserFieldResolver = FieldLibrary(User, fields);
|
|
256
484
|
```
|
|
257
485
|
|
|
486
|
+
**Real-World Example:**
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
import { query, QueryLibrary, field, FieldLibrary } from "naystack/graphql";
|
|
490
|
+
import { db } from "@/lib/db";
|
|
491
|
+
import { NotificationTable } from "@/lib/db/schema";
|
|
492
|
+
import { eq, lte } from "drizzle-orm";
|
|
493
|
+
import { waitUntil } from "@vercel/functions";
|
|
494
|
+
|
|
495
|
+
// Query with side effects
|
|
496
|
+
export const getNotifications = query(
|
|
497
|
+
async (ctx) => {
|
|
498
|
+
if (!ctx.userId) return [];
|
|
499
|
+
|
|
500
|
+
// Mark all as read
|
|
501
|
+
const notifications = await db
|
|
502
|
+
.update(NotificationTable)
|
|
503
|
+
.set({ read: true })
|
|
504
|
+
.where(eq(NotificationTable.user, ctx.userId))
|
|
505
|
+
.returning();
|
|
506
|
+
|
|
507
|
+
// Clean up old notifications (async)
|
|
508
|
+
const weekBefore = new Date();
|
|
509
|
+
weekBefore.setDate(weekBefore.getDate() - 7);
|
|
510
|
+
waitUntil(
|
|
511
|
+
db
|
|
512
|
+
.delete(NotificationTable)
|
|
513
|
+
.where(lte(NotificationTable.createdAt, weekBefore)),
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
return notifications.sort((a, b) => a.id - b.id);
|
|
517
|
+
},
|
|
518
|
+
{
|
|
519
|
+
output: [NotificationGQL!],
|
|
520
|
+
},
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
// Create resolver from queries
|
|
524
|
+
export const NotificationResolver = QueryLibrary({
|
|
525
|
+
getNotifications,
|
|
526
|
+
getUnreadNotifications,
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// Field resolver example
|
|
530
|
+
export const UserFields = FieldLibrary(UserGQL, {
|
|
531
|
+
isOnboarded: field(
|
|
532
|
+
async (user) => {
|
|
533
|
+
return getIsOnboarded(user);
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
output: Boolean,
|
|
537
|
+
}
|
|
538
|
+
),
|
|
539
|
+
});
|
|
540
|
+
```
|
|
541
|
+
|
|
258
542
|
### Types
|
|
259
543
|
|
|
260
544
|
```typescript
|
|
@@ -285,7 +569,9 @@ import {
|
|
|
285
569
|
|
|
286
570
|
#### `useVisibility`
|
|
287
571
|
|
|
288
|
-
Observe element visibility using Intersection Observer.
|
|
572
|
+
Observe element visibility using Intersection Observer. Perfect for infinite scroll and lazy loading.
|
|
573
|
+
|
|
574
|
+
**Basic Example:**
|
|
289
575
|
|
|
290
576
|
```typescript
|
|
291
577
|
function Component() {
|
|
@@ -297,9 +583,38 @@ function Component() {
|
|
|
297
583
|
}
|
|
298
584
|
```
|
|
299
585
|
|
|
586
|
+
**Real-World Example - Infinite Scroll:**
|
|
587
|
+
|
|
588
|
+
```typescript
|
|
589
|
+
"use client";
|
|
590
|
+
|
|
591
|
+
import { useVisibility } from "naystack/client";
|
|
592
|
+
import { useRef } from "react";
|
|
593
|
+
|
|
594
|
+
export default function PostingCard({
|
|
595
|
+
posting,
|
|
596
|
+
fetchMore
|
|
597
|
+
}: {
|
|
598
|
+
posting: Posting;
|
|
599
|
+
fetchMore?: () => void;
|
|
600
|
+
}) {
|
|
601
|
+
// Trigger fetchMore when card becomes visible
|
|
602
|
+
const mainRef = useVisibility(fetchMore);
|
|
603
|
+
|
|
604
|
+
return (
|
|
605
|
+
<div ref={mainRef} className="posting-card">
|
|
606
|
+
<h3>{posting.title}</h3>
|
|
607
|
+
<p>{posting.description}</p>
|
|
608
|
+
</div>
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
```
|
|
612
|
+
|
|
300
613
|
#### `useBreakpoint`
|
|
301
614
|
|
|
302
|
-
React to media query changes.
|
|
615
|
+
React to media query changes. Useful for responsive layouts and conditional rendering.
|
|
616
|
+
|
|
617
|
+
**Basic Example:**
|
|
303
618
|
|
|
304
619
|
```typescript
|
|
305
620
|
function Component() {
|
|
@@ -309,6 +624,35 @@ function Component() {
|
|
|
309
624
|
}
|
|
310
625
|
```
|
|
311
626
|
|
|
627
|
+
**Real-World Example - Responsive Layout:**
|
|
628
|
+
|
|
629
|
+
```typescript
|
|
630
|
+
"use client";
|
|
631
|
+
|
|
632
|
+
import { useBreakpoint } from "naystack/client";
|
|
633
|
+
|
|
634
|
+
export default function LayoutWrapper({
|
|
635
|
+
children
|
|
636
|
+
}: {
|
|
637
|
+
children: React.ReactNode;
|
|
638
|
+
}) {
|
|
639
|
+
const isLarge = useBreakpoint("(min-width: 1024px)");
|
|
640
|
+
|
|
641
|
+
return (
|
|
642
|
+
<div
|
|
643
|
+
style={{
|
|
644
|
+
height: isLarge
|
|
645
|
+
? "calc(100svh - 80px)"
|
|
646
|
+
: "calc(100svh - 55px)",
|
|
647
|
+
}}
|
|
648
|
+
className="flex flex-col"
|
|
649
|
+
>
|
|
650
|
+
{children}
|
|
651
|
+
</div>
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
312
656
|
### SEO Helper
|
|
313
657
|
|
|
314
658
|
```typescript
|
|
@@ -329,14 +673,28 @@ export const metadata = getSEO(
|
|
|
329
673
|
|
|
330
674
|
### Instagram Authorization URL
|
|
331
675
|
|
|
676
|
+
Generate Instagram OAuth authorization URLs for client-side redirects.
|
|
677
|
+
|
|
678
|
+
**Example:**
|
|
679
|
+
|
|
332
680
|
```typescript
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
681
|
+
import { getInstagramAuthorizationURLSetup } from "naystack/client";
|
|
682
|
+
|
|
683
|
+
// Setup once
|
|
684
|
+
const getInstagramAuthorizationURL = getInstagramAuthorizationURLSetup(
|
|
685
|
+
process.env.NEXT_PUBLIC_INSTAGRAM_CLIENT_ID!,
|
|
686
|
+
`${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/instagram`,
|
|
336
687
|
);
|
|
337
688
|
|
|
338
|
-
// Usage
|
|
339
|
-
|
|
689
|
+
// Usage in component
|
|
690
|
+
function ConnectInstagramButton() {
|
|
691
|
+
const handleConnect = () => {
|
|
692
|
+
const authURL = getInstagramAuthorizationURL(userToken);
|
|
693
|
+
window.location.href = authURL;
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
return <button onClick={handleConnect}>Connect Instagram</button>;
|
|
697
|
+
}
|
|
340
698
|
```
|
|
341
699
|
|
|
342
700
|
### Image Upload Client
|
|
@@ -363,6 +721,8 @@ import { setupFileUpload } from "naystack/file";
|
|
|
363
721
|
|
|
364
722
|
### Setup File Upload
|
|
365
723
|
|
|
724
|
+
**Basic Example:**
|
|
725
|
+
|
|
366
726
|
```typescript
|
|
367
727
|
const fileUpload = setupFileUpload({
|
|
368
728
|
refreshKey: process.env.JWT_REFRESH_KEY!,
|
|
@@ -388,6 +748,113 @@ const { getUploadFileURL, uploadImage, deleteImage, getFileURL, uploadFile } =
|
|
|
388
748
|
fileUpload;
|
|
389
749
|
```
|
|
390
750
|
|
|
751
|
+
**Real-World Example with Multiple File Types:**
|
|
752
|
+
|
|
753
|
+
```typescript
|
|
754
|
+
import { setupFileUpload } from "naystack/file";
|
|
755
|
+
import { db } from "@/lib/db";
|
|
756
|
+
import { PortfolioTable, UserTable } from "@/lib/db/schema";
|
|
757
|
+
import { and, eq } from "drizzle-orm";
|
|
758
|
+
import { waitUntil } from "@vercel/functions";
|
|
759
|
+
|
|
760
|
+
export const { deleteImage, getUploadFileURL, getFileURL, uploadImage, PUT } =
|
|
761
|
+
setupFileUpload({
|
|
762
|
+
region: process.env.SITE_AWS_REGION!,
|
|
763
|
+
refreshKey: process.env.REFRESH_KEY!,
|
|
764
|
+
awsSecret: process.env.SITE_AWS_SECRET_ACCESS_KEY!,
|
|
765
|
+
awsKey: process.env.SITE_AWS_ACCESS_KEY_ID!,
|
|
766
|
+
signingKey: process.env.SIGNING_KEY!,
|
|
767
|
+
bucket: process.env.SITE_AWS_BUCKET!,
|
|
768
|
+
|
|
769
|
+
processFile: async ({ url, userId, data, type }) => {
|
|
770
|
+
switch (type) {
|
|
771
|
+
case "PORTFOLIO":
|
|
772
|
+
// Handle portfolio image upload
|
|
773
|
+
waitUntil(
|
|
774
|
+
(async () => {
|
|
775
|
+
if (!url) return {};
|
|
776
|
+
const id = (data as { id?: number }).id;
|
|
777
|
+
|
|
778
|
+
if (id) {
|
|
779
|
+
// Update existing portfolio
|
|
780
|
+
const [existing] = await db
|
|
781
|
+
.select()
|
|
782
|
+
.from(PortfolioTable)
|
|
783
|
+
.where(
|
|
784
|
+
and(
|
|
785
|
+
eq(PortfolioTable.id, id),
|
|
786
|
+
eq(PortfolioTable.user, userId),
|
|
787
|
+
),
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
if (!existing) {
|
|
791
|
+
return { deleteURL: url };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const oldURL = existing.imageURL;
|
|
795
|
+
await db
|
|
796
|
+
.update(PortfolioTable)
|
|
797
|
+
.set({ imageURL: url })
|
|
798
|
+
.where(
|
|
799
|
+
and(
|
|
800
|
+
eq(PortfolioTable.id, id),
|
|
801
|
+
eq(PortfolioTable.user, userId),
|
|
802
|
+
),
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
return {
|
|
806
|
+
deleteURL: oldURL,
|
|
807
|
+
data: { id },
|
|
808
|
+
};
|
|
809
|
+
} else {
|
|
810
|
+
// Create new portfolio
|
|
811
|
+
const [portfolio] = await db
|
|
812
|
+
.insert(PortfolioTable)
|
|
813
|
+
.values({
|
|
814
|
+
user: userId,
|
|
815
|
+
imageURL: url,
|
|
816
|
+
caption: "",
|
|
817
|
+
link: "",
|
|
818
|
+
})
|
|
819
|
+
.returning({ id: PortfolioTable.id });
|
|
820
|
+
|
|
821
|
+
return { data: { id: portfolio?.id } };
|
|
822
|
+
}
|
|
823
|
+
})(),
|
|
824
|
+
);
|
|
825
|
+
break;
|
|
826
|
+
|
|
827
|
+
case "PROFILE_PICTURE":
|
|
828
|
+
// Handle profile picture upload
|
|
829
|
+
waitUntil(
|
|
830
|
+
(async () => {
|
|
831
|
+
const user = await db.query.UserTable.findFirst({
|
|
832
|
+
where: eq(UserTable.id, userId),
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
if (!user && url) {
|
|
836
|
+
return { deleteURL: url };
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const oldPhoto = user?.photo;
|
|
840
|
+
await db
|
|
841
|
+
.update(UserTable)
|
|
842
|
+
.set({ photo: url })
|
|
843
|
+
.where(eq(UserTable.id, userId));
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
deleteURL: oldPhoto || undefined,
|
|
847
|
+
};
|
|
848
|
+
})(),
|
|
849
|
+
);
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
return {};
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
```
|
|
857
|
+
|
|
391
858
|
#### Options
|
|
392
859
|
|
|
393
860
|
| Option | Type | Required | Description |
|
|
@@ -452,6 +919,8 @@ const user = await getInstagramUser(accessToken, "me", [
|
|
|
452
919
|
|
|
453
920
|
#### Get Media
|
|
454
921
|
|
|
922
|
+
**Basic Usage:**
|
|
923
|
+
|
|
455
924
|
```typescript
|
|
456
925
|
const media = await getInstagramMedia(accessToken);
|
|
457
926
|
const media = await getInstagramMedia(
|
|
@@ -461,6 +930,60 @@ const media = await getInstagramMedia(
|
|
|
461
930
|
);
|
|
462
931
|
```
|
|
463
932
|
|
|
933
|
+
**Real-World Example - Fetching Media with Custom Fields:**
|
|
934
|
+
|
|
935
|
+
```typescript
|
|
936
|
+
import { getInstagramMedia } from "naystack/socials";
|
|
937
|
+
|
|
938
|
+
export async function fetchInstagramGraphMedia(
|
|
939
|
+
accessToken: string,
|
|
940
|
+
followers: number,
|
|
941
|
+
userId: number,
|
|
942
|
+
) {
|
|
943
|
+
const fetchReq = await getInstagramMedia<{
|
|
944
|
+
thumbnail_url?: string;
|
|
945
|
+
id: string;
|
|
946
|
+
like_count?: number;
|
|
947
|
+
comments_count: number;
|
|
948
|
+
permalink: string;
|
|
949
|
+
caption: string;
|
|
950
|
+
media_url?: string;
|
|
951
|
+
media_type?: string;
|
|
952
|
+
timestamp: string;
|
|
953
|
+
}>(accessToken, [
|
|
954
|
+
"id",
|
|
955
|
+
"thumbnail_url",
|
|
956
|
+
"media_url",
|
|
957
|
+
"like_count",
|
|
958
|
+
"comments_count",
|
|
959
|
+
"media_type",
|
|
960
|
+
"permalink",
|
|
961
|
+
"caption",
|
|
962
|
+
"timestamp",
|
|
963
|
+
]);
|
|
964
|
+
|
|
965
|
+
if (fetchReq?.data) {
|
|
966
|
+
return fetchReq.data.map((media) => ({
|
|
967
|
+
isVideo: media.media_type === "VIDEO",
|
|
968
|
+
comments: media.comments_count || -1,
|
|
969
|
+
likes: media.like_count || 0,
|
|
970
|
+
link: media.permalink,
|
|
971
|
+
thumbnail: media.thumbnail_url || media.media_url,
|
|
972
|
+
mediaURL: media.media_url,
|
|
973
|
+
timestamp: media.timestamp,
|
|
974
|
+
caption: media.caption,
|
|
975
|
+
appID: media.id,
|
|
976
|
+
user: userId,
|
|
977
|
+
er: calculateEngagementRate(
|
|
978
|
+
followers,
|
|
979
|
+
media.like_count || 0,
|
|
980
|
+
media.comments_count || -1,
|
|
981
|
+
),
|
|
982
|
+
}));
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
```
|
|
986
|
+
|
|
464
987
|
#### Conversations
|
|
465
988
|
|
|
466
989
|
```typescript
|
|
@@ -495,6 +1018,8 @@ const result = await sendInstagramMessage(accessToken, recipientId, "Hello!");
|
|
|
495
1018
|
|
|
496
1019
|
#### Webhook
|
|
497
1020
|
|
|
1021
|
+
**Basic Example:**
|
|
1022
|
+
|
|
498
1023
|
```typescript
|
|
499
1024
|
const instagramWebhook = setupInstagramWebhook({
|
|
500
1025
|
secret: process.env.INSTAGRAM_WEBHOOK_SECRET!,
|
|
@@ -506,6 +1031,59 @@ const instagramWebhook = setupInstagramWebhook({
|
|
|
506
1031
|
export const { GET, POST } = instagramWebhook;
|
|
507
1032
|
```
|
|
508
1033
|
|
|
1034
|
+
**Real-World Example - Auto-Reply Bot:**
|
|
1035
|
+
|
|
1036
|
+
```typescript
|
|
1037
|
+
import {
|
|
1038
|
+
getInstagramConversationByUser,
|
|
1039
|
+
sendInstagramMessage,
|
|
1040
|
+
setupInstagramWebhook,
|
|
1041
|
+
} from "naystack/socials";
|
|
1042
|
+
|
|
1043
|
+
export const { GET, POST } = setupInstagramWebhook({
|
|
1044
|
+
secret: process.env.REFRESH_KEY!,
|
|
1045
|
+
callback: async (
|
|
1046
|
+
type,
|
|
1047
|
+
value: {
|
|
1048
|
+
sender: { id: string };
|
|
1049
|
+
message: { text: string };
|
|
1050
|
+
recipient: { id: string };
|
|
1051
|
+
},
|
|
1052
|
+
) => {
|
|
1053
|
+
if (
|
|
1054
|
+
type === "messaging" &&
|
|
1055
|
+
value.message.text &&
|
|
1056
|
+
value.sender.id !== "YOUR_PAGE_ID" &&
|
|
1057
|
+
value.recipient.id === "YOUR_PAGE_ID"
|
|
1058
|
+
) {
|
|
1059
|
+
// Check if message is recent (within 24 hours)
|
|
1060
|
+
const conversation = await getInstagramConversationByUser(
|
|
1061
|
+
process.env.INSTAGRAM_ACCESS_TOKEN!,
|
|
1062
|
+
value.sender.id,
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
const lastMessage = conversation?.messages?.data[1]?.created_time;
|
|
1066
|
+
if (lastMessage) {
|
|
1067
|
+
const lastMessageDate = new Date(lastMessage);
|
|
1068
|
+
if (lastMessageDate.getTime() > Date.now() - 1000 * 60 * 60 * 24) {
|
|
1069
|
+
return; // Already replied recently
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// Generate reply (using your AI/LLM service)
|
|
1074
|
+
const reply = await generateReply(value.message.text);
|
|
1075
|
+
if (reply && reply !== '""') {
|
|
1076
|
+
await sendInstagramMessage(
|
|
1077
|
+
process.env.INSTAGRAM_ACCESS_TOKEN!,
|
|
1078
|
+
value.sender.id,
|
|
1079
|
+
reply,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
},
|
|
1084
|
+
});
|
|
1085
|
+
```
|
|
1086
|
+
|
|
509
1087
|
---
|
|
510
1088
|
|
|
511
1089
|
### Threads API
|
|
@@ -547,6 +1125,8 @@ const firstPostId = await createThread(accessToken, [
|
|
|
547
1125
|
|
|
548
1126
|
#### Webhook
|
|
549
1127
|
|
|
1128
|
+
**Basic Example:**
|
|
1129
|
+
|
|
550
1130
|
```typescript
|
|
551
1131
|
const threadsWebhook = setupThreadsWebhook({
|
|
552
1132
|
secret: process.env.THREADS_WEBHOOK_SECRET!,
|
|
@@ -559,6 +1139,144 @@ const threadsWebhook = setupThreadsWebhook({
|
|
|
559
1139
|
export const { GET, POST } = threadsWebhook;
|
|
560
1140
|
```
|
|
561
1141
|
|
|
1142
|
+
**Real-World Example - Auto-Reply to Threads:**
|
|
1143
|
+
|
|
1144
|
+
```typescript
|
|
1145
|
+
import {
|
|
1146
|
+
createThreadsPost,
|
|
1147
|
+
getThreadsReplies,
|
|
1148
|
+
setupThreadsWebhook,
|
|
1149
|
+
} from "naystack/socials";
|
|
1150
|
+
import { db } from "@/lib/db";
|
|
1151
|
+
import { SocialPostsTable } from "@/lib/db/schema";
|
|
1152
|
+
import { eq } from "drizzle-orm";
|
|
1153
|
+
|
|
1154
|
+
const REPLIES = [
|
|
1155
|
+
"Thanks for your interest! Check out campaign ${ID}",
|
|
1156
|
+
"We'd love to work with you! See campaign ${ID}",
|
|
1157
|
+
];
|
|
1158
|
+
|
|
1159
|
+
export const { GET, POST } = setupThreadsWebhook({
|
|
1160
|
+
secret: process.env.REFRESH_KEY!,
|
|
1161
|
+
callback: async (
|
|
1162
|
+
field,
|
|
1163
|
+
value: {
|
|
1164
|
+
id: string;
|
|
1165
|
+
username: string;
|
|
1166
|
+
text: string;
|
|
1167
|
+
replied_to: { id: string };
|
|
1168
|
+
root_post: { id: string; username: string };
|
|
1169
|
+
},
|
|
1170
|
+
) => {
|
|
1171
|
+
// Skip if replying to own post
|
|
1172
|
+
if (value.root_post.username === value.username) return true;
|
|
1173
|
+
|
|
1174
|
+
// Only reply to direct replies to root post
|
|
1175
|
+
if (value.root_post.id !== value.replied_to.id) return true;
|
|
1176
|
+
|
|
1177
|
+
// Check if already replied
|
|
1178
|
+
const replies = await getThreadsReplies(
|
|
1179
|
+
process.env.THREADS_ACCESS_TOKEN!,
|
|
1180
|
+
value.id,
|
|
1181
|
+
);
|
|
1182
|
+
if (replies?.length ?? 0 > 0) return true;
|
|
1183
|
+
|
|
1184
|
+
// Find associated campaign
|
|
1185
|
+
const [post] = await db
|
|
1186
|
+
.select()
|
|
1187
|
+
.from(SocialPostsTable)
|
|
1188
|
+
.where(eq(SocialPostsTable.postID, value.root_post.id));
|
|
1189
|
+
|
|
1190
|
+
if (!post) return true;
|
|
1191
|
+
|
|
1192
|
+
// Generate reply message
|
|
1193
|
+
const message = REPLIES[
|
|
1194
|
+
Math.floor(Math.random() * REPLIES.length)
|
|
1195
|
+
]?.replace("${ID}", post.campaignID.toString());
|
|
1196
|
+
|
|
1197
|
+
if (!message) return true;
|
|
1198
|
+
|
|
1199
|
+
// Send reply
|
|
1200
|
+
const res = await createThreadsPost(
|
|
1201
|
+
process.env.THREADS_ACCESS_TOKEN!,
|
|
1202
|
+
message,
|
|
1203
|
+
value.id,
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
return !!res;
|
|
1207
|
+
},
|
|
1208
|
+
});
|
|
1209
|
+
```
|
|
1210
|
+
|
|
1211
|
+
---
|
|
1212
|
+
|
|
1213
|
+
## Best Practices & Common Patterns
|
|
1214
|
+
|
|
1215
|
+
### Authentication Flow
|
|
1216
|
+
|
|
1217
|
+
1. **Email Auth with Database Integration:**
|
|
1218
|
+
- Use Drizzle ORM queries in `getUser` and `createUser`
|
|
1219
|
+
- Send verification emails in `onSignUp` callback
|
|
1220
|
+
- Clean up session data in `onLogout`
|
|
1221
|
+
|
|
1222
|
+
2. **OAuth Integration:**
|
|
1223
|
+
- Always verify email/username matches existing accounts
|
|
1224
|
+
- Create new users only when necessary
|
|
1225
|
+
- Update verification status for existing users
|
|
1226
|
+
|
|
1227
|
+
### GraphQL Resolver Patterns
|
|
1228
|
+
|
|
1229
|
+
1. **Error Handling:**
|
|
1230
|
+
- Use `GQLError` for all error cases
|
|
1231
|
+
- Provide clear, user-friendly error messages
|
|
1232
|
+
- Use appropriate HTTP status codes (400, 403, 404, 500)
|
|
1233
|
+
|
|
1234
|
+
2. **Authorization:**
|
|
1235
|
+
- Check authentication first (`if (!ctx.userId)`)
|
|
1236
|
+
- Verify permissions before operations
|
|
1237
|
+
- Use `authChecker` for role-based access control
|
|
1238
|
+
|
|
1239
|
+
3. **Query Library Pattern:**
|
|
1240
|
+
- Use `query()` helper for simple queries/mutations
|
|
1241
|
+
- Use `QueryLibrary()` to combine multiple queries
|
|
1242
|
+
- Use `field()` and `FieldLibrary()` for computed fields
|
|
1243
|
+
|
|
1244
|
+
### File Upload Patterns
|
|
1245
|
+
|
|
1246
|
+
1. **Multiple File Types:**
|
|
1247
|
+
- Use `type` parameter to distinguish upload types
|
|
1248
|
+
- Handle each type in `processFile` callback
|
|
1249
|
+
- Return `deleteURL` for old files to clean up
|
|
1250
|
+
|
|
1251
|
+
2. **Async Processing:**
|
|
1252
|
+
- Use `waitUntil()` for non-blocking operations
|
|
1253
|
+
- Process file metadata in background
|
|
1254
|
+
- Update database after successful upload
|
|
1255
|
+
|
|
1256
|
+
### Webhook Patterns
|
|
1257
|
+
|
|
1258
|
+
1. **Instagram Webhooks:**
|
|
1259
|
+
- Verify sender/recipient IDs
|
|
1260
|
+
- Check message timestamps to avoid duplicates
|
|
1261
|
+
- Use async operations for replies
|
|
1262
|
+
|
|
1263
|
+
2. **Threads Webhooks:**
|
|
1264
|
+
- Filter by reply structure (root_post vs replied_to)
|
|
1265
|
+
- Check for existing replies before responding
|
|
1266
|
+
- Return `true`/`false` to indicate success/failure
|
|
1267
|
+
|
|
1268
|
+
### Client-Side Patterns
|
|
1269
|
+
|
|
1270
|
+
1. **Infinite Scroll:**
|
|
1271
|
+
- Use `useVisibility` hook with `fetchMore` callback
|
|
1272
|
+
- Attach ref to last item in list
|
|
1273
|
+
- Handle loading states
|
|
1274
|
+
|
|
1275
|
+
2. **Responsive Design:**
|
|
1276
|
+
- Use `useBreakpoint` for conditional rendering
|
|
1277
|
+
- Adjust layout based on screen size
|
|
1278
|
+
- Combine with CSS for optimal UX
|
|
1279
|
+
|
|
562
1280
|
---
|
|
563
1281
|
|
|
564
1282
|
## Environment Variables
|