naystack 1.1.16 → 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 +1318 -0
- 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 +10 -4
package/README.md
ADDED
|
@@ -0,0 +1,1318 @@
|
|
|
1
|
+
# Naystack
|
|
2
|
+
|
|
3
|
+
> A stack built with tight **Next.js + Drizzle ORM + GraphQL** integration
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/naystack)
|
|
6
|
+
[](https://opensource.org/licenses/ISC)
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install naystack
|
|
12
|
+
# or
|
|
13
|
+
pnpm add naystack
|
|
14
|
+
# or
|
|
15
|
+
yarn add naystack
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Modules
|
|
19
|
+
|
|
20
|
+
Naystack provides the following modules, each accessible via its own import path:
|
|
21
|
+
|
|
22
|
+
| Module | Import Path | Description |
|
|
23
|
+
| ----------- | ------------------ | ----------------------------------------------- |
|
|
24
|
+
| **Auth** | `naystack/auth` | Email, Google, and Instagram authentication |
|
|
25
|
+
| **GraphQL** | `naystack/graphql` | GraphQL server initialization with type-graphql |
|
|
26
|
+
| **Client** | `naystack/client` | Client-side hooks and utilities |
|
|
27
|
+
| **File** | `naystack/file` | File upload to AWS S3 |
|
|
28
|
+
| **Socials** | `naystack/socials` | Instagram and Threads API integration |
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Auth Module
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import {
|
|
36
|
+
getEmailAuthRoutes,
|
|
37
|
+
initGoogleAuth,
|
|
38
|
+
initInstagramAuth,
|
|
39
|
+
} from "naystack/auth";
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Email Authentication
|
|
43
|
+
|
|
44
|
+
Setup email-based authentication with JWT tokens and optional Turnstile captcha verification.
|
|
45
|
+
|
|
46
|
+
**Basic Example:**
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
const emailAuth = getEmailAuthRoutes({
|
|
50
|
+
getUser: async (email: string) => { /* fetch user by email */ },
|
|
51
|
+
createUser: async (user: UserInput) => { /* create new user */ },
|
|
52
|
+
signingKey: process.env.JWT_SIGNING_KEY!,
|
|
53
|
+
refreshKey: process.env.JWT_REFRESH_KEY!,
|
|
54
|
+
turnstileKey?: string, // Optional: Cloudflare Turnstile secret key
|
|
55
|
+
onSignUp: (user) => { /* callback on signup */ },
|
|
56
|
+
onLogout?: (body) => { /* callback on logout */ },
|
|
57
|
+
onError?: (error) => { /* custom error handler */ },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Export in Next.js route handler
|
|
61
|
+
export const { GET, POST, PUT, DELETE, getUserIdFromRequest } = emailAuth;
|
|
62
|
+
```
|
|
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
|
+
|
|
116
|
+
#### Options
|
|
117
|
+
|
|
118
|
+
| Option | Type | Required | Description |
|
|
119
|
+
| -------------- | ------------------------------------------------------- | -------- | ------------------------------- |
|
|
120
|
+
| `getUser` | `(email: string) => Promise<UserOutput \| undefined>` | ✅ | Fetch user by email |
|
|
121
|
+
| `createUser` | `(user: UserInput) => Promise<UserOutput \| undefined>` | ✅ | Create new user |
|
|
122
|
+
| `signingKey` | `string` | ✅ | JWT signing key |
|
|
123
|
+
| `refreshKey` | `string` | ✅ | JWT refresh key |
|
|
124
|
+
| `turnstileKey` | `string` | ❌ | Cloudflare Turnstile secret key |
|
|
125
|
+
| `onSignUp` | `(user: UserOutput) => void` | ✅ | Callback when user signs up |
|
|
126
|
+
| `onLogout` | `(body: string) => Promise<void>` | ❌ | Callback on logout |
|
|
127
|
+
| `onError` | `ErrorHandler` | ❌ | Custom error handler |
|
|
128
|
+
|
|
129
|
+
#### Types
|
|
130
|
+
|
|
131
|
+
```typescript
|
|
132
|
+
interface UserInput {
|
|
133
|
+
email: string;
|
|
134
|
+
password: string;
|
|
135
|
+
[key: string]: unknown;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface UserOutput {
|
|
139
|
+
id: number;
|
|
140
|
+
email: string;
|
|
141
|
+
password: string | null;
|
|
142
|
+
[key: string]: unknown;
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
### Google Authentication
|
|
149
|
+
|
|
150
|
+
**Real-World Example:**
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
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({
|
|
159
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
160
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
161
|
+
authRoute: `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/google`,
|
|
162
|
+
successRedirectURL: "/dashboard",
|
|
163
|
+
errorRedirectURL: "/login?error=google",
|
|
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;
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
#### Options
|
|
201
|
+
|
|
202
|
+
| Option | Type | Required | Description |
|
|
203
|
+
| -------------------- | ----------------------------------------------------- | -------- | --------------------------------- |
|
|
204
|
+
| `clientId` | `string` | ✅ | Google OAuth client ID |
|
|
205
|
+
| `clientSecret` | `string` | ✅ | Google OAuth client secret |
|
|
206
|
+
| `authRoute` | `string` | ✅ | OAuth callback route |
|
|
207
|
+
| `successRedirectURL` | `string` | ✅ | Redirect URL on success |
|
|
208
|
+
| `errorRedirectURL` | `string` | ✅ | Redirect URL on error |
|
|
209
|
+
| `refreshKey` | `string` | ✅ | JWT refresh key |
|
|
210
|
+
| `getUserIdFromEmail` | `(email: Schema$Userinfo) => Promise<number \| null>` | ✅ | Get user ID from Google user info |
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
### Instagram Authentication
|
|
215
|
+
|
|
216
|
+
**Real-World Example:**
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
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!,
|
|
226
|
+
clientSecret: process.env.INSTAGRAM_CLIENT_SECRET!,
|
|
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
|
+
);
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
#### Options
|
|
278
|
+
|
|
279
|
+
| Option | Type | Required | Description |
|
|
280
|
+
| -------------------- | ------------------------------------------------------------------------------------------- | -------- | --------------------------------- |
|
|
281
|
+
| `clientId` | `string` | ✅ | Instagram app client ID |
|
|
282
|
+
| `clientSecret` | `string` | ✅ | Instagram app client secret |
|
|
283
|
+
| `authRoute` | `string` | ✅ | OAuth callback route |
|
|
284
|
+
| `successRedirectURL` | `string` | ✅ | Redirect URL on success |
|
|
285
|
+
| `errorRedirectURL` | `string` | ✅ | Redirect URL on error |
|
|
286
|
+
| `refreshKey` | `string` | ✅ | JWT refresh key |
|
|
287
|
+
| `onUser` | `(data: InstagramUser, id: number \| null, accessToken: string) => Promise<string \| void>` | ✅ | Callback with Instagram user data |
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## GraphQL Module
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
import {
|
|
295
|
+
initGraphQLServer,
|
|
296
|
+
GQLError,
|
|
297
|
+
query,
|
|
298
|
+
field,
|
|
299
|
+
QueryLibrary,
|
|
300
|
+
FieldLibrary,
|
|
301
|
+
} from "naystack/graphql";
|
|
302
|
+
import type { Context, AuthorizedContext } from "naystack/graphql";
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### Initialize GraphQL Server
|
|
306
|
+
|
|
307
|
+
**Basic Example:**
|
|
308
|
+
|
|
309
|
+
```typescript
|
|
310
|
+
const { GET, POST } = await initGraphQLServer({
|
|
311
|
+
resolvers: [UserResolver, PostResolver],
|
|
312
|
+
authChecker: ({ context }) => !!context.userId,
|
|
313
|
+
plugins: [], // Optional Apollo plugins
|
|
314
|
+
context: async (req) => ({
|
|
315
|
+
// Custom context builder
|
|
316
|
+
userId: await getUserIdFromRequest(req),
|
|
317
|
+
}),
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
export { GET, POST };
|
|
321
|
+
```
|
|
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
|
+
|
|
360
|
+
#### Options
|
|
361
|
+
|
|
362
|
+
| Option | Type | Required | Description |
|
|
363
|
+
| ------------- | ------------------------------------ | -------- | -------------------------------- |
|
|
364
|
+
| `resolvers` | `NonEmptyArray<Function>` | ✅ | Array of TypeGraphQL resolvers |
|
|
365
|
+
| `authChecker` | `AuthChecker<any>` | ❌ | Custom auth checker function |
|
|
366
|
+
| `plugins` | `ApolloServerPlugin[]` | ❌ | Additional Apollo Server plugins |
|
|
367
|
+
| `context` | `(req: NextRequest) => Promise<any>` | ❌ | Context builder function |
|
|
368
|
+
|
|
369
|
+
### Error Handling
|
|
370
|
+
|
|
371
|
+
**Basic Usage:**
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
import { GQLError } from "naystack/graphql";
|
|
375
|
+
|
|
376
|
+
// Usage in resolvers
|
|
377
|
+
throw GQLError(404, "User not found");
|
|
378
|
+
throw GQLError(403); // "You are not allowed to perform this action"
|
|
379
|
+
throw GQLError(400); // "Please provide all required inputs"
|
|
380
|
+
throw GQLError(); // "Server Error" (500)
|
|
381
|
+
```
|
|
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
|
+
|
|
435
|
+
### Query & Field Helpers
|
|
436
|
+
|
|
437
|
+
Build resolvers functionally using `query`, `field`, `QueryLibrary`, and `FieldLibrary`:
|
|
438
|
+
|
|
439
|
+
**Basic Example:**
|
|
440
|
+
|
|
441
|
+
```typescript
|
|
442
|
+
import { query, QueryLibrary, field, FieldLibrary } from "naystack/graphql";
|
|
443
|
+
|
|
444
|
+
// Define queries/mutations
|
|
445
|
+
const queries = {
|
|
446
|
+
getUser: query(
|
|
447
|
+
async (ctx, input) => {
|
|
448
|
+
return await db.query.users.findFirst({ where: eq(users.id, input) });
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
output: User,
|
|
452
|
+
input: Number,
|
|
453
|
+
authorized: true,
|
|
454
|
+
}
|
|
455
|
+
),
|
|
456
|
+
createUser: query(
|
|
457
|
+
async (ctx, input) => {
|
|
458
|
+
/* ... */
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
output: User,
|
|
462
|
+
input: CreateUserInput,
|
|
463
|
+
mutation: true, // Makes this a mutation instead of query
|
|
464
|
+
}
|
|
465
|
+
),
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Generate resolver class
|
|
469
|
+
const UserResolver = QueryLibrary(queries);
|
|
470
|
+
|
|
471
|
+
// Define field resolvers
|
|
472
|
+
const fields = {
|
|
473
|
+
posts: field(
|
|
474
|
+
async (root, ctx) => {
|
|
475
|
+
return await db.query.posts.findMany({
|
|
476
|
+
where: eq(posts.userId, root.id),
|
|
477
|
+
});
|
|
478
|
+
},
|
|
479
|
+
{ output: [Post] }
|
|
480
|
+
),
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const UserFieldResolver = FieldLibrary(User, fields);
|
|
484
|
+
```
|
|
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
|
+
|
|
542
|
+
### Types
|
|
543
|
+
|
|
544
|
+
```typescript
|
|
545
|
+
interface Context {
|
|
546
|
+
userId: number | null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
interface AuthorizedContext {
|
|
550
|
+
userId: number;
|
|
551
|
+
}
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
---
|
|
555
|
+
|
|
556
|
+
## Client Module
|
|
557
|
+
|
|
558
|
+
```typescript
|
|
559
|
+
import {
|
|
560
|
+
useVisibility,
|
|
561
|
+
useBreakpoint,
|
|
562
|
+
setupSEO,
|
|
563
|
+
getHandleImageUpload,
|
|
564
|
+
getInstagramAuthorizationURLSetup,
|
|
565
|
+
} from "naystack/client";
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
### Hooks
|
|
569
|
+
|
|
570
|
+
#### `useVisibility`
|
|
571
|
+
|
|
572
|
+
Observe element visibility using Intersection Observer. Perfect for infinite scroll and lazy loading.
|
|
573
|
+
|
|
574
|
+
**Basic Example:**
|
|
575
|
+
|
|
576
|
+
```typescript
|
|
577
|
+
function Component() {
|
|
578
|
+
const ref = useVisibility(() => {
|
|
579
|
+
console.log("Element is visible!");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
return <div ref={ref}>Watch me!</div>;
|
|
583
|
+
}
|
|
584
|
+
```
|
|
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
|
+
|
|
613
|
+
#### `useBreakpoint`
|
|
614
|
+
|
|
615
|
+
React to media query changes. Useful for responsive layouts and conditional rendering.
|
|
616
|
+
|
|
617
|
+
**Basic Example:**
|
|
618
|
+
|
|
619
|
+
```typescript
|
|
620
|
+
function Component() {
|
|
621
|
+
const isMobile = useBreakpoint("(max-width: 768px)");
|
|
622
|
+
|
|
623
|
+
return <div>{isMobile ? "Mobile" : "Desktop"}</div>;
|
|
624
|
+
}
|
|
625
|
+
```
|
|
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
|
+
|
|
656
|
+
### SEO Helper
|
|
657
|
+
|
|
658
|
+
```typescript
|
|
659
|
+
const getSEO = setupSEO({
|
|
660
|
+
title: "My App",
|
|
661
|
+
description: "Default description",
|
|
662
|
+
siteName: "MyApp",
|
|
663
|
+
themeColor: "#000000",
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// In page
|
|
667
|
+
export const metadata = getSEO(
|
|
668
|
+
"Page Title",
|
|
669
|
+
"Page description",
|
|
670
|
+
"/og-image.png"
|
|
671
|
+
);
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
### Instagram Authorization URL
|
|
675
|
+
|
|
676
|
+
Generate Instagram OAuth authorization URLs for client-side redirects.
|
|
677
|
+
|
|
678
|
+
**Example:**
|
|
679
|
+
|
|
680
|
+
```typescript
|
|
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`,
|
|
687
|
+
);
|
|
688
|
+
|
|
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
|
+
}
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
### Image Upload Client
|
|
701
|
+
|
|
702
|
+
```typescript
|
|
703
|
+
const uploadImage = getHandleImageUpload("/api/upload");
|
|
704
|
+
|
|
705
|
+
const result = await uploadImage({
|
|
706
|
+
file: imageFile,
|
|
707
|
+
token: authToken,
|
|
708
|
+
type: "avatar",
|
|
709
|
+
data: { userId: 123 }, // Optional additional data
|
|
710
|
+
sync: true, // Optional: wait for processing
|
|
711
|
+
});
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
---
|
|
715
|
+
|
|
716
|
+
## File Module
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
import { setupFileUpload } from "naystack/file";
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
### Setup File Upload
|
|
723
|
+
|
|
724
|
+
**Basic Example:**
|
|
725
|
+
|
|
726
|
+
```typescript
|
|
727
|
+
const fileUpload = setupFileUpload({
|
|
728
|
+
refreshKey: process.env.JWT_REFRESH_KEY!,
|
|
729
|
+
signingKey: process.env.JWT_SIGNING_KEY!,
|
|
730
|
+
region: "us-east-1",
|
|
731
|
+
bucket: "my-bucket",
|
|
732
|
+
awsKey: process.env.AWS_ACCESS_KEY_ID!,
|
|
733
|
+
awsSecret: process.env.AWS_SECRET_ACCESS_KEY!,
|
|
734
|
+
processFile: async ({ url, type, userId, data }) => {
|
|
735
|
+
// Process uploaded file
|
|
736
|
+
return {
|
|
737
|
+
deleteURL: url, // URL to delete if needed
|
|
738
|
+
response: { success: true },
|
|
739
|
+
};
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// Export route handler
|
|
744
|
+
export const { PUT } = fileUpload;
|
|
745
|
+
|
|
746
|
+
// Server-side utilities
|
|
747
|
+
const { getUploadFileURL, uploadImage, deleteImage, getFileURL, uploadFile } =
|
|
748
|
+
fileUpload;
|
|
749
|
+
```
|
|
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
|
+
|
|
858
|
+
#### Options
|
|
859
|
+
|
|
860
|
+
| Option | Type | Required | Description |
|
|
861
|
+
| ------------- | ---------- | -------- | ------------------------ |
|
|
862
|
+
| `refreshKey` | `string` | ✅ | JWT refresh key |
|
|
863
|
+
| `signingKey` | `string` | ✅ | JWT signing key |
|
|
864
|
+
| `region` | `string` | ✅ | AWS S3 region |
|
|
865
|
+
| `bucket` | `string` | ✅ | AWS S3 bucket name |
|
|
866
|
+
| `awsKey` | `string` | ✅ | AWS access key ID |
|
|
867
|
+
| `awsSecret` | `string` | ✅ | AWS secret access key |
|
|
868
|
+
| `processFile` | `Function` | ✅ | File processing callback |
|
|
869
|
+
|
|
870
|
+
#### Returned Utilities
|
|
871
|
+
|
|
872
|
+
| Utility | Description |
|
|
873
|
+
| ------------------ | ------------------------------ |
|
|
874
|
+
| `PUT` | Route handler for file uploads |
|
|
875
|
+
| `getUploadFileURL` | Get presigned URL for upload |
|
|
876
|
+
| `uploadImage` | Upload image to S3 |
|
|
877
|
+
| `deleteImage` | Delete image from S3 |
|
|
878
|
+
| `getFileURL` | Get public URL for a file |
|
|
879
|
+
| `uploadFile` | Upload any file to S3 |
|
|
880
|
+
|
|
881
|
+
---
|
|
882
|
+
|
|
883
|
+
## Socials Module
|
|
884
|
+
|
|
885
|
+
```typescript
|
|
886
|
+
import {
|
|
887
|
+
// Instagram
|
|
888
|
+
getInstagramUser,
|
|
889
|
+
getInstagramMedia,
|
|
890
|
+
getInstagramConversations,
|
|
891
|
+
getInstagramConversationsByUser,
|
|
892
|
+
getInstagramConversationByUser,
|
|
893
|
+
getInstagramConversation,
|
|
894
|
+
getInstagramMessage,
|
|
895
|
+
sendInstagramMessage,
|
|
896
|
+
setupInstagramWebhook,
|
|
897
|
+
// Threads
|
|
898
|
+
getThread,
|
|
899
|
+
getThreads,
|
|
900
|
+
getThreadsReplies,
|
|
901
|
+
createThread,
|
|
902
|
+
createThreadsPost,
|
|
903
|
+
setupThreadsWebhook,
|
|
904
|
+
} from "naystack/socials";
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
### Instagram API
|
|
908
|
+
|
|
909
|
+
#### Get User Data
|
|
910
|
+
|
|
911
|
+
```typescript
|
|
912
|
+
const user = await getInstagramUser(accessToken);
|
|
913
|
+
const user = await getInstagramUser(accessToken, "user_id");
|
|
914
|
+
const user = await getInstagramUser(accessToken, "me", [
|
|
915
|
+
"username",
|
|
916
|
+
"followers_count",
|
|
917
|
+
]);
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
#### Get Media
|
|
921
|
+
|
|
922
|
+
**Basic Usage:**
|
|
923
|
+
|
|
924
|
+
```typescript
|
|
925
|
+
const media = await getInstagramMedia(accessToken);
|
|
926
|
+
const media = await getInstagramMedia(
|
|
927
|
+
accessToken,
|
|
928
|
+
["like_count", "comments_count"],
|
|
929
|
+
24
|
|
930
|
+
);
|
|
931
|
+
```
|
|
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
|
+
|
|
987
|
+
#### Conversations
|
|
988
|
+
|
|
989
|
+
```typescript
|
|
990
|
+
// Get all conversations
|
|
991
|
+
const { data, fetchMore } = await getInstagramConversations(accessToken);
|
|
992
|
+
|
|
993
|
+
// Get conversations by user
|
|
994
|
+
const conversations = await getInstagramConversationsByUser(
|
|
995
|
+
accessToken,
|
|
996
|
+
userId
|
|
997
|
+
);
|
|
998
|
+
|
|
999
|
+
// Get single conversation
|
|
1000
|
+
const conversation = await getInstagramConversationByUser(accessToken, userId);
|
|
1001
|
+
|
|
1002
|
+
// Get conversation with messages
|
|
1003
|
+
const { messages, participants, fetchMore } = await getInstagramConversation(
|
|
1004
|
+
accessToken,
|
|
1005
|
+
conversationId
|
|
1006
|
+
);
|
|
1007
|
+
```
|
|
1008
|
+
|
|
1009
|
+
#### Messages
|
|
1010
|
+
|
|
1011
|
+
```typescript
|
|
1012
|
+
// Get message details
|
|
1013
|
+
const message = await getInstagramMessage(accessToken, messageId);
|
|
1014
|
+
|
|
1015
|
+
// Send message
|
|
1016
|
+
const result = await sendInstagramMessage(accessToken, recipientId, "Hello!");
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
#### Webhook
|
|
1020
|
+
|
|
1021
|
+
**Basic Example:**
|
|
1022
|
+
|
|
1023
|
+
```typescript
|
|
1024
|
+
const instagramWebhook = setupInstagramWebhook({
|
|
1025
|
+
secret: process.env.INSTAGRAM_WEBHOOK_SECRET!,
|
|
1026
|
+
callback: async (type, value, id) => {
|
|
1027
|
+
// Handle webhook events
|
|
1028
|
+
},
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
export const { GET, POST } = instagramWebhook;
|
|
1032
|
+
```
|
|
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
|
+
|
|
1087
|
+
---
|
|
1088
|
+
|
|
1089
|
+
### Threads API
|
|
1090
|
+
|
|
1091
|
+
#### Get Threads
|
|
1092
|
+
|
|
1093
|
+
```typescript
|
|
1094
|
+
// Get single thread
|
|
1095
|
+
const thread = await getThread(accessToken, threadId);
|
|
1096
|
+
const thread = await getThread(accessToken, threadId, ["text", "permalink"]);
|
|
1097
|
+
|
|
1098
|
+
// Get all threads
|
|
1099
|
+
const threads = await getThreads(accessToken);
|
|
1100
|
+
|
|
1101
|
+
// Get thread replies
|
|
1102
|
+
const replies = await getThreadsReplies(accessToken, threadId);
|
|
1103
|
+
```
|
|
1104
|
+
|
|
1105
|
+
#### Create Threads
|
|
1106
|
+
|
|
1107
|
+
```typescript
|
|
1108
|
+
// Create single post
|
|
1109
|
+
const postId = await createThreadsPost(accessToken, "Hello, Threads!");
|
|
1110
|
+
|
|
1111
|
+
// Reply to a thread
|
|
1112
|
+
const replyId = await createThreadsPost(
|
|
1113
|
+
accessToken,
|
|
1114
|
+
"Reply text",
|
|
1115
|
+
parentThreadId
|
|
1116
|
+
);
|
|
1117
|
+
|
|
1118
|
+
// Create threaded posts (carousel)
|
|
1119
|
+
const firstPostId = await createThread(accessToken, [
|
|
1120
|
+
"First post",
|
|
1121
|
+
"Second post in thread",
|
|
1122
|
+
"Third post in thread",
|
|
1123
|
+
]);
|
|
1124
|
+
```
|
|
1125
|
+
|
|
1126
|
+
#### Webhook
|
|
1127
|
+
|
|
1128
|
+
**Basic Example:**
|
|
1129
|
+
|
|
1130
|
+
```typescript
|
|
1131
|
+
const threadsWebhook = setupThreadsWebhook({
|
|
1132
|
+
secret: process.env.THREADS_WEBHOOK_SECRET!,
|
|
1133
|
+
callback: async (type, value) => {
|
|
1134
|
+
// Handle webhook events
|
|
1135
|
+
return true; // Return false to indicate failure
|
|
1136
|
+
},
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
export const { GET, POST } = threadsWebhook;
|
|
1140
|
+
```
|
|
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
|
+
|
|
1280
|
+
---
|
|
1281
|
+
|
|
1282
|
+
## Environment Variables
|
|
1283
|
+
|
|
1284
|
+
Here's a summary of common environment variables used:
|
|
1285
|
+
|
|
1286
|
+
```env
|
|
1287
|
+
# JWT Keys
|
|
1288
|
+
JWT_SIGNING_KEY=your-signing-key
|
|
1289
|
+
JWT_REFRESH_KEY=your-refresh-key
|
|
1290
|
+
|
|
1291
|
+
# Google OAuth
|
|
1292
|
+
GOOGLE_CLIENT_ID=your-google-client-id
|
|
1293
|
+
GOOGLE_CLIENT_SECRET=your-google-client-secret
|
|
1294
|
+
|
|
1295
|
+
# Instagram
|
|
1296
|
+
INSTAGRAM_CLIENT_ID=your-instagram-client-id
|
|
1297
|
+
INSTAGRAM_CLIENT_SECRET=your-instagram-client-secret
|
|
1298
|
+
INSTAGRAM_WEBHOOK_SECRET=your-instagram-webhook-secret
|
|
1299
|
+
|
|
1300
|
+
# Threads
|
|
1301
|
+
THREADS_WEBHOOK_SECRET=your-threads-webhook-secret
|
|
1302
|
+
|
|
1303
|
+
# AWS S3
|
|
1304
|
+
AWS_ACCESS_KEY_ID=your-aws-key
|
|
1305
|
+
AWS_SECRET_ACCESS_KEY=your-aws-secret
|
|
1306
|
+
|
|
1307
|
+
# Cloudflare Turnstile (optional)
|
|
1308
|
+
TURNSTILE_SECRET_KEY=your-turnstile-key
|
|
1309
|
+
|
|
1310
|
+
# App
|
|
1311
|
+
NEXT_PUBLIC_BASE_URL=https://your-app.com
|
|
1312
|
+
```
|
|
1313
|
+
|
|
1314
|
+
---
|
|
1315
|
+
|
|
1316
|
+
## License
|
|
1317
|
+
|
|
1318
|
+
ISC © [Abhinay Pandey](mailto:abhinaypandey02@gmail.com)
|