@m5kdev/backend 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 (113) hide show
  1. package/LICENSE +621 -0
  2. package/README.md +22 -0
  3. package/package.json +205 -0
  4. package/src/lib/posthog.ts +5 -0
  5. package/src/lib/sentry.ts +8 -0
  6. package/src/modules/access/access.repository.ts +36 -0
  7. package/src/modules/access/access.service.ts +81 -0
  8. package/src/modules/access/access.test.ts +216 -0
  9. package/src/modules/access/access.utils.ts +46 -0
  10. package/src/modules/ai/ai.db.ts +38 -0
  11. package/src/modules/ai/ai.prompt.ts +47 -0
  12. package/src/modules/ai/ai.repository.ts +53 -0
  13. package/src/modules/ai/ai.router.ts +148 -0
  14. package/src/modules/ai/ai.service.ts +310 -0
  15. package/src/modules/ai/ai.trpc.ts +22 -0
  16. package/src/modules/ai/ideogram/ideogram.constants.ts +170 -0
  17. package/src/modules/ai/ideogram/ideogram.dto.ts +64 -0
  18. package/src/modules/ai/ideogram/ideogram.prompt.ts +858 -0
  19. package/src/modules/ai/ideogram/ideogram.repository.ts +39 -0
  20. package/src/modules/ai/ideogram/ideogram.service.ts +14 -0
  21. package/src/modules/auth/auth.db.ts +224 -0
  22. package/src/modules/auth/auth.dto.ts +47 -0
  23. package/src/modules/auth/auth.lib.ts +349 -0
  24. package/src/modules/auth/auth.middleware.ts +62 -0
  25. package/src/modules/auth/auth.repository.ts +672 -0
  26. package/src/modules/auth/auth.service.ts +261 -0
  27. package/src/modules/auth/auth.trpc.ts +208 -0
  28. package/src/modules/auth/auth.utils.ts +117 -0
  29. package/src/modules/base/base.abstract.ts +62 -0
  30. package/src/modules/base/base.dto.ts +206 -0
  31. package/src/modules/base/base.grants.test.ts +861 -0
  32. package/src/modules/base/base.grants.ts +199 -0
  33. package/src/modules/base/base.repository.ts +433 -0
  34. package/src/modules/base/base.service.ts +154 -0
  35. package/src/modules/base/base.types.ts +7 -0
  36. package/src/modules/billing/billing.db.ts +27 -0
  37. package/src/modules/billing/billing.repository.ts +328 -0
  38. package/src/modules/billing/billing.router.ts +77 -0
  39. package/src/modules/billing/billing.service.ts +177 -0
  40. package/src/modules/billing/billing.trpc.ts +17 -0
  41. package/src/modules/clay/clay.repository.ts +29 -0
  42. package/src/modules/clay/clay.service.ts +61 -0
  43. package/src/modules/connect/connect.db.ts +32 -0
  44. package/src/modules/connect/connect.dto.ts +44 -0
  45. package/src/modules/connect/connect.linkedin.ts +70 -0
  46. package/src/modules/connect/connect.oauth.ts +288 -0
  47. package/src/modules/connect/connect.repository.ts +65 -0
  48. package/src/modules/connect/connect.router.ts +76 -0
  49. package/src/modules/connect/connect.service.ts +171 -0
  50. package/src/modules/connect/connect.trpc.ts +26 -0
  51. package/src/modules/connect/connect.types.ts +27 -0
  52. package/src/modules/crypto/crypto.db.ts +15 -0
  53. package/src/modules/crypto/crypto.repository.ts +13 -0
  54. package/src/modules/crypto/crypto.service.ts +57 -0
  55. package/src/modules/email/email.service.ts +222 -0
  56. package/src/modules/file/file.repository.ts +95 -0
  57. package/src/modules/file/file.router.ts +108 -0
  58. package/src/modules/file/file.service.ts +186 -0
  59. package/src/modules/recurrence/recurrence.db.ts +79 -0
  60. package/src/modules/recurrence/recurrence.repository.ts +70 -0
  61. package/src/modules/recurrence/recurrence.service.ts +105 -0
  62. package/src/modules/recurrence/recurrence.trpc.ts +82 -0
  63. package/src/modules/social/social.dto.ts +22 -0
  64. package/src/modules/social/social.linkedin.test.ts +277 -0
  65. package/src/modules/social/social.linkedin.ts +593 -0
  66. package/src/modules/social/social.service.ts +112 -0
  67. package/src/modules/social/social.types.ts +43 -0
  68. package/src/modules/tag/tag.db.ts +41 -0
  69. package/src/modules/tag/tag.dto.ts +18 -0
  70. package/src/modules/tag/tag.repository.ts +222 -0
  71. package/src/modules/tag/tag.service.ts +48 -0
  72. package/src/modules/tag/tag.trpc.ts +62 -0
  73. package/src/modules/uploads/0581796b-8845-420d-bd95-cd7de79f6d37.webm +0 -0
  74. package/src/modules/uploads/33b1e649-6727-4bd0-94d0-a0b363646865.webm +0 -0
  75. package/src/modules/uploads/49a8c4c0-54d7-4c94-bef4-c93c029f9ed0.webm +0 -0
  76. package/src/modules/uploads/50e31e38-a2f0-47ca-8b7d-2d7fcad9267d.webm +0 -0
  77. package/src/modules/uploads/72ac8cf9-c3a7-4cd8-8a78-6d8e137a4c7e.webm +0 -0
  78. package/src/modules/uploads/75293649-d966-46cd-a675-67518958ae9c.png +0 -0
  79. package/src/modules/uploads/88b7b867-ce15-4891-bf73-81305a7de1f7.wav +0 -0
  80. package/src/modules/uploads/a5d6fee8-6a59-42c6-9d4a-ac8a3c5e7245.webm +0 -0
  81. package/src/modules/uploads/c13a9785-ca5a-4983-af30-b338ed76d370.webm +0 -0
  82. package/src/modules/uploads/caa1a5a7-71ba-4381-902d-7e2cafdf6dcb.webm +0 -0
  83. package/src/modules/uploads/cbeb0b81-374d-445b-914b-40ace7c8e031.webm +0 -0
  84. package/src/modules/uploads/d626aa82-b10f-493f-aee7-87bfb3361dfc.webm +0 -0
  85. package/src/modules/uploads/d7de4c16-de0c-495d-9612-e72260a6ecca.png +0 -0
  86. package/src/modules/uploads/e532e38a-6421-400e-8a5f-8e7bc8ce411b.wav +0 -0
  87. package/src/modules/uploads/e86ec867-6adf-4c51-84e0-00b0836625e8.webm +0 -0
  88. package/src/modules/utils/applyPagination.ts +13 -0
  89. package/src/modules/utils/applySorting.ts +21 -0
  90. package/src/modules/utils/getConditionsFromFilters.ts +216 -0
  91. package/src/modules/video/video.service.ts +89 -0
  92. package/src/modules/webhook/webhook.constants.ts +9 -0
  93. package/src/modules/webhook/webhook.db.ts +15 -0
  94. package/src/modules/webhook/webhook.dto.ts +9 -0
  95. package/src/modules/webhook/webhook.repository.ts +68 -0
  96. package/src/modules/webhook/webhook.router.ts +29 -0
  97. package/src/modules/webhook/webhook.service.ts +78 -0
  98. package/src/modules/workflow/workflow.db.ts +29 -0
  99. package/src/modules/workflow/workflow.repository.ts +171 -0
  100. package/src/modules/workflow/workflow.service.ts +56 -0
  101. package/src/modules/workflow/workflow.trpc.ts +26 -0
  102. package/src/modules/workflow/workflow.types.ts +30 -0
  103. package/src/modules/workflow/workflow.utils.ts +259 -0
  104. package/src/test/stubs/utils.ts +2 -0
  105. package/src/trpc/context.ts +21 -0
  106. package/src/trpc/index.ts +3 -0
  107. package/src/trpc/procedures.ts +43 -0
  108. package/src/trpc/utils.ts +20 -0
  109. package/src/types.ts +22 -0
  110. package/src/utils/errors.ts +148 -0
  111. package/src/utils/logger.ts +8 -0
  112. package/src/utils/posthog.ts +43 -0
  113. package/src/utils/types.ts +5 -0
@@ -0,0 +1,177 @@
1
+ import { err, ok } from "neverthrow";
2
+ import type Stripe from "stripe";
3
+ import type { User } from "#modules/auth/auth.lib";
4
+ import type { ServerResult, ServerResultAsync } from "#modules/base/base.dto";
5
+ import { BaseService } from "#modules/base/base.service";
6
+ import type { BillingRepository } from "#modules/billing/billing.repository";
7
+
8
+ import { posthogCapture } from "#utils/posthog";
9
+
10
+ const allowedEvents: Stripe.Event.Type[] = [
11
+ "checkout.session.completed",
12
+ "customer.subscription.created",
13
+ "customer.subscription.updated",
14
+ "customer.subscription.deleted",
15
+ "customer.subscription.paused",
16
+ "customer.subscription.resumed",
17
+ "customer.subscription.pending_update_applied",
18
+ "customer.subscription.pending_update_expired",
19
+ "customer.subscription.trial_will_end",
20
+ "invoice.paid",
21
+ "invoice.payment_failed",
22
+ "invoice.payment_action_required",
23
+ "invoice.upcoming",
24
+ "invoice.marked_uncollectible",
25
+ "invoice.payment_succeeded",
26
+ "payment_intent.succeeded",
27
+ "payment_intent.payment_failed",
28
+ "payment_intent.canceled",
29
+ ];
30
+
31
+ export class BillingService extends BaseService<{ billing: BillingRepository }, never> {
32
+ async createUserCustomer({
33
+ user,
34
+ }: {
35
+ user: { id: string; email: string; name?: string };
36
+ }): ServerResultAsync<Stripe.Customer> {
37
+ let stripeCustomer: Stripe.Customer | null = null;
38
+ const existingCustomer = await this.repository.billing.getCustomerByEmail(user.email);
39
+ if (existingCustomer.isErr()) return err(existingCustomer.error);
40
+ stripeCustomer = existingCustomer.value;
41
+ if (!stripeCustomer) {
42
+ const newCustomer = await this.repository.billing.createCustomer({
43
+ email: user.email,
44
+ name: user.name,
45
+ userId: user.id,
46
+ });
47
+ if (newCustomer.isErr()) return err(newCustomer.error);
48
+ stripeCustomer = newCustomer.value;
49
+ }
50
+
51
+ if (!stripeCustomer)
52
+ return this.error("INTERNAL_SERVER_ERROR", "Failed to create or get stripe customer");
53
+ const updatedUser = await this.repository.billing.updateUserCustomerId({
54
+ userId: user.id,
55
+ customerId: stripeCustomer.id,
56
+ });
57
+ if (updatedUser.isErr()) return err(updatedUser.error);
58
+ return ok(stripeCustomer);
59
+ }
60
+
61
+ async createUserHook({
62
+ user,
63
+ }: {
64
+ user: { id: string; email: string; name?: string };
65
+ }): ServerResultAsync<boolean> {
66
+ const stripeCustomer = await this.createUserCustomer({ user });
67
+ if (stripeCustomer.isErr()) return err(stripeCustomer.error);
68
+
69
+ if (this.repository.billing.hasTrial()) {
70
+ const existingSubscription = await this.repository.billing.getLatestSubscription(user.id);
71
+ if (existingSubscription.isErr()) return err(existingSubscription.error);
72
+ if (!existingSubscription.value) {
73
+ const subscription = await this.repository.billing.createTrialSubscription(
74
+ stripeCustomer.value.id
75
+ );
76
+ if (subscription.isErr()) return err(subscription.error);
77
+ }
78
+ const syncResult = await this.syncStripeData(stripeCustomer.value.id);
79
+ if (syncResult.isErr()) return err(syncResult.error);
80
+ if (syncResult.value === false)
81
+ return this.error("INTERNAL_SERVER_ERROR", "Sync did not create new subscription");
82
+ }
83
+
84
+ return ok(true);
85
+ }
86
+
87
+ async getActiveSubscription({ user }: { user: User }) {
88
+ return this.repository.billing.getActiveSubscription(user.id);
89
+ }
90
+
91
+ async listInvoices({ user }: { user: User }): ServerResultAsync<Stripe.Invoice[]> {
92
+ if (!user.stripeCustomerId) return this.error("NOT_FOUND", "User has no stripe customer id");
93
+ return this.repository.billing.listInvoices(user.stripeCustomerId);
94
+ }
95
+
96
+ async createCheckoutSession(
97
+ { priceId }: { priceId: string },
98
+ { user }: { user: User }
99
+ ): ServerResultAsync<Stripe.Checkout.Session> {
100
+ let stripeCustomerId = user.stripeCustomerId;
101
+ if (!stripeCustomerId) {
102
+ const stripeCustomer = await this.createUserCustomer({ user });
103
+ if (stripeCustomer.isErr()) return err(stripeCustomer.error);
104
+ stripeCustomerId = stripeCustomer.value.id;
105
+ }
106
+ return this.repository.billing.createCheckoutSession({
107
+ customerId: stripeCustomerId,
108
+ priceId,
109
+ userId: user.id,
110
+ });
111
+ }
112
+
113
+ async createBillingPortalSession({
114
+ user,
115
+ }: {
116
+ user: User;
117
+ }): ServerResultAsync<Stripe.BillingPortal.Session> {
118
+ let stripeCustomerId = user.stripeCustomerId;
119
+ if (!stripeCustomerId) {
120
+ const stripeCustomer = await this.createUserCustomer({ user });
121
+ if (stripeCustomer.isErr()) return err(stripeCustomer.error);
122
+ stripeCustomerId = stripeCustomer.value.id;
123
+ }
124
+ return this.repository.billing.createBillingPortalSession(stripeCustomerId);
125
+ }
126
+
127
+ constructEvent(body: Buffer | string, signature: string): ServerResult<Stripe.Event> {
128
+ if (!process.env.STRIPE_WEBHOOK_SECRET)
129
+ return this.error("INTERNAL_SERVER_ERROR", "Stripe webhook secret is not set");
130
+ return this.repository.billing.constructEvent(
131
+ body,
132
+ signature,
133
+ process.env.STRIPE_WEBHOOK_SECRET!
134
+ );
135
+ }
136
+
137
+ async syncStripeData(customerId: string, eventType?: string): ServerResultAsync<boolean> {
138
+ const user = await this.repository.billing.getUserByCustomerId(customerId);
139
+ if (user.isErr()) return err(user.error);
140
+ if (!user.value) return this.error("NOT_FOUND", "User not found");
141
+
142
+ if (eventType) {
143
+ posthogCapture({
144
+ distinctId: user.value.id,
145
+ event: `stripe.${eventType}`,
146
+ properties: {
147
+ customerId,
148
+ },
149
+ });
150
+ }
151
+ return this.repository.billing.syncStripeData({ customerId, userId: user.value.id });
152
+ }
153
+
154
+ async processEvent(event: Stripe.Event): ServerResultAsync<boolean> {
155
+ return this.throwableAsync(async () => {
156
+ // Skip processing if the event isn't one I'm tracking (list of all events below)
157
+ if (!allowedEvents.includes(event.type)) return ok(false);
158
+
159
+ // All the events I track have a customerId
160
+ const { customer: customerId } = event?.data?.object as {
161
+ customer: string; // Sadly TypeScript does not know this
162
+ };
163
+
164
+ // This helps make it typesafe and also lets me know if my assumption is wrong
165
+ if (typeof customerId !== "string") {
166
+ return this.error(
167
+ "INTERNAL_SERVER_ERROR",
168
+ `[STRIPE HOOK] Unexpected event structure: customer ID is not a string. Event type: ${event.type}`
169
+ );
170
+ }
171
+
172
+ const result = await this.syncStripeData(customerId, event.type);
173
+ if (result.isErr()) return err(result.error);
174
+ return ok(true);
175
+ });
176
+ }
177
+ }
@@ -0,0 +1,17 @@
1
+ import { billingSchema } from "@m5kdev/commons/modules/billing/billing.schema";
2
+ import type { BillingService } from "#modules/billing/billing.service";
3
+ import { handleTRPCResult, procedure, router } from "#trpc";
4
+
5
+ export function createBillingTRPC(billingService: BillingService) {
6
+ return router({
7
+ getActiveSubscription: procedure
8
+ .output(billingSchema.nullable())
9
+ .query(async ({ ctx: { user } }) => {
10
+ return handleTRPCResult(await billingService.getActiveSubscription({ user }));
11
+ }),
12
+
13
+ listInvoices: procedure.query(async ({ ctx: { user } }) => {
14
+ return handleTRPCResult(await billingService.listInvoices({ user }));
15
+ }),
16
+ });
17
+ }
@@ -0,0 +1,29 @@
1
+ import { ok } from "neverthrow";
2
+ import type { ServerResultAsync } from "#modules/base/base.dto";
3
+ import { BaseExternaRepository } from "#modules/base/base.repository";
4
+
5
+ const { CLAY_WEBHOOK_AUTH_TOKEN } = process.env;
6
+
7
+ export class ClayRepository extends BaseExternaRepository {
8
+ async sendToWebhook(
9
+ webhookUrl: string,
10
+ row: Record<string, unknown>,
11
+ callbackUrl: string
12
+ ): ServerResultAsync<void> {
13
+ return this.throwableAsync(async () => {
14
+ const response = await fetch(webhookUrl, {
15
+ method: "POST",
16
+ headers: {
17
+ "Content-Type": "application/json",
18
+ ...(CLAY_WEBHOOK_AUTH_TOKEN ? { "x-clay-webhook-auth": CLAY_WEBHOOK_AUTH_TOKEN } : {}),
19
+ },
20
+ body: JSON.stringify({ ...row, callback: callbackUrl }),
21
+ });
22
+ if (!response.ok)
23
+ return this.error("BAD_REQUEST", `HTTP error! status: ${response.status}`, {
24
+ cause: response,
25
+ });
26
+ return ok();
27
+ });
28
+ }
29
+ }
@@ -0,0 +1,61 @@
1
+ import type { z } from "zod";
2
+ import type { ServerResultAsync } from "#modules/base/base.dto";
3
+ import { BaseService } from "#modules/base/base.service";
4
+ import type { WebhookService } from "#modules/webhook/webhook.service";
5
+ import type { ClayRepository } from "./clay.repository";
6
+
7
+ type ClayTable = {
8
+ name?: string;
9
+ tableId?: string;
10
+ viewId?: string;
11
+ webhookUrl: string;
12
+ schema?: z.ZodAny;
13
+ timeoutInSeconds?: number;
14
+ };
15
+
16
+ export class ClayService<K extends string> extends BaseService<
17
+ { clay: ClayRepository },
18
+ { webhook: WebhookService }
19
+ > {
20
+ private tables: Record<K, ClayTable>;
21
+ constructor(
22
+ repositories: { clay: ClayRepository },
23
+ services: { webhook: WebhookService },
24
+ tables: Record<K, ClayTable>
25
+ ) {
26
+ super(repositories, services);
27
+ this.tables = tables;
28
+ }
29
+
30
+ async waitForResponse<T>(
31
+ webhookUrl: string,
32
+ row: Record<string, unknown>,
33
+ timeoutInSeconds?: number
34
+ ): ServerResultAsync<T> {
35
+ return await this.service.webhook.waitForRequest<T>((url) => {
36
+ return this.repository.clay.sendToWebhook(webhookUrl, row, url);
37
+ }, timeoutInSeconds);
38
+ }
39
+
40
+ async sendToTable(
41
+ table: K,
42
+ row: Record<string, unknown>,
43
+ timeoutInSeconds?: number
44
+ ): ServerResultAsync<
45
+ z.infer<
46
+ (typeof this.tables)[K]["schema"] extends z.ZodAny
47
+ ? z.infer<(typeof this.tables)[K]["schema"]>
48
+ : unknown
49
+ >
50
+ > {
51
+ const tableData = this.tables[table];
52
+ if (!tableData) return this.error("NOT_FOUND", `Table ${table} not found`);
53
+ const response = await this.waitForResponse<z.infer<typeof tableData.schema>>(
54
+ tableData.webhookUrl,
55
+ row,
56
+ tableData.timeoutInSeconds || timeoutInSeconds
57
+ );
58
+
59
+ return response;
60
+ }
61
+ }
@@ -0,0 +1,32 @@
1
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+ import { v4 as uuidv4 } from "uuid";
3
+
4
+ export const connect = sqliteTable("connect", {
5
+ id: text("id").primaryKey().$default(uuidv4),
6
+ userId: text("user_id").notNull(), // FK -> users.id
7
+
8
+ provider: text("provider").notNull(), // e.g. "linkedin"
9
+ accountType: text("account_type").notNull(), // "user" | "page" | "org" | "channel"
10
+ providerAccountId: text("provider_account_id").notNull(), // e.g. LinkedIn URN, FB Page ID, IG business acct ID, X user ID
11
+ handle: text("handle"), // @name or page slug
12
+ displayName: text("display_name"),
13
+ avatarUrl: text("avatar_url"),
14
+
15
+ // OAuth credentials (ENCRYPTED)
16
+ accessToken: text("access_token").notNull(),
17
+ refreshToken: text("refresh_token"), // may be null if provider doesn’t issue refresh tokens
18
+ tokenType: text("token_type"), // e.g. "bearer"
19
+ scope: text("scope"), // space- or comma-separated list, for auditing
20
+ expiresAt: integer("expires_at", { mode: "timestamp" }), // epoch seconds
21
+
22
+ // Provider-specific glue
23
+ parentId: text("parent_id"), // e.g. FB Page’s connected IG business account, or org URN
24
+ metadataJson: text("metadata_json", { mode: "json" }), // JSON string for extras (region, perms, etc.)
25
+
26
+ revokedAt: integer("revoked_at", { mode: "timestamp" }),
27
+ lastRefreshedAt: integer("last_refreshed_at", { mode: "timestamp" }),
28
+ createdAt: integer("created_at", { mode: "timestamp" })
29
+ .notNull()
30
+ .$default(() => new Date()),
31
+ updatedAt: integer("updated_at", { mode: "timestamp" }),
32
+ });
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+
3
+ export const connectSelectSchema = z.object({
4
+ id: z.string(),
5
+ userId: z.string(),
6
+ provider: z.string(),
7
+ accountType: z.string(),
8
+ providerAccountId: z.string(),
9
+ handle: z.string().nullish(),
10
+ displayName: z.string().nullish(),
11
+ avatarUrl: z.string().nullish(),
12
+ accessToken: z.string(),
13
+ refreshToken: z.string().nullish(),
14
+ tokenType: z.string().nullish(),
15
+ scope: z.string().nullish(),
16
+ expiresAt: z.date().nullish(),
17
+ parentId: z.string().nullish(),
18
+ metadataJson: z.unknown().nullish(),
19
+ revokedAt: z.date().nullish(),
20
+ lastRefreshedAt: z.date().nullish(),
21
+ createdAt: z.date(),
22
+ updatedAt: z.date().nullish(),
23
+ });
24
+
25
+ export type ConnectSelectSchema = z.infer<typeof connectSelectSchema>;
26
+
27
+ export const connectSelectOutputSchema = connectSelectSchema.omit({
28
+ accessToken: true,
29
+ refreshToken: true,
30
+ });
31
+
32
+ export const connectListInputSchema = z.object({
33
+ providers: z.array(z.string()).optional(),
34
+ inactive: z.boolean().optional(),
35
+ });
36
+
37
+ export type ConnectListInputSchema = z.infer<typeof connectListInputSchema>;
38
+
39
+ export const connectListOutputSchema = z.array(connectSelectOutputSchema);
40
+
41
+ export const connectDeleteInputSchema = z.object({ id: z.string() });
42
+ export const connectDeleteOutputSchema = z.object({ id: z.string() });
43
+ export type ConnectDeleteInputSchema = z.infer<typeof connectDeleteInputSchema>;
44
+ export type ConnectDeleteOutputSchema = z.infer<typeof connectDeleteOutputSchema>;
@@ -0,0 +1,70 @@
1
+ import type { ConnectProfile, ConnectProvider } from "./connect.types";
2
+
3
+ interface LinkedInUserInfoResponse {
4
+ sub: string; // Subject identifier (user ID)
5
+ name?: string; // Full name
6
+ given_name?: string; // First name
7
+ family_name?: string; // Last name
8
+ picture?: string; // Profile picture URL
9
+ locale?: string;
10
+ email?: string;
11
+ email_verified?: boolean;
12
+ }
13
+
14
+ export function createLinkedInProvider(): ConnectProvider {
15
+ const clientId = process.env.LINKEDIN_CLIENT_ID;
16
+ const clientSecret = process.env.LINKEDIN_CLIENT_SECRET;
17
+ const baseUrl = process.env.VITE_SERVER_URL;
18
+
19
+ if (!clientId || !clientSecret || !baseUrl) {
20
+ throw new Error(
21
+ "Missing required LinkedIn OAuth environment variables: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET, VITE_SERVER_URL"
22
+ );
23
+ }
24
+
25
+ return {
26
+ id: "linkedin",
27
+ clientId,
28
+ clientSecret,
29
+ redirectUri: `${baseUrl}/connect/linkedin/callback`,
30
+ // LinkedIn OpenID Connect scopes
31
+ scopes: ["openid", "profile", "w_member_social"],
32
+ // LinkedIn doesn't support PKCE - disable it
33
+ supportsPKCE: false,
34
+ // LinkedIn OpenID Connect endpoints
35
+ issuerConfig: {
36
+ issuer: "https://www.linkedin.com",
37
+ authorization_endpoint: "https://www.linkedin.com/oauth/v2/authorization",
38
+ token_endpoint: "https://www.linkedin.com/oauth/v2/accessToken",
39
+ userinfo_endpoint: "https://api.linkedin.com/v2/userinfo",
40
+ },
41
+ async mapProfile(accessToken: string): Promise<ConnectProfile> {
42
+ // Use OpenID Connect userinfo endpoint
43
+ const response = await fetch("https://api.linkedin.com/v2/userinfo", {
44
+ headers: {
45
+ Authorization: `Bearer ${accessToken}`,
46
+ },
47
+ });
48
+
49
+ if (!response.ok) {
50
+ throw new Error(`LinkedIn API error: ${response.status} ${response.statusText}`);
51
+ }
52
+
53
+ const data = (await response.json()) as LinkedInUserInfoResponse;
54
+
55
+ return {
56
+ providerAccountId: data.sub,
57
+ displayName: data.name,
58
+ avatarUrl: data.picture,
59
+ accountType: "user",
60
+ metadata: {
61
+ givenName: data.given_name,
62
+ familyName: data.family_name,
63
+ locale: data.locale,
64
+ email: data.email,
65
+ emailVerified: data.email_verified,
66
+ },
67
+ };
68
+ },
69
+ };
70
+ }