diff --git a/apps/api/package.json b/apps/api/package.json index c8be83a..64f68c1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -21,7 +21,7 @@ "dependencies": { "@elysiajs/cors": "^1.4.1", "@t3-oss/env-core": "^0.13.10", - "convex": "^1.31.7", + "convex": "^1.32.0", "jose": "^6.1.3", "jsonwebtoken": "^9.0.3", "zod": "^3.25.76" diff --git a/apps/application/package.json b/apps/application/package.json index 6fc9bb2..d8d24e7 100644 --- a/apps/application/package.json +++ b/apps/application/package.json @@ -18,7 +18,7 @@ "@mentra/sdk": "^2.1.29", "@t3-oss/env-core": "^0.13.10", "@tavily/core": "^0.5.14", - "convex": "^1.31.7", + "convex": "^1.32.0", "exa-js": "^1.10.2", "openai": "^5.23.2", "zod": "^3.25.76" diff --git a/apps/web/package.json b/apps/web/package.json index 55e913f..801957f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,30 +27,30 @@ "@vercel/analytics": "^1.6.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "convex": "^1.31.7", + "convex": "^1.32.0", "date-fns": "^4.1.0", - "framer-motion": "^12.34.0", + "framer-motion": "^12.34.3", "lucide-react": "^0.553.0", - "mapbox-gl": "^3.18.1", - "motion": "^12.34.0", + "mapbox-gl": "^3.19.0", + "motion": "^12.34.3", "react": "^19.2.4", - "react-day-picker": "^9.13.2", + "react-day-picker": "^9.14.0", "react-dom": "^19.2.4", "react-map-gl": "^8.1.0", - "react-router-dom": "^7.13.0", + "react-router-dom": "^7.13.1", "recharts": "^3.7.0", - "tailwind-merge": "^3.4.0", + "tailwind-merge": "^3.5.0", "zod": "^3.25.76" }, "devDependencies": { - "@tailwindcss/vite": "^4.1.18", - "@types/node": "^24.10.13", + "@tailwindcss/vite": "^4.2.1", + "@types/node": "^24.10.15", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.7.0", - "autoprefixer": "^10.4.24", + "autoprefixer": "^10.4.27", "postcss": "^8.5.6", - "tailwindcss": "^4.1.18", + "tailwindcss": "^4.2.1", "tailwindcss-animate": "^1.0.7", "tw-animate-css": "^1.4.0", "typescript": "^5.9.3", diff --git a/apps/web/src/components/ChatPage.tsx b/apps/web/src/components/ChatPage.tsx index a7c26b3..0c35831 100644 --- a/apps/web/src/components/ChatPage.tsx +++ b/apps/web/src/components/ChatPage.tsx @@ -76,7 +76,7 @@ export function ChatPage({ mentraUserId }: ChatPageProps) { useEffect(() => { if (existingMessages && pendingMessages.length > 0) { const existingContents = new Set( - existingMessages.map((m) => `${m.role}:${m.content}`), + existingMessages.map((m: Message) => `${m.role}:${m.content}`), ); const remaining = pendingMessages.filter( (m) => !existingContents.has(`${m.role}:${m.content}`), diff --git a/apps/web/src/components/FollowupChatPage.tsx b/apps/web/src/components/FollowupChatPage.tsx index 1e94f6b..907f07a 100644 --- a/apps/web/src/components/FollowupChatPage.tsx +++ b/apps/web/src/components/FollowupChatPage.tsx @@ -46,7 +46,7 @@ export function FollowupChatPage({ mentraUserId }: FollowupChatPageProps) { useEffect(() => { if (existingMessages && pendingMessages.length > 0) { const existingContents = new Set( - existingMessages.map((m) => `${m.role}:${m.content}`), + existingMessages.map((m: Message) => `${m.role}:${m.content}`), ); const remaining = pendingMessages.filter( (m) => !existingContents.has(`${m.role}:${m.content}`), diff --git a/apps/web/src/components/FollowupsPage.tsx b/apps/web/src/components/FollowupsPage.tsx index b7d5ca1..de0d410 100644 --- a/apps/web/src/components/FollowupsPage.tsx +++ b/apps/web/src/components/FollowupsPage.tsx @@ -125,52 +125,60 @@ export function FollowupsPage({ userId }: FollowupsPageProps) {

Follow-ups

- {followups.map((followup) => ( - { - if (followup.status === "pending") { - navigate(`/followups/chat/${followup._id}`); - } - }} - > - -
- {followup.topic} - -
-
- -

{followup.summary}

+ {followups.map( + (followup: { + _id: Id<"followups">; + topic: string; + summary: string; + status: "pending" | "completed" | "dismissed"; + createdAt: string; + }) => ( + { + if (followup.status === "pending") { + navigate(`/followups/chat/${followup._id}`); + } + }} + > + +
+ {followup.topic} + +
+
+ +

{followup.summary}

-
-

- {formatRelativeTime(followup.createdAt)} -

+
+

+ {formatRelativeTime(followup.createdAt)} +

- {followup.status === "pending" && ( -
- - -
- )} -
- - - ))} + {followup.status === "pending" && ( +
+ + +
+ )} +
+
+
+ ), + )}
); diff --git a/apps/web/src/components/MemoryPage.tsx b/apps/web/src/components/MemoryPage.tsx index ee46e84..55b0203 100644 --- a/apps/web/src/components/MemoryPage.tsx +++ b/apps/web/src/components/MemoryPage.tsx @@ -82,40 +82,47 @@ export function MemoryPage({ mentraUserId }: MemoryPageProps) {

Memory

- {result.summaries.map((day) => ( - navigate(`/memory/chat/${day.date}`)} - > - - - 📅 - {formatDate(day.date)} - - - -

{day.summary}

+ {result.summaries.map( + (day: { + date: string; + summary: string; + topics: string[]; + sessionCount: number; + }) => ( + navigate(`/memory/chat/${day.date}`)} + > + + + 📅 + {formatDate(day.date)} + + + +

{day.summary}

- {day.topics.length > 0 && ( -
- {day.topics.map((topic) => ( - - {topic} - - ))} -
- )} + {day.topics.length > 0 && ( +
+ {day.topics.map((topic: string) => ( + + {topic} + + ))} +
+ )} -

- {day.sessionCount} session{day.sessionCount > 1 ? "s" : ""} -

-
-
- ))} +

+ {day.sessionCount} session{day.sessionCount > 1 ? "s" : ""} +

+
+
+ ), + )}
); diff --git a/apps/web/src/components/QueuePage.tsx b/apps/web/src/components/QueuePage.tsx index 756b356..93e67f4 100644 --- a/apps/web/src/components/QueuePage.tsx +++ b/apps/web/src/components/QueuePage.tsx @@ -9,7 +9,10 @@ interface QueuePageProps { userId: Id<"users">; } -const PREFIX_COLORS: Record = { +const PREFIX_COLORS: Record< + string, + { bg: string; text: string; label: string } +> = { W: { bg: "bg-blue-100", text: "text-blue-700", label: "Weather" }, M: { bg: "bg-purple-100", text: "text-purple-700", label: "Memory" }, S: { bg: "bg-green-100", text: "text-green-700", label: "Search" }, @@ -51,9 +54,19 @@ interface MessageCardProps { displayedAt?: string; } -function MessageCard({ message, prefix, status, createdAt, displayedAt }: MessageCardProps) { - const prefixStyle = PREFIX_COLORS[prefix] || { bg: "bg-gray-100", text: "text-gray-700", label: prefix }; - +function MessageCard({ + message, + prefix, + status, + createdAt, + displayedAt, +}: MessageCardProps) { + const prefixStyle = PREFIX_COLORS[prefix] || { + bg: "bg-gray-100", + text: "text-gray-700", + label: prefix, + }; + const statusStyles = { queued: "border-yellow-300 bg-yellow-50", displayed: "border-border", @@ -63,11 +76,15 @@ function MessageCard({ message, prefix, status, createdAt, displayedAt }: Messag const cleanMessage = message.replace(/^\/\/ Clairvoyant\n[A-Z]: /, ""); return ( -
+
- + {prefixStyle.label} {status === "queued" && ( @@ -84,7 +101,9 @@ function MessageCard({ message, prefix, status, createdAt, displayedAt }: Messag

{cleanMessage}

- {displayedAt ? formatRelativeTime(displayedAt) : formatRelativeTime(createdAt)} + {displayedAt + ? formatRelativeTime(displayedAt) + : formatRelativeTime(createdAt)}
@@ -115,8 +134,12 @@ export function QueuePage({ userId }: QueuePageProps) { ); } - const queuedMessages = messages.filter((m) => m.status === "queued").slice(0, MAX_VISIBLE); - const recentMessages = messages.filter((m) => m.status !== "queued").slice(0, MAX_VISIBLE); + const queuedMessages = messages + .filter((m: { status: string }) => m.status === "queued") + .slice(0, MAX_VISIBLE); + const recentMessages = messages + .filter((m: { status: string }) => m.status !== "queued") + .slice(0, MAX_VISIBLE); return (
@@ -144,16 +167,25 @@ export function QueuePage({ userId }: QueuePageProps) {

No messages in queue

) : (
- {queuedMessages.map((msg) => ( - - ))} + {queuedMessages.map( + (msg: { + _id: string; + message: string; + prefix: string; + status: string; + createdAt: string; + displayedAt?: string; + }) => ( + + ), + )}
)} @@ -168,16 +200,25 @@ export function QueuePage({ userId }: QueuePageProps) {

No recent messages

) : (
- {recentMessages.map((msg) => ( - - ))} + {recentMessages.map( + (msg: { + _id: string; + message: string; + prefix: string; + status: string; + createdAt: string; + displayedAt?: string; + }) => ( + + ), + )}
)} diff --git a/apps/web/src/components/SettingsPage.tsx b/apps/web/src/components/SettingsPage.tsx index 7bbf33b..c76a5be 100644 --- a/apps/web/src/components/SettingsPage.tsx +++ b/apps/web/src/components/SettingsPage.tsx @@ -1,6 +1,6 @@ import { api } from "@convex/_generated/api"; import type { Id } from "@convex/_generated/dataModel"; -import { useMutation, useQuery } from "convex/react"; +import { useAction, useMutation, useQuery } from "convex/react"; import { useEffect, useMemo, useState } from "react"; import { LocationSelector } from "./LocationSelector"; import { MessageSpeedSelector } from "./MessageSpeedSelector"; @@ -33,18 +33,27 @@ export function SettingsPage({ userId, mentraUserId }: SettingsPageProps) { const updatePreferences = useMutation(api.users.updatePreferences); const updatePrefixPriorities = useMutation(api.users.updatePrefixPriorities); const updateMessageGapSpeed = useMutation(api.users.updateMessageGapSpeed); - + const requestOptOutCheckoutEmail = useAction( + api.optOut.requestOptOutCheckoutEmail, + ); const storedEmail = useQuery( api.users.getEmail, mentraUserId ? { mentraUserId } : "skip", ); + const optOutStatus = useQuery( + api.users.getOptOutStatus, + mentraUserId ? { mentraUserId } : "skip", + ); const updateEmail = useMutation(api.users.updateEmail); const [emailInput, setEmailInput] = useState(""); const [emailStatus, setEmailStatus] = useState< "idle" | "saving" | "saved" | "error" >("idle"); const [emailError, setEmailError] = useState(null); - + const [optOutRequestStatus, setOptOutRequestStatus] = useState< + "idle" | "sending" | "sent" | "error" + >("idle"); + const [optOutError, setOptOutError] = useState(null); useEffect(() => { if (storedEmail !== undefined && storedEmail !== null) { setEmailInput(storedEmail); @@ -97,6 +106,26 @@ export function SettingsPage({ userId, mentraUserId }: SettingsPageProps) { console.log(`Preference saved: messageGapSpeed=${speed}`); }; + const handleRequestOptOut = async () => { + if (!mentraUserId) return; + + setOptOutRequestStatus("sending"); + setOptOutError(null); + try { + const result = await requestOptOutCheckoutEmail({ mentraUserId }); + if (!result.success) { + setOptOutError("Unable to send opt-out email."); + setOptOutRequestStatus("error"); + return; + } + setOptOutRequestStatus("sent"); + setTimeout(() => setOptOutRequestStatus("idle"), 2500); + } catch { + setOptOutError("Unable to send opt-out email."); + setOptOutRequestStatus("error"); + } + }; + const hasPreferencesLoaded = preferences !== undefined; const weatherUnit = useMemo(() => { if (!preferences) { @@ -200,6 +229,39 @@ export function SettingsPage({ userId, mentraUserId }: SettingsPageProps) { + + + Training Data + + Control whether your data is used for model training. + + + +

+ Status:{" "} + {optOutStatus?.optedOut ? "Opted out" : "Included in training"} +

+ {!optOutStatus?.optedOut && ( + + )} + {optOutRequestStatus === "sent" && ( +

+ Email sent with instructions. +

+ )} + {optOutError && ( +

{optOutError}

+ )} +
+
+ Weather Unit diff --git a/apps/web/src/components/charts/ToolUsageChart.tsx b/apps/web/src/components/charts/ToolUsageChart.tsx index 15c3b53..3471afc 100644 --- a/apps/web/src/components/charts/ToolUsageChart.tsx +++ b/apps/web/src/components/charts/ToolUsageChart.tsx @@ -79,6 +79,8 @@ export function ToolUsageChart({ days: timeframe, }); + type ToolInvocation = { date: string; router: string; count: number }; + const chartMemo = useMemo(() => { if (!toolInvocations || !toolInvocations.length) { return { @@ -90,7 +92,7 @@ export function ToolUsageChart({ } // Filter out KNOWLEDGE entries - const filteredInvocations = toolInvocations.filter( + const filteredInvocations = (toolInvocations as ToolInvocation[]).filter( ({ router }) => router !== "KNOWLEDGE", ); @@ -226,12 +228,15 @@ export function ToolUsageChart({ /> - new Date(`${value}T00:00:00Z`).toLocaleDateString(undefined, { - weekday: "short", - month: "short", - day: "numeric", - }) + labelFormatter={(value) => + new Date(`${String(value)}T00:00:00Z`).toLocaleDateString( + undefined, + { + weekday: "short", + month: "short", + day: "numeric", + }, + ) } formatter={(value, name) => [ Number(value).toLocaleString(), diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1c055cc..c7200d7 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -19,6 +19,7 @@ "noEmit": true, "strict": true, "skipLibCheck": true, + "types": ["node"], "noFallthroughCasesInSwitch": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, diff --git a/docs/screenshots/followups-mobile.png b/docs/screenshots/followups-mobile.png new file mode 100644 index 0000000..0fd522c Binary files /dev/null and b/docs/screenshots/followups-mobile.png differ diff --git a/docs/screenshots/home-mobile.png b/docs/screenshots/home-mobile.png new file mode 100644 index 0000000..5205525 Binary files /dev/null and b/docs/screenshots/home-mobile.png differ diff --git a/docs/screenshots/memory-mobile.png b/docs/screenshots/memory-mobile.png new file mode 100644 index 0000000..36266d0 Binary files /dev/null and b/docs/screenshots/memory-mobile.png differ diff --git a/docs/screenshots/queue-mobile.png b/docs/screenshots/queue-mobile.png new file mode 100644 index 0000000..03d89e8 Binary files /dev/null and b/docs/screenshots/queue-mobile.png differ diff --git a/docs/screenshots/settings-mobile.png b/docs/screenshots/settings-mobile.png new file mode 100644 index 0000000..c881f0e Binary files /dev/null and b/docs/screenshots/settings-mobile.png differ diff --git a/package.json b/package.json index ff19e72..5e18432 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "type": "module", "private": true, - "packageManager": "bun@1.3.9", + "packageManager": "bun@1.3.10", "workspaces": [ "apps/*", "packages/*" @@ -23,23 +23,31 @@ "test": "turbo run test", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules .turbo apps/*/.turbo packages/*/.turbo apps/*/dist packages/*/dist apps/*/build packages/*/build packages/convex/_generated bun.lock", "clean:install": "bun run clean && bun install", - "ngrok": "turbo run dev --filter=@clairvoyant/ngrok" + "ngrok": "turbo run dev --filter=@clairvoyant/ngrok", + "prepare": "husky" }, "dependencies": { - "convex": "^1.31.7", - "elysia": "^1.4.24" + "convex": "^1.32.0", + "elysia": "^1.4.26" }, "overrides": { "elysia": "$elysia", "esbuild": "0.25.12" }, + "lint-staged": { + "*.{ts,tsx,js,jsx,json,css}": [ + "bunx biome check --write --no-errors-on-unmatched" + ] + }, "devDependencies": { "@biomejs/biome": "2.1.2", "@boundaryml/baml": "0.215.0", "@types/bun": "latest", - "@types/node": "^24.10.13", + "@types/node": "^24.10.15", + "husky": "^9.1.7", + "lint-staged": "^16.2.7", "tsx": "^4.21.0", - "turbo": "^2.8.7", + "turbo": "^2.8.11", "typescript": "^5.9.3" } } diff --git a/packages/convex/_generated/api.d.ts b/packages/convex/_generated/api.d.ts index 0d89eb7..3c5d346 100644 --- a/packages/convex/_generated/api.d.ts +++ b/packages/convex/_generated/api.d.ts @@ -17,10 +17,14 @@ import type * as cronManagement from "../cronManagement.js"; import type * as dailySummaries from "../dailySummaries.js"; import type * as dailySynthesis from "../dailySynthesis.js"; import type * as displayQueue from "../displayQueue.js"; +import type * as emailEntitlements from "../emailEntitlements.js"; +import type * as emailEntitlementsNode from "../emailEntitlementsNode.js"; import type * as emailEvents from "../emailEvents.js"; import type * as emailNotes from "../emailNotes.js"; import type * as emailReply from "../emailReply.js"; import type * as emailThreadMessages from "../emailThreadMessages.js"; +import type * as emails_EmailThreadPaywall from "../emails/EmailThreadPaywall.js"; +import type * as emails_OptOutCheckout from "../emails/OptOutCheckout.js"; import type * as emails_SessionNote from "../emails/SessionNote.js"; import type * as followups from "../followups.js"; import type * as followupsChat from "../followupsChat.js"; @@ -31,6 +35,7 @@ import type * as http from "../http.js"; import type * as inboundEmail from "../inboundEmail.js"; import type * as init from "../init.js"; import type * as notes from "../notes.js"; +import type * as optOut from "../optOut.js"; import type * as payments from "../payments.js"; import type * as resendClient from "../resendClient.js"; import type * as sessionSummaries from "../sessionSummaries.js"; @@ -38,40 +43,45 @@ import type * as tavilySearch from "../tavilySearch.js"; import type * as users from "../users.js"; import type { - ApiFromModules, - FilterApi, - FunctionReference, + ApiFromModules, + FilterApi, + FunctionReference, } from "convex/server"; declare const fullApi: ApiFromModules<{ - analytics: typeof analytics; - bamlActions: typeof bamlActions; - chat: typeof chat; - chatQueries: typeof chatQueries; - conversationLogs: typeof conversationLogs; - cronManagement: typeof cronManagement; - dailySummaries: typeof dailySummaries; - dailySynthesis: typeof dailySynthesis; - displayQueue: typeof displayQueue; - emailEvents: typeof emailEvents; - emailNotes: typeof emailNotes; - emailReply: typeof emailReply; - emailThreadMessages: typeof emailThreadMessages; - "emails/SessionNote": typeof emails_SessionNote; - followups: typeof followups; - followupsChat: typeof followupsChat; - followupsChatQueries: typeof followupsChatQueries; - honcho: typeof honcho; - honchoSessions: typeof honchoSessions; - http: typeof http; - inboundEmail: typeof inboundEmail; - init: typeof init; - notes: typeof notes; - payments: typeof payments; - resendClient: typeof resendClient; - sessionSummaries: typeof sessionSummaries; - tavilySearch: typeof tavilySearch; - users: typeof users; + analytics: typeof analytics; + bamlActions: typeof bamlActions; + chat: typeof chat; + chatQueries: typeof chatQueries; + conversationLogs: typeof conversationLogs; + cronManagement: typeof cronManagement; + dailySummaries: typeof dailySummaries; + dailySynthesis: typeof dailySynthesis; + displayQueue: typeof displayQueue; + emailEntitlements: typeof emailEntitlements; + emailEntitlementsNode: typeof emailEntitlementsNode; + emailEvents: typeof emailEvents; + emailNotes: typeof emailNotes; + emailReply: typeof emailReply; + emailThreadMessages: typeof emailThreadMessages; + "emails/EmailThreadPaywall": typeof emails_EmailThreadPaywall; + "emails/OptOutCheckout": typeof emails_OptOutCheckout; + "emails/SessionNote": typeof emails_SessionNote; + followups: typeof followups; + followupsChat: typeof followupsChat; + followupsChatQueries: typeof followupsChatQueries; + honcho: typeof honcho; + honchoSessions: typeof honchoSessions; + http: typeof http; + inboundEmail: typeof inboundEmail; + init: typeof init; + notes: typeof notes; + optOut: typeof optOut; + payments: typeof payments; + resendClient: typeof resendClient; + sessionSummaries: typeof sessionSummaries; + tavilySearch: typeof tavilySearch; + users: typeof users; }>; /** @@ -83,8 +93,8 @@ declare const fullApi: ApiFromModules<{ * ``` */ export declare const api: FilterApi< - typeof fullApi, - FunctionReference + typeof fullApi, + FunctionReference >; /** @@ -96,752 +106,752 @@ export declare const api: FilterApi< * ``` */ export declare const internal: FilterApi< - typeof fullApi, - FunctionReference + typeof fullApi, + FunctionReference >; export declare const components: { - polar: { - lib: { - createProduct: FunctionReference< - "mutation", - "internal", - { - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - maximumAmount?: number | null; - minimumAmount?: number | null; - modifiedAt: string | null; - presetAmount?: number | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "day" | "week" | "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "day" | "week" | "month" | "year" | null; - }; - }, - any - >; - createSubscription: FunctionReference< - "mutation", - "internal", - { - subscription: { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "day" | "week" | "month" | "year" | null; - startedAt: string | null; - status: string; - }; - }, - any - >; - getCurrentSubscription: FunctionReference< - "query", - "internal", - { userId: string }, - { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - maximumAmount?: number | null; - minimumAmount?: number | null; - modifiedAt: string | null; - presetAmount?: number | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "day" | "week" | "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "day" | "week" | "month" | "year" | null; - }; - productId: string; - recurringInterval: "day" | "week" | "month" | "year" | null; - startedAt: string | null; - status: string; - } | null - >; - getCustomerByUserId: FunctionReference< - "query", - "internal", - { userId: string }, - { id: string; metadata?: Record; userId: string } | null - >; - getProduct: FunctionReference< - "query", - "internal", - { id: string }, - { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - maximumAmount?: number | null; - minimumAmount?: number | null; - modifiedAt: string | null; - presetAmount?: number | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "day" | "week" | "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "day" | "week" | "month" | "year" | null; - } | null - >; - getSubscription: FunctionReference< - "query", - "internal", - { id: string }, - { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "day" | "week" | "month" | "year" | null; - startedAt: string | null; - status: string; - } | null - >; - insertCustomer: FunctionReference< - "mutation", - "internal", - { id: string; metadata?: Record; userId: string }, - string - >; - listCustomerSubscriptions: FunctionReference< - "query", - "internal", - { customerId: string }, - Array<{ - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "day" | "week" | "month" | "year" | null; - startedAt: string | null; - status: string; - }> - >; - listProducts: FunctionReference< - "query", - "internal", - { includeArchived?: boolean }, - Array<{ - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - priceAmount?: number; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - maximumAmount?: number | null; - minimumAmount?: number | null; - modifiedAt: string | null; - presetAmount?: number | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "day" | "week" | "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "day" | "week" | "month" | "year" | null; - }> - >; - listUserSubscriptions: FunctionReference< - "query", - "internal", - { userId: string }, - Array<{ - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - maximumAmount?: number | null; - minimumAmount?: number | null; - modifiedAt: string | null; - presetAmount?: number | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "day" | "week" | "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "day" | "week" | "month" | "year" | null; - } | null; - productId: string; - recurringInterval: "day" | "week" | "month" | "year" | null; - startedAt: string | null; - status: string; - }> - >; - syncProducts: FunctionReference< - "action", - "internal", - { polarAccessToken: string; server: "sandbox" | "production" }, - any - >; - updateProduct: FunctionReference< - "mutation", - "internal", - { - product: { - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - maximumAmount?: number | null; - minimumAmount?: number | null; - modifiedAt: string | null; - presetAmount?: number | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "day" | "week" | "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "day" | "week" | "month" | "year" | null; - }; - }, - any - >; - updateProducts: FunctionReference< - "mutation", - "internal", - { - polarAccessToken: string; - products: Array<{ - createdAt: string; - description: string | null; - id: string; - isArchived: boolean; - isRecurring: boolean; - medias: Array<{ - checksumEtag: string | null; - checksumSha256Base64: string | null; - checksumSha256Hex: string | null; - createdAt: string; - id: string; - isUploaded: boolean; - lastModifiedAt: string | null; - mimeType: string; - name: string; - organizationId: string; - path: string; - publicUrl: string; - service?: string; - size: number; - sizeReadable: string; - storageVersion: string | null; - version: string | null; - }>; - metadata?: Record; - modifiedAt: string | null; - name: string; - organizationId: string; - prices: Array<{ - amountType?: string; - createdAt: string; - id: string; - isArchived: boolean; - maximumAmount?: number | null; - minimumAmount?: number | null; - modifiedAt: string | null; - presetAmount?: number | null; - priceAmount?: number; - priceCurrency?: string; - productId: string; - recurringInterval?: "day" | "week" | "month" | "year" | null; - type?: string; - }>; - recurringInterval?: "day" | "week" | "month" | "year" | null; - }>; - }, - any - >; - updateSubscription: FunctionReference< - "mutation", - "internal", - { - subscription: { - amount: number | null; - cancelAtPeriodEnd: boolean; - checkoutId: string | null; - createdAt: string; - currency: string | null; - currentPeriodEnd: string | null; - currentPeriodStart: string; - customerCancellationComment?: string | null; - customerCancellationReason?: string | null; - customerId: string; - endedAt: string | null; - id: string; - metadata: Record; - modifiedAt: string | null; - priceId?: string; - productId: string; - recurringInterval: "day" | "week" | "month" | "year" | null; - startedAt: string | null; - status: string; - }; - }, - any - >; - upsertCustomer: FunctionReference< - "mutation", - "internal", - { id: string; metadata?: Record; userId: string }, - string - >; - }; - }; - crons: { - public: { - del: FunctionReference< - "mutation", - "internal", - { identifier: { id: string } | { name: string } }, - null - >; - get: FunctionReference< - "query", - "internal", - { identifier: { id: string } | { name: string } }, - { - args: Record; - functionHandle: string; - id: string; - name?: string; - schedule: - | { kind: "interval"; ms: number } - | { cronspec: string; kind: "cron"; tz?: string }; - } | null - >; - list: FunctionReference< - "query", - "internal", - {}, - Array<{ - args: Record; - functionHandle: string; - id: string; - name?: string; - schedule: - | { kind: "interval"; ms: number } - | { cronspec: string; kind: "cron"; tz?: string }; - }> - >; - register: FunctionReference< - "mutation", - "internal", - { - args: Record; - functionHandle: string; - name?: string; - schedule: - | { kind: "interval"; ms: number } - | { cronspec: string; kind: "cron"; tz?: string }; - }, - string - >; - }; - }; - resend: { - lib: { - cancelEmail: FunctionReference< - "mutation", - "internal", - { emailId: string }, - null - >; - cleanupAbandonedEmails: FunctionReference< - "mutation", - "internal", - { olderThan?: number }, - null - >; - cleanupOldEmails: FunctionReference< - "mutation", - "internal", - { olderThan?: number }, - null - >; - createManualEmail: FunctionReference< - "mutation", - "internal", - { - from: string; - headers?: Array<{ name: string; value: string }>; - replyTo?: Array; - subject: string; - to: Array | string; - }, - string - >; - get: FunctionReference< - "query", - "internal", - { emailId: string }, - { - bcc?: Array; - bounced?: boolean; - cc?: Array; - clicked?: boolean; - complained: boolean; - createdAt: number; - deliveryDelayed?: boolean; - errorMessage?: string; - failed?: boolean; - finalizedAt: number; - from: string; - headers?: Array<{ name: string; value: string }>; - html?: string; - opened: boolean; - replyTo: Array; - resendId?: string; - segment: number; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced" - | "failed"; - subject?: string; - template?: { - id: string; - variables?: Record; - }; - text?: string; - to: Array; - } | null - >; - getStatus: FunctionReference< - "query", - "internal", - { emailId: string }, - { - bounced: boolean; - clicked: boolean; - complained: boolean; - deliveryDelayed: boolean; - errorMessage: string | null; - failed: boolean; - opened: boolean; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced" - | "failed"; - } | null - >; - handleEmailEvent: FunctionReference< - "mutation", - "internal", - { event: any }, - null - >; - sendEmail: FunctionReference< - "mutation", - "internal", - { - bcc?: Array; - cc?: Array; - from: string; - headers?: Array<{ name: string; value: string }>; - html?: string; - options: { - apiKey: string; - initialBackoffMs: number; - onEmailEvent?: { fnHandle: string }; - retryAttempts: number; - testMode: boolean; - }; - replyTo?: Array; - subject?: string; - template?: { - id: string; - variables?: Record; - }; - text?: string; - to: Array; - }, - string - >; - updateManualEmail: FunctionReference< - "mutation", - "internal", - { - emailId: string; - errorMessage?: string; - resendId?: string; - status: - | "waiting" - | "queued" - | "cancelled" - | "sent" - | "delivered" - | "delivery_delayed" - | "bounced" - | "failed"; - }, - null - >; - }; - }; + polar: { + lib: { + createProduct: FunctionReference< + "mutation", + "internal", + { + product: { + createdAt: string; + description: string | null; + id: string; + isArchived: boolean; + isRecurring: boolean; + medias: Array<{ + checksumEtag: string | null; + checksumSha256Base64: string | null; + checksumSha256Hex: string | null; + createdAt: string; + id: string; + isUploaded: boolean; + lastModifiedAt: string | null; + mimeType: string; + name: string; + organizationId: string; + path: string; + publicUrl: string; + service?: string; + size: number; + sizeReadable: string; + storageVersion: string | null; + version: string | null; + }>; + metadata?: Record; + modifiedAt: string | null; + name: string; + organizationId: string; + prices: Array<{ + amountType?: string; + createdAt: string; + id: string; + isArchived: boolean; + maximumAmount?: number | null; + minimumAmount?: number | null; + modifiedAt: string | null; + presetAmount?: number | null; + priceAmount?: number; + priceCurrency?: string; + productId: string; + recurringInterval?: "day" | "week" | "month" | "year" | null; + type?: string; + }>; + recurringInterval?: "day" | "week" | "month" | "year" | null; + }; + }, + any + >; + createSubscription: FunctionReference< + "mutation", + "internal", + { + subscription: { + amount: number | null; + cancelAtPeriodEnd: boolean; + checkoutId: string | null; + createdAt: string; + currency: string | null; + currentPeriodEnd: string | null; + currentPeriodStart: string; + customerCancellationComment?: string | null; + customerCancellationReason?: string | null; + customerId: string; + endedAt: string | null; + id: string; + metadata: Record; + modifiedAt: string | null; + priceId?: string; + productId: string; + recurringInterval: "day" | "week" | "month" | "year" | null; + startedAt: string | null; + status: string; + }; + }, + any + >; + getCurrentSubscription: FunctionReference< + "query", + "internal", + { userId: string }, + { + amount: number | null; + cancelAtPeriodEnd: boolean; + checkoutId: string | null; + createdAt: string; + currency: string | null; + currentPeriodEnd: string | null; + currentPeriodStart: string; + customerCancellationComment?: string | null; + customerCancellationReason?: string | null; + customerId: string; + endedAt: string | null; + id: string; + metadata: Record; + modifiedAt: string | null; + priceId?: string; + product: { + createdAt: string; + description: string | null; + id: string; + isArchived: boolean; + isRecurring: boolean; + medias: Array<{ + checksumEtag: string | null; + checksumSha256Base64: string | null; + checksumSha256Hex: string | null; + createdAt: string; + id: string; + isUploaded: boolean; + lastModifiedAt: string | null; + mimeType: string; + name: string; + organizationId: string; + path: string; + publicUrl: string; + service?: string; + size: number; + sizeReadable: string; + storageVersion: string | null; + version: string | null; + }>; + metadata?: Record; + modifiedAt: string | null; + name: string; + organizationId: string; + prices: Array<{ + amountType?: string; + createdAt: string; + id: string; + isArchived: boolean; + maximumAmount?: number | null; + minimumAmount?: number | null; + modifiedAt: string | null; + presetAmount?: number | null; + priceAmount?: number; + priceCurrency?: string; + productId: string; + recurringInterval?: "day" | "week" | "month" | "year" | null; + type?: string; + }>; + recurringInterval?: "day" | "week" | "month" | "year" | null; + }; + productId: string; + recurringInterval: "day" | "week" | "month" | "year" | null; + startedAt: string | null; + status: string; + } | null + >; + getCustomerByUserId: FunctionReference< + "query", + "internal", + { userId: string }, + { id: string; metadata?: Record; userId: string } | null + >; + getProduct: FunctionReference< + "query", + "internal", + { id: string }, + { + createdAt: string; + description: string | null; + id: string; + isArchived: boolean; + isRecurring: boolean; + medias: Array<{ + checksumEtag: string | null; + checksumSha256Base64: string | null; + checksumSha256Hex: string | null; + createdAt: string; + id: string; + isUploaded: boolean; + lastModifiedAt: string | null; + mimeType: string; + name: string; + organizationId: string; + path: string; + publicUrl: string; + service?: string; + size: number; + sizeReadable: string; + storageVersion: string | null; + version: string | null; + }>; + metadata?: Record; + modifiedAt: string | null; + name: string; + organizationId: string; + prices: Array<{ + amountType?: string; + createdAt: string; + id: string; + isArchived: boolean; + maximumAmount?: number | null; + minimumAmount?: number | null; + modifiedAt: string | null; + presetAmount?: number | null; + priceAmount?: number; + priceCurrency?: string; + productId: string; + recurringInterval?: "day" | "week" | "month" | "year" | null; + type?: string; + }>; + recurringInterval?: "day" | "week" | "month" | "year" | null; + } | null + >; + getSubscription: FunctionReference< + "query", + "internal", + { id: string }, + { + amount: number | null; + cancelAtPeriodEnd: boolean; + checkoutId: string | null; + createdAt: string; + currency: string | null; + currentPeriodEnd: string | null; + currentPeriodStart: string; + customerCancellationComment?: string | null; + customerCancellationReason?: string | null; + customerId: string; + endedAt: string | null; + id: string; + metadata: Record; + modifiedAt: string | null; + priceId?: string; + productId: string; + recurringInterval: "day" | "week" | "month" | "year" | null; + startedAt: string | null; + status: string; + } | null + >; + insertCustomer: FunctionReference< + "mutation", + "internal", + { id: string; metadata?: Record; userId: string }, + string + >; + listCustomerSubscriptions: FunctionReference< + "query", + "internal", + { customerId: string }, + Array<{ + amount: number | null; + cancelAtPeriodEnd: boolean; + checkoutId: string | null; + createdAt: string; + currency: string | null; + currentPeriodEnd: string | null; + currentPeriodStart: string; + customerCancellationComment?: string | null; + customerCancellationReason?: string | null; + customerId: string; + endedAt: string | null; + id: string; + metadata: Record; + modifiedAt: string | null; + priceId?: string; + productId: string; + recurringInterval: "day" | "week" | "month" | "year" | null; + startedAt: string | null; + status: string; + }> + >; + listProducts: FunctionReference< + "query", + "internal", + { includeArchived?: boolean }, + Array<{ + createdAt: string; + description: string | null; + id: string; + isArchived: boolean; + isRecurring: boolean; + medias: Array<{ + checksumEtag: string | null; + checksumSha256Base64: string | null; + checksumSha256Hex: string | null; + createdAt: string; + id: string; + isUploaded: boolean; + lastModifiedAt: string | null; + mimeType: string; + name: string; + organizationId: string; + path: string; + publicUrl: string; + service?: string; + size: number; + sizeReadable: string; + storageVersion: string | null; + version: string | null; + }>; + metadata?: Record; + modifiedAt: string | null; + name: string; + organizationId: string; + priceAmount?: number; + prices: Array<{ + amountType?: string; + createdAt: string; + id: string; + isArchived: boolean; + maximumAmount?: number | null; + minimumAmount?: number | null; + modifiedAt: string | null; + presetAmount?: number | null; + priceAmount?: number; + priceCurrency?: string; + productId: string; + recurringInterval?: "day" | "week" | "month" | "year" | null; + type?: string; + }>; + recurringInterval?: "day" | "week" | "month" | "year" | null; + }> + >; + listUserSubscriptions: FunctionReference< + "query", + "internal", + { userId: string }, + Array<{ + amount: number | null; + cancelAtPeriodEnd: boolean; + checkoutId: string | null; + createdAt: string; + currency: string | null; + currentPeriodEnd: string | null; + currentPeriodStart: string; + customerCancellationComment?: string | null; + customerCancellationReason?: string | null; + customerId: string; + endedAt: string | null; + id: string; + metadata: Record; + modifiedAt: string | null; + priceId?: string; + product: { + createdAt: string; + description: string | null; + id: string; + isArchived: boolean; + isRecurring: boolean; + medias: Array<{ + checksumEtag: string | null; + checksumSha256Base64: string | null; + checksumSha256Hex: string | null; + createdAt: string; + id: string; + isUploaded: boolean; + lastModifiedAt: string | null; + mimeType: string; + name: string; + organizationId: string; + path: string; + publicUrl: string; + service?: string; + size: number; + sizeReadable: string; + storageVersion: string | null; + version: string | null; + }>; + metadata?: Record; + modifiedAt: string | null; + name: string; + organizationId: string; + prices: Array<{ + amountType?: string; + createdAt: string; + id: string; + isArchived: boolean; + maximumAmount?: number | null; + minimumAmount?: number | null; + modifiedAt: string | null; + presetAmount?: number | null; + priceAmount?: number; + priceCurrency?: string; + productId: string; + recurringInterval?: "day" | "week" | "month" | "year" | null; + type?: string; + }>; + recurringInterval?: "day" | "week" | "month" | "year" | null; + } | null; + productId: string; + recurringInterval: "day" | "week" | "month" | "year" | null; + startedAt: string | null; + status: string; + }> + >; + syncProducts: FunctionReference< + "action", + "internal", + { polarAccessToken: string; server: "sandbox" | "production" }, + any + >; + updateProduct: FunctionReference< + "mutation", + "internal", + { + product: { + createdAt: string; + description: string | null; + id: string; + isArchived: boolean; + isRecurring: boolean; + medias: Array<{ + checksumEtag: string | null; + checksumSha256Base64: string | null; + checksumSha256Hex: string | null; + createdAt: string; + id: string; + isUploaded: boolean; + lastModifiedAt: string | null; + mimeType: string; + name: string; + organizationId: string; + path: string; + publicUrl: string; + service?: string; + size: number; + sizeReadable: string; + storageVersion: string | null; + version: string | null; + }>; + metadata?: Record; + modifiedAt: string | null; + name: string; + organizationId: string; + prices: Array<{ + amountType?: string; + createdAt: string; + id: string; + isArchived: boolean; + maximumAmount?: number | null; + minimumAmount?: number | null; + modifiedAt: string | null; + presetAmount?: number | null; + priceAmount?: number; + priceCurrency?: string; + productId: string; + recurringInterval?: "day" | "week" | "month" | "year" | null; + type?: string; + }>; + recurringInterval?: "day" | "week" | "month" | "year" | null; + }; + }, + any + >; + updateProducts: FunctionReference< + "mutation", + "internal", + { + polarAccessToken: string; + products: Array<{ + createdAt: string; + description: string | null; + id: string; + isArchived: boolean; + isRecurring: boolean; + medias: Array<{ + checksumEtag: string | null; + checksumSha256Base64: string | null; + checksumSha256Hex: string | null; + createdAt: string; + id: string; + isUploaded: boolean; + lastModifiedAt: string | null; + mimeType: string; + name: string; + organizationId: string; + path: string; + publicUrl: string; + service?: string; + size: number; + sizeReadable: string; + storageVersion: string | null; + version: string | null; + }>; + metadata?: Record; + modifiedAt: string | null; + name: string; + organizationId: string; + prices: Array<{ + amountType?: string; + createdAt: string; + id: string; + isArchived: boolean; + maximumAmount?: number | null; + minimumAmount?: number | null; + modifiedAt: string | null; + presetAmount?: number | null; + priceAmount?: number; + priceCurrency?: string; + productId: string; + recurringInterval?: "day" | "week" | "month" | "year" | null; + type?: string; + }>; + recurringInterval?: "day" | "week" | "month" | "year" | null; + }>; + }, + any + >; + updateSubscription: FunctionReference< + "mutation", + "internal", + { + subscription: { + amount: number | null; + cancelAtPeriodEnd: boolean; + checkoutId: string | null; + createdAt: string; + currency: string | null; + currentPeriodEnd: string | null; + currentPeriodStart: string; + customerCancellationComment?: string | null; + customerCancellationReason?: string | null; + customerId: string; + endedAt: string | null; + id: string; + metadata: Record; + modifiedAt: string | null; + priceId?: string; + productId: string; + recurringInterval: "day" | "week" | "month" | "year" | null; + startedAt: string | null; + status: string; + }; + }, + any + >; + upsertCustomer: FunctionReference< + "mutation", + "internal", + { id: string; metadata?: Record; userId: string }, + string + >; + }; + }; + crons: { + public: { + del: FunctionReference< + "mutation", + "internal", + { identifier: { id: string } | { name: string } }, + null + >; + get: FunctionReference< + "query", + "internal", + { identifier: { id: string } | { name: string } }, + { + args: Record; + functionHandle: string; + id: string; + name?: string; + schedule: + | { kind: "interval"; ms: number } + | { cronspec: string; kind: "cron"; tz?: string }; + } | null + >; + list: FunctionReference< + "query", + "internal", + {}, + Array<{ + args: Record; + functionHandle: string; + id: string; + name?: string; + schedule: + | { kind: "interval"; ms: number } + | { cronspec: string; kind: "cron"; tz?: string }; + }> + >; + register: FunctionReference< + "mutation", + "internal", + { + args: Record; + functionHandle: string; + name?: string; + schedule: + | { kind: "interval"; ms: number } + | { cronspec: string; kind: "cron"; tz?: string }; + }, + string + >; + }; + }; + resend: { + lib: { + cancelEmail: FunctionReference< + "mutation", + "internal", + { emailId: string }, + null + >; + cleanupAbandonedEmails: FunctionReference< + "mutation", + "internal", + { olderThan?: number }, + null + >; + cleanupOldEmails: FunctionReference< + "mutation", + "internal", + { olderThan?: number }, + null + >; + createManualEmail: FunctionReference< + "mutation", + "internal", + { + from: string; + headers?: Array<{ name: string; value: string }>; + replyTo?: Array; + subject: string; + to: Array | string; + }, + string + >; + get: FunctionReference< + "query", + "internal", + { emailId: string }, + { + bcc?: Array; + bounced?: boolean; + cc?: Array; + clicked?: boolean; + complained: boolean; + createdAt: number; + deliveryDelayed?: boolean; + errorMessage?: string; + failed?: boolean; + finalizedAt: number; + from: string; + headers?: Array<{ name: string; value: string }>; + html?: string; + opened: boolean; + replyTo: Array; + resendId?: string; + segment: number; + status: + | "waiting" + | "queued" + | "cancelled" + | "sent" + | "delivered" + | "delivery_delayed" + | "bounced" + | "failed"; + subject?: string; + template?: { + id: string; + variables?: Record; + }; + text?: string; + to: Array; + } | null + >; + getStatus: FunctionReference< + "query", + "internal", + { emailId: string }, + { + bounced: boolean; + clicked: boolean; + complained: boolean; + deliveryDelayed: boolean; + errorMessage: string | null; + failed: boolean; + opened: boolean; + status: + | "waiting" + | "queued" + | "cancelled" + | "sent" + | "delivered" + | "delivery_delayed" + | "bounced" + | "failed"; + } | null + >; + handleEmailEvent: FunctionReference< + "mutation", + "internal", + { event: any }, + null + >; + sendEmail: FunctionReference< + "mutation", + "internal", + { + bcc?: Array; + cc?: Array; + from: string; + headers?: Array<{ name: string; value: string }>; + html?: string; + options: { + apiKey: string; + initialBackoffMs: number; + onEmailEvent?: { fnHandle: string }; + retryAttempts: number; + testMode: boolean; + }; + replyTo?: Array; + subject?: string; + template?: { + id: string; + variables?: Record; + }; + text?: string; + to: Array; + }, + string + >; + updateManualEmail: FunctionReference< + "mutation", + "internal", + { + emailId: string; + errorMessage?: string; + resendId?: string; + status: + | "waiting" + | "queued" + | "cancelled" + | "sent" + | "delivered" + | "delivery_delayed" + | "bounced" + | "failed"; + }, + null + >; + }; + }; }; diff --git a/packages/convex/conversationLogs.ts b/packages/convex/conversationLogs.ts index 3c4e235..6a230bb 100644 --- a/packages/convex/conversationLogs.ts +++ b/packages/convex/conversationLogs.ts @@ -10,6 +10,11 @@ export const logConversation = mutation({ response: v.optional(v.string()), }, handler: async (ctx, args) => { + const user = await ctx.db.get(args.userId); + if (user?.optedOutOfTraining) { + return null; + } + return await ctx.db.insert("conversationLogs", { userId: args.userId, sessionId: args.sessionId, diff --git a/packages/convex/emailEntitlements.ts b/packages/convex/emailEntitlements.ts new file mode 100644 index 0000000..ac5fc78 --- /dev/null +++ b/packages/convex/emailEntitlements.ts @@ -0,0 +1,117 @@ +import { v } from "convex/values"; +import { internalMutation, internalQuery, query } from "./_generated/server"; + +export const FREE_EMAIL_THREAD_LIMIT = 10; + +function currentPeriodKey(): string { + const now = new Date(); + const month = String(now.getUTCMonth() + 1).padStart(2, "0"); + return `${now.getUTCFullYear()}-${month}`; +} + +export const getEmailThreadStatus = query({ + args: { mentraUserId: v.string() }, + handler: async (ctx, args) => { + const user = await ctx.db + .query("users") + .withIndex("by_mentra_id", (q) => q.eq("mentraUserId", args.mentraUserId)) + .first(); + + if (!user) { + return null; + } + + const periodKey = currentPeriodKey(); + const usage = await ctx.db + .query("emailThreadUsage") + .withIndex("by_user_period", (q) => + q.eq("userId", user._id).eq("periodKey", periodKey), + ) + .first(); + + const used = usage?.outboundCount ?? 0; + const limit = FREE_EMAIL_THREAD_LIMIT; + + return { + periodKey, + paidEmailThreads: user.paidEmailThreads ?? false, + used, + limit, + remaining: Math.max(0, limit - used), + }; + }, +}); + +export const getUsageByUserPeriod = internalQuery({ + args: { + userId: v.id("users"), + periodKey: v.string(), + }, + handler: async (ctx, args) => { + return await ctx.db + .query("emailThreadUsage") + .withIndex("by_user_period", (q) => + q.eq("userId", args.userId).eq("periodKey", args.periodKey), + ) + .first(); + }, +}); + +export const incrementOutboundUsage = internalMutation({ + args: { + userId: v.id("users"), + periodKey: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const periodKey = args.periodKey ?? currentPeriodKey(); + const existing = await ctx.db + .query("emailThreadUsage") + .withIndex("by_user_period", (q) => + q.eq("userId", args.userId).eq("periodKey", periodKey), + ) + .first(); + + if (existing) { + await ctx.db.patch(existing._id, { + outboundCount: existing.outboundCount + 1, + }); + return existing._id; + } + + return await ctx.db.insert("emailThreadUsage", { + userId: args.userId, + periodKey, + outboundCount: 1, + }); + }, +}); + +export const markPaywallSent = internalMutation({ + args: { + userId: v.id("users"), + periodKey: v.string(), + }, + handler: async (ctx, args) => { + const existing = await ctx.db + .query("emailThreadUsage") + .withIndex("by_user_period", (q) => + q.eq("userId", args.userId).eq("periodKey", args.periodKey), + ) + .first(); + + const timestamp = new Date().toISOString(); + if (existing) { + await ctx.db.patch(existing._id, { + paywallEmailSentAt: timestamp, + }); + return existing._id; + } + + return await ctx.db.insert("emailThreadUsage", { + userId: args.userId, + periodKey: args.periodKey, + outboundCount: FREE_EMAIL_THREAD_LIMIT, + paywallEmailSentAt: timestamp, + }); + }, +}); diff --git a/packages/convex/emailEntitlementsNode.ts b/packages/convex/emailEntitlementsNode.ts new file mode 100644 index 0000000..40046a9 --- /dev/null +++ b/packages/convex/emailEntitlementsNode.ts @@ -0,0 +1,96 @@ +"use node"; + +import { render } from "@react-email/render"; +import { v } from "convex/values"; +import { internal } from "./_generated/api"; +import { internalAction } from "./_generated/server"; +import { FREE_EMAIL_THREAD_LIMIT } from "./emailEntitlements"; +import { EmailThreadPaywallEmail } from "./emails/EmailThreadPaywall"; +import { resend } from "./resendClient"; + +const EMAIL_DOMAIN = process.env.EMAIL_DOMAIN || "notes.example.com"; + +function currentPeriodKey(): string { + const now = new Date(); + const month = String(now.getUTCMonth() + 1).padStart(2, "0"); + return `${now.getUTCFullYear()}-${month}`; +} + +export const preflightOutboundEmail = internalAction({ + args: { userId: v.id("users") }, + handler: async ( + ctx, + args, + ): Promise<{ allowed: boolean; trackUsage: boolean; reason?: string }> => { + const user = await ctx.runQuery(internal.users.getByIdInternal, { + userId: args.userId, + }); + if (!user) { + return { + allowed: false as const, + trackUsage: false, + reason: "user_not_found", + }; + } + + if (user.paidEmailThreads) { + return { allowed: true as const, trackUsage: false }; + } + + const periodKey = currentPeriodKey(); + const usage = await ctx.runQuery( + internal.emailEntitlements.getUsageByUserPeriod, + { + userId: args.userId, + periodKey, + }, + ); + + const used = usage?.outboundCount ?? 0; + if (used < FREE_EMAIL_THREAD_LIMIT) { + return { allowed: true as const, trackUsage: true }; + } + + if (!usage?.paywallEmailSentAt && user.email) { + try { + const checkout = await ctx.runAction( + internal.payments.createFeatureCheckoutLinkInternal, + { + userId: args.userId, + feature: "emailThreads", + }, + ); + + const html = await render( + EmailThreadPaywallEmail({ + checkoutUrl: checkout.url, + limit: FREE_EMAIL_THREAD_LIMIT, + }), + ); + + await resend.sendEmail(ctx, { + from: `Clairvoyant `, + to: user.email, + subject: "Continue your Clairvoyant email thread", + html, + }); + + await ctx.runMutation(internal.emailEntitlements.markPaywallSent, { + userId: args.userId, + periodKey, + }); + } catch (error) { + console.error( + "[EmailEntitlements] Failed to send paywall email:", + error, + ); + } + } + + return { + allowed: false as const, + trackUsage: false, + reason: "limit_reached", + }; + }, +}); diff --git a/packages/convex/emailReply.ts b/packages/convex/emailReply.ts index f98c46e..dbd73ef 100644 --- a/packages/convex/emailReply.ts +++ b/packages/convex/emailReply.ts @@ -261,22 +261,25 @@ export const processEmailReply = internalAction({ const diatribePeer = await honchoClient.peer(`${user._id}-diatribe`, { metadata: { name: "Diatribe", - description: "A peer that listens to the raw translations of the users' speech.", + description: + "A peer that listens to the raw translations of the users' speech.", }, }); const synthesisPeer = await honchoClient.peer(`${user._id}-synthesis`, { metadata: { name: "Synthesis Peer", - description: "A peer that captures synthesized knowledge from the user's speech.", + description: + "A peer that captures synthesized knowledge from the user's speech.", }, }); await session.addPeers([diatribePeer, synthesisPeer]); // Add user email message + extracted facts to diatribe peer - const userContent = interpretation.extractedFacts.length > 0 - ? `${textContent}\n\nExtracted facts:\n${interpretation.extractedFacts.map((f) => `• ${f}`).join("\n")}` - : textContent; + const userContent = + interpretation.extractedFacts.length > 0 + ? `${textContent}\n\nExtracted facts:\n${interpretation.extractedFacts.map((f) => `• ${f}`).join("\n")}` + : textContent; await session.addMessages([ { @@ -290,7 +293,9 @@ export const processEmailReply = internalAction({ }, }, ]); - console.log(`[EmailReply] ✓ Added user message to diatribe peer${interpretation.extractedFacts.length > 0 ? ` with ${interpretation.extractedFacts.length} facts` : ""}`); + console.log( + `[EmailReply] ✓ Added user message to diatribe peer${interpretation.extractedFacts.length > 0 ? ` with ${interpretation.extractedFacts.length} facts` : ""}`, + ); // Add assistant response to synthesis peer await session.addMessages([ @@ -305,7 +310,9 @@ export const processEmailReply = internalAction({ }, }, ]); - console.log(`[EmailReply] ✓ Added assistant response to synthesis peer`); + console.log( + `[EmailReply] ✓ Added assistant response to synthesis peer`, + ); } catch (error) { console.warn( `[EmailReply] ✗ Failed to update Honcho memory: ${error instanceof Error ? error.message : String(error)}`, @@ -356,6 +363,16 @@ export const processEmailReply = internalAction({ // Step 9: Build and send reply email with proper threading headers console.log("[EmailReply] Step 9: Building and sending reply email..."); + const access = await ctx.runAction( + internal.emailEntitlementsNode.preflightOutboundEmail, + { + userId: user._id, + }, + ); + if (!access.allowed) { + return { success: false, error: access.reason ?? "email_limit_reached" }; + } + const newMessageId = generateMessageId(); const inReplyTo = inboundMessageId; @@ -415,6 +432,15 @@ Sent by Clairvoyant`; resendEmailId, textContent: replyContent, }); + + if (access.trackUsage) { + await ctx.runMutation( + internal.emailEntitlements.incrementOutboundUsage, + { + userId: user._id, + }, + ); + } console.log(`[EmailReply] ✓ Stored outbound message in thread`); const totalTime = Date.now() - startTime; diff --git a/packages/convex/emails/EmailThreadPaywall.tsx b/packages/convex/emails/EmailThreadPaywall.tsx new file mode 100644 index 0000000..e2dd22b --- /dev/null +++ b/packages/convex/emails/EmailThreadPaywall.tsx @@ -0,0 +1,117 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Hr, + Html, + Preview, + Section, + Text, +} from "@react-email/components"; + +interface EmailThreadPaywallEmailProps { + checkoutUrl: string; + limit: number; +} + +export function EmailThreadPaywallEmail({ + checkoutUrl, + limit, +}: EmailThreadPaywallEmailProps) { + return ( + + + Your Clairvoyant email thread limit was reached + + + Email Thread Limit Reached + +
+ + You have reached your free monthly limit of {limit} email thread + messages. + + + Subscribe to continue receiving threaded email responses. + +
+ +
+ +
+ +
+ +
+ + Sent by Clairvoyant +
+ + + ); +} + +const main = { + backgroundColor: "#f6f9fc", + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif', +}; + +const container = { + backgroundColor: "#ffffff", + margin: "0 auto", + padding: "40px 20px", + maxWidth: "560px", + borderRadius: "8px", +}; + +const heading = { + fontSize: "24px", + fontWeight: "600", + color: "#1a1a1a", + margin: "0 0 24px", + padding: "0", +}; + +const paragraph = { + fontSize: "14px", + lineHeight: "24px", + color: "#333333", + margin: "0 0 16px", + padding: "0", +}; + +const hr = { + borderColor: "#e6e6e6", + margin: "24px 0", +}; + +const buttonContainer = { + textAlign: "center" as const, +}; + +const button = { + backgroundColor: "#1a1a1a", + borderRadius: "6px", + color: "#ffffff", + fontSize: "16px", + fontWeight: "600", + textDecoration: "none", + textAlign: "center" as const, + display: "inline-block", + padding: "12px 24px", +}; + +const footer = { + fontSize: "12px", + color: "#999999", + textAlign: "center" as const, + margin: "0", + padding: "0", +}; + +export default EmailThreadPaywallEmail; diff --git a/packages/convex/emails/OptOutCheckout.tsx b/packages/convex/emails/OptOutCheckout.tsx new file mode 100644 index 0000000..fb5abd5 --- /dev/null +++ b/packages/convex/emails/OptOutCheckout.tsx @@ -0,0 +1,114 @@ +import { + Body, + Button, + Container, + Head, + Heading, + Hr, + Html, + Preview, + Section, + Text, +} from "@react-email/components"; + +interface OptOutCheckoutEmailProps { + checkoutUrl: string; +} + +export function OptOutCheckoutEmail({ checkoutUrl }: OptOutCheckoutEmailProps) { + return ( + + + Enable paid opt-out for Clairvoyant training + + + Opt Out Of Training + +
+ + You requested to opt out of Clairvoyant model training. This is a + paid privacy feature. + + + Activate this subscription and your new conversations will no + longer be logged for training. + +
+ +
+ +
+ +
+ +
+ + Sent by Clairvoyant +
+ + + ); +} + +const main = { + backgroundColor: "#f6f9fc", + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif', +}; + +const container = { + backgroundColor: "#ffffff", + margin: "0 auto", + padding: "40px 20px", + maxWidth: "560px", + borderRadius: "8px", +}; + +const heading = { + fontSize: "24px", + fontWeight: "600", + color: "#1a1a1a", + margin: "0 0 24px", + padding: "0", +}; + +const paragraph = { + fontSize: "14px", + lineHeight: "24px", + color: "#333333", + margin: "0 0 16px", + padding: "0", +}; + +const hr = { + borderColor: "#e6e6e6", + margin: "24px 0", +}; + +const buttonContainer = { + textAlign: "center" as const, +}; + +const button = { + backgroundColor: "#1a1a1a", + borderRadius: "6px", + color: "#ffffff", + fontSize: "16px", + fontWeight: "600", + textDecoration: "none", + textAlign: "center" as const, + display: "inline-block", + padding: "12px 24px", +}; + +const footer = { + fontSize: "12px", + color: "#999999", + textAlign: "center" as const, + margin: "0", + padding: "0", +}; + +export default OptOutCheckoutEmail; diff --git a/packages/convex/http.ts b/packages/convex/http.ts index cbfddc2..13d7f1d 100644 --- a/packages/convex/http.ts +++ b/packages/convex/http.ts @@ -14,6 +14,7 @@ polar.registerRoutes(http, { event.data.customer?.externalId ?? (metadataUserId ? String(metadataUserId) : undefined); const customerId = event.data.customer?.id; + const productId = event.data.productId; if (!userId) { console.warn( @@ -23,9 +24,23 @@ polar.registerRoutes(http, { } console.log( - `[Polar Webhook] Subscription created for user ${userId}, customer ${customerId}`, + `[Polar Webhook] Subscription created for user ${userId}, customer ${customerId}, product ${productId}`, ); + if (productId === polar.products.optOut) { + await ctx.runMutation(internal.users.setOptOutStatus, { + userId: userId as import("./_generated/dataModel").Id<"users">, + optedOut: true, + }); + } + + if (productId === polar.products.emailThreads) { + await ctx.runMutation(internal.users.setPaidEmailThreadsStatus, { + userId: userId as import("./_generated/dataModel").Id<"users">, + paidEmailThreads: true, + }); + } + await ctx.runMutation( internal.payments.scheduleSubscriptionCreatedHandler, { @@ -34,6 +49,38 @@ polar.registerRoutes(http, { }, ); }, + onSubscriptionUpdated: async (ctx, event) => { + const metadataUserId = event.data.customer?.metadata?.userId; + const userId = + event.data.customer?.externalId ?? + (metadataUserId ? String(metadataUserId) : undefined); + if (!userId) { + return; + } + + const productId = event.data.productId; + const endedStatuses = new Set([ + "canceled", + "past_due", + "unpaid", + "revoked", + ]); + const isActive = !endedStatuses.has(event.data.status); + + if (productId === polar.products.optOut) { + await ctx.runMutation(internal.users.setOptOutStatus, { + userId: userId as import("./_generated/dataModel").Id<"users">, + optedOut: isActive, + }); + } + + if (productId === polar.products.emailThreads) { + await ctx.runMutation(internal.users.setPaidEmailThreadsStatus, { + userId: userId as import("./_generated/dataModel").Id<"users">, + paidEmailThreads: isActive, + }); + } + }, }); http.route({ diff --git a/packages/convex/notes.ts b/packages/convex/notes.ts index 0fe6ada..f239fba 100644 --- a/packages/convex/notes.ts +++ b/packages/convex/notes.ts @@ -44,6 +44,16 @@ export const sendNoteEmail = action({ return { success: false, reason: "no_email_configured" }; } + const access = await ctx.runAction( + internal.emailEntitlementsNode.preflightOutboundEmail, + { + userId: user._id, + }, + ); + if (!access.allowed) { + return { success: false, reason: access.reason ?? "email_limit_reached" }; + } + const sessionDate = new Date().toLocaleDateString("en-US", { weekday: "long", year: "numeric", @@ -93,6 +103,15 @@ export const sendNoteEmail = action({ textContent, }); + if (access.trackUsage) { + await ctx.runMutation( + internal.emailEntitlements.incrementOutboundUsage, + { + userId: user._id, + }, + ); + } + console.log( `[Notes] Email queued successfully to ${user.email} (noteId: ${emailNoteId}, resendId: ${resendEmailId})`, ); diff --git a/packages/convex/optOut.ts b/packages/convex/optOut.ts new file mode 100644 index 0000000..38505de --- /dev/null +++ b/packages/convex/optOut.ts @@ -0,0 +1,52 @@ +"use node"; + +import { render } from "@react-email/render"; +import { v } from "convex/values"; +import { api, internal } from "./_generated/api"; +import { action } from "./_generated/server"; +import { OptOutCheckoutEmail } from "./emails/OptOutCheckout"; +import { resend } from "./resendClient"; + +const EMAIL_DOMAIN = process.env.EMAIL_DOMAIN || "notes.example.com"; + +export const requestOptOutCheckoutEmail = action({ + args: { mentraUserId: v.string() }, + handler: async (ctx, args) => { + const user = await ctx.runQuery(api.users.getByMentraId, { + mentraUserId: args.mentraUserId, + }); + + if (!user) { + return { success: false as const, reason: "user_not_found" }; + } + + if (!user.email) { + return { success: false as const, reason: "no_email_configured" }; + } + + if (user.optedOutOfTraining) { + return { success: true as const, alreadyOptedOut: true }; + } + + const checkout = await ctx.runAction( + internal.payments.createFeatureCheckoutLinkInternal, + { + userId: user._id, + feature: "optOut", + }, + ); + + const html = await render( + OptOutCheckoutEmail({ checkoutUrl: checkout.url }), + ); + + await resend.sendEmail(ctx, { + from: `Clairvoyant `, + to: user.email, + subject: "Complete your paid opt-out setup", + html, + }); + + return { success: true as const, alreadyOptedOut: false }; + }, +}); diff --git a/packages/convex/package.json b/packages/convex/package.json index c9dd274..dcc8a44 100644 --- a/packages/convex/package.json +++ b/packages/convex/package.json @@ -17,14 +17,14 @@ "@convex-dev/resend": "^0.2.3", "@honcho-ai/sdk": "^1.6.0", "@polar-sh/sdk": "^0.41.5", - "@react-email/components": "^1.0.7", + "@react-email/components": "^1.0.8", "@react-email/render": "^2.0.4", - "convex": "^1.31.7", + "convex": "^1.32.0", "groq-sdk": "^0.18.0", "react": "^19.2.4", "react-dom": "^19.2.4", "react-email": "^5.2.8", - "svix": "^1.84.1", + "svix": "^1.86.0", "zod": "^3.25.76" }, "devDependencies": { diff --git a/packages/convex/payments.ts b/packages/convex/payments.ts index bfb939a..7a2e454 100644 --- a/packages/convex/payments.ts +++ b/packages/convex/payments.ts @@ -10,6 +10,12 @@ import { query, } from "./_generated/server"; +const DEFAULT_SETTINGS_URL = process.env.WEB_APP_URL + ? `${process.env.WEB_APP_URL.replace(/\/$/, "")}/settings` + : "https://clairvoyant.app/settings"; + +type PaidFeatureProduct = "optOut" | "emailThreads"; + // ============================================================================= // Polar Types (inferred from polar.listProducts return type) // ============================================================================= @@ -69,6 +75,10 @@ export type PolarProduct = { // ============================================================================= export const polar = new Polar(components.polar, { + products: { + optOut: process.env.POLAR_OPT_OUT_PRODUCT_ID ?? "", + emailThreads: process.env.POLAR_EMAIL_THREADS_PRODUCT_ID ?? "", + }, getUserInfo: async (ctx): Promise<{ userId: Id<"users">; email: string }> => { const user = await ctx.runQuery(api.users.getCurrentUser); return { @@ -190,6 +200,32 @@ export const getCustomerInfo = action({ }, }); +export const createFeatureCheckoutLink = action({ + args: { + mentraUserId: v.string(), + feature: v.union(v.literal("optOut"), v.literal("emailThreads")), + successUrl: v.optional(v.string()), + }, + handler: async (ctx, args): Promise<{ url: string }> => { + const user = await ctx.runQuery(api.users.getByMentraId, { + mentraUserId: args.mentraUserId, + }); + + if (!user) { + throw new Error("User not found"); + } + + return await ctx.runAction( + internal.payments.createFeatureCheckoutLinkInternal, + { + userId: user._id, + feature: args.feature, + successUrl: args.successUrl, + }, + ); + }, +}); + // ============================================================================= // Internal Mutations // ============================================================================= @@ -312,3 +348,60 @@ export const handleSubscriptionCreated = internalAction({ } }, }); + +export const createFeatureCheckoutLinkInternal = internalAction({ + args: { + userId: v.id("users"), + feature: v.union(v.literal("optOut"), v.literal("emailThreads")), + successUrl: v.optional(v.string()), + }, + handler: async (ctx, args): Promise<{ url: string }> => { + const user = await ctx.runQuery(internal.users.getByIdInternal, { + userId: args.userId, + }); + if (!user) { + throw new Error("User not found"); + } + + const accessToken = + process.env.POLAR_ACCESS_TOKEN ?? process.env.POLAR_ORGANIZATION_TOKEN; + if (!accessToken) { + throw new Error( + "POLAR_ACCESS_TOKEN or POLAR_ORGANIZATION_TOKEN environment variable is not set", + ); + } + + const productId = getFeatureProductId(args.feature); + if (!productId) { + throw new Error( + `Polar product is not configured for feature: ${args.feature}`, + ); + } + + const polarSDK = new PolarSDK({ + accessToken, + server: process.env.POLAR_SERVER as "production" | "sandbox" | undefined, + }); + + const checkout = await polarSDK.checkouts.create({ + products: [productId], + externalCustomerId: user._id, + customerEmail: user.email, + successUrl: args.successUrl ?? DEFAULT_SETTINGS_URL, + metadata: { + userId: user._id, + feature: args.feature, + }, + }); + + return { url: checkout.url }; + }, +}); + +function getFeatureProductId(feature: PaidFeatureProduct): string { + if (feature === "optOut") { + return polar.products.optOut; + } + + return polar.products.emailThreads; +} diff --git a/packages/convex/schema.ts b/packages/convex/schema.ts index 3740084..f92f9db 100644 --- a/packages/convex/schema.ts +++ b/packages/convex/schema.ts @@ -6,6 +6,8 @@ export default defineSchema({ mentraUserId: v.string(), mentraToken: v.optional(v.string()), email: v.optional(v.string()), + optedOutOfTraining: v.optional(v.boolean()), + paidEmailThreads: v.optional(v.boolean()), billingName: v.optional(v.string()), billingAddress: v.optional( v.object({ @@ -91,6 +93,14 @@ export default defineSchema({ }) .index("by_email_note", ["emailNoteId"]) .index("by_resend_email_id", ["resendEmailId"]), + emailThreadUsage: defineTable({ + userId: v.id("users"), + periodKey: v.string(), + outboundCount: v.number(), + paywallEmailSentAt: v.optional(v.string()), + }) + .index("by_user", ["userId"]) + .index("by_user_period", ["userId", "periodKey"]), chatMessages: defineTable({ userId: v.id("users"), dailySummaryId: v.optional(v.id("dailySummaries")), diff --git a/packages/convex/tsconfig.json b/packages/convex/tsconfig.json index a84f928..575c600 100644 --- a/packages/convex/tsconfig.json +++ b/packages/convex/tsconfig.json @@ -10,6 +10,7 @@ "moduleResolution": "Bundler", "jsx": "react-jsx", "skipLibCheck": true, + "types": [], "allowSyntheticDefaultImports": true, /* These compiler options are required by Convex */ diff --git a/packages/convex/users.ts b/packages/convex/users.ts index 6c0b25b..880ac6d 100644 --- a/packages/convex/users.ts +++ b/packages/convex/users.ts @@ -96,6 +96,26 @@ export const getEmail = query({ }, }); +export const getOptOutStatus = query({ + args: { mentraUserId: v.string() }, + handler: async (ctx, args) => { + const user = await getByMentraIdInternal(ctx, args.mentraUserId); + return { + optedOut: user?.optedOutOfTraining ?? false, + }; + }, +}); + +export const getEmailThreadsPaidStatus = query({ + args: { mentraUserId: v.string() }, + handler: async (ctx, args) => { + const user = await getByMentraIdInternal(ctx, args.mentraUserId); + return { + paidEmailThreads: user?.paidEmailThreads ?? false, + }; + }, +}); + // ============================================================================= // Public Queries - Preferences // ============================================================================= @@ -164,9 +184,11 @@ export const getOrCreate = mutation({ if (existing) { return existing._id; } + const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(args.mentraUserId); const userId = await ctx.db.insert("users", { mentraUserId: args.mentraUserId, mentraToken: args.mentraToken, + ...(isEmail ? { email: args.mentraUserId } : {}), }); await ctx.db.insert("preferences", { @@ -405,3 +427,44 @@ export const updateDefaultLocation = internalMutation({ }); }, }); + +export const setOptOutStatus = internalMutation({ + args: { + userId: v.id("users"), + optedOut: v.boolean(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.userId, { + optedOutOfTraining: args.optedOut, + }); + return { success: true }; + }, +}); + +export const backfillEmailsFromMentraId = internalMutation({ + handler: async (ctx) => { + const users = await ctx.db.query("users").collect(); + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + let updated = 0; + for (const user of users) { + if (!user.email && emailRegex.test(user.mentraUserId)) { + await ctx.db.patch(user._id, { email: user.mentraUserId }); + updated++; + } + } + return { updated, total: users.length }; + }, +}); + +export const setPaidEmailThreadsStatus = internalMutation({ + args: { + userId: v.id("users"), + paidEmailThreads: v.boolean(), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.userId, { + paidEmailThreads: args.paidEmailThreads, + }); + return { success: true }; + }, +});