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 };
+ },
+});