diff --git a/packages/console/app/src/component/go-referral.css b/packages/console/app/src/component/go-referral.css index 9ca3e25fae..4a05372da2 100644 --- a/packages/console/app/src/component/go-referral.css +++ b/packages/console/app/src/component/go-referral.css @@ -336,6 +336,18 @@ &[data-slot="referral-source"] { color: var(--color-text-secondary); font-family: var(--font-sans); + + span, + [data-slot="pending-note"] { + display: block; + } + + [data-slot="pending-note"] { + margin-top: var(--space-1); + color: var(--color-text-muted); + line-height: 1.4; + white-space: normal; + } } &[data-slot="referral-action"] { @@ -356,6 +368,17 @@ } } + &[data-status="pending"] { + td[data-slot="referral-amount"], + td[data-slot="referral-date"] { + color: var(--color-text-muted); + } + + td[data-slot="referral-source"] { + color: var(--color-text); + } + } + &:last-child td { border-bottom: none; } diff --git a/packages/console/app/src/component/go-referral.tsx b/packages/console/app/src/component/go-referral.tsx index 28515d82e6..d0eaab2185 100644 --- a/packages/console/app/src/component/go-referral.tsx +++ b/packages/console/app/src/component/go-referral.tsx @@ -19,6 +19,7 @@ export type GoReferralReward = { id: string amount: number source: "inviter" | "invitee" + status: "pending" | "available" | "applied" timeCreated: string | Date timeApplied: string | Date | null } @@ -179,11 +180,24 @@ function formatDate(value: string | Date, locale: string) { return new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", year: "numeric" }).format(new Date(value)) } -function rewardSourceKey(source: GoReferralReward["source"]) { - if (source === "invitee") return "workspace.referral.reward.source.invitee" as const +function rewardSourceKey(reward: GoReferralReward) { + if (reward.status === "pending" && reward.source === "invitee") + return "workspace.referral.reward.source.pendingInvitee" as const + if (reward.status === "pending") return "workspace.referral.reward.source.pendingInviter" as const + if (reward.source === "invitee") return "workspace.referral.reward.source.invitee" as const return "workspace.referral.reward.source.inviter" as const } +function rewardPendingKey(source: GoReferralReward["source"]) { + if (source === "invitee") return "workspace.referral.reward.pending.invitee" as const + return "workspace.referral.reward.pending.inviter" as const +} + +function rewardPendingStatusKey(source: GoReferralReward["source"]) { + if (source === "invitee") return "workspace.referral.reward.status.pendingInvitee" as const + return "workspace.referral.reward.status.pendingInviter" as const +} + function CopyInviteLink(props: { summary: GoReferralSummary }) { const i18n = useI18n() const [copied, setCopied] = createSignal(false) @@ -236,6 +250,7 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer } satisfies GoReferralUsagePreview }) const appliedCount = createMemo(() => props.summary.rewards.filter((reward) => reward.timeApplied).length) + const earnedCount = createMemo(() => props.summary.rewards.filter((reward) => reward.status !== "pending").length) createEffect(() => { const reward = selected() @@ -295,7 +310,7 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer

{i18n.t("workspace.referral.rewards.subtitle", { applied: appliedCount(), - total: props.summary.rewards.length, + total: earnedCount(), })}

@@ -309,32 +324,40 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer {i18n.t("workspace.referral.table.reward")} {i18n.t("workspace.referral.table.referral")} - {i18n.t("workspace.referral.table.earned")} + {i18n.t("workspace.referral.table.date")} {(reward) => { - const applied = createMemo(() => !!reward.timeApplied) + const applied = createMemo(() => reward.status === "applied") + const pending = createMemo(() => reward.status === "pending") const earnedAt = createMemo(() => formatDate(reward.timeCreated, language.tag(language.locale()))) return ( - + {formatCurrency(reward.amount)} - {i18n.t(rewardSourceKey(reward.source))} + + {i18n.t(rewardSourceKey(reward))} + + {i18n.t(rewardPendingKey(reward.source))} + + {earnedAt()} diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index bdf63c0113..1f7fcf5d4a 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -678,11 +678,17 @@ export const dict = { "workspace.referral.rewards.subtitle": "{{applied}} / {{total}} rewards applied.", "workspace.referral.rewards.empty": "No referral rewards yet.", "workspace.referral.table.reward": "Reward", - "workspace.referral.table.referral": "Referral", - "workspace.referral.table.earned": "Earned", + "workspace.referral.table.referral": "Description", + "workspace.referral.table.date": "Date", "workspace.referral.reward.source.inviter": "Earned by inviting a friend", "workspace.referral.reward.source.invitee": "Received from an invite", + "workspace.referral.reward.source.pendingInviter": "Friend account created", + "workspace.referral.reward.source.pendingInvitee": "Invite accepted", "workspace.referral.reward.status.applied": "Applied", + "workspace.referral.reward.status.pendingInviter": "Waiting for subscription", + "workspace.referral.reward.status.pendingInvitee": "Subscribe to unlock", + "workspace.referral.reward.pending.inviter": "Reward unlocks when they subscribe to Go.", + "workspace.referral.reward.pending.invitee": "Subscribe to Go to unlock this reward.", "workspace.referral.reward.earnedOn": "{{date}}", "workspace.referral.apply.noGo": "Subscribe to Go", "workspace.referral.apply.action": "Apply", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 946eafab9f..5f8ee3808a 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -659,11 +659,17 @@ export const dict = { "workspace.referral.rewards.subtitle": "已使用 {{applied}} / {{total}} 个奖励。", "workspace.referral.rewards.empty": "暂无邀请奖励。", "workspace.referral.table.reward": "奖励", - "workspace.referral.table.referral": "邀请", - "workspace.referral.table.earned": "获得时间", + "workspace.referral.table.referral": "描述", + "workspace.referral.table.date": "日期", "workspace.referral.reward.source.inviter": "邀请好友获得", "workspace.referral.reward.source.invitee": "通过邀请获得", + "workspace.referral.reward.source.pendingInviter": "好友已创建账号", + "workspace.referral.reward.source.pendingInvitee": "已接受邀请", "workspace.referral.reward.status.applied": "已使用", + "workspace.referral.reward.status.pendingInviter": "等待订阅", + "workspace.referral.reward.status.pendingInvitee": "订阅后解锁", + "workspace.referral.reward.pending.inviter": "好友订阅 Go 后即可解锁奖励。", + "workspace.referral.reward.pending.invitee": "订阅 Go 后即可解锁此奖励。", "workspace.referral.reward.earnedOn": "{{date}}", "workspace.referral.apply.noGo": "订阅 Go", "workspace.referral.apply.action": "使用", diff --git a/packages/console/app/src/lib/referral-invite.ts b/packages/console/app/src/lib/referral-invite.ts new file mode 100644 index 0000000000..4453e39f66 --- /dev/null +++ b/packages/console/app/src/lib/referral-invite.ts @@ -0,0 +1,26 @@ +const INVITE_COOKIE = "opencode.go.invite" +const INVITE_MAX_AGE = 60 * 60 * 24 * 30 + +export function normalizeInviteCode(code?: string | null) { + return code?.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, 16) +} + +export function inviteCookie(code: string) { + return `${INVITE_COOKIE}=${encodeURIComponent(code)}; Path=/; Max-Age=${INVITE_MAX_AGE}; SameSite=Lax; HttpOnly` +} + +export function clearInviteCookie() { + return `${INVITE_COOKIE}=; Path=/; Max-Age=0; SameSite=Lax; HttpOnly` +} + +export function inviteFromCookieHeader(header: string | null) { + if (!header) return undefined + + return normalizeInviteCode( + header + .split(";") + .map((x) => x.trim()) + .find((x) => x.startsWith(`${INVITE_COOKIE}=`)) + ?.slice(`${INVITE_COOKIE}=`.length), + ) +} diff --git a/packages/console/app/src/middleware.ts b/packages/console/app/src/middleware.ts index 750b0d9674..9e2ceeaa4e 100644 --- a/packages/console/app/src/middleware.ts +++ b/packages/console/app/src/middleware.ts @@ -1,16 +1,20 @@ import { createMiddleware } from "@solidjs/start/middleware" import { LOCALE_HEADER, cookie, fromPathname, strip } from "~/lib/language" +import { inviteCookie, normalizeInviteCode } from "~/lib/referral-invite" export default createMiddleware({ onRequest(event) { const url = new URL(event.request.url) const locale = fromPathname(url.pathname) - if (!locale) return + if (locale) { + url.pathname = strip(url.pathname) + const request = new Request(url, event.request) + request.headers.set(LOCALE_HEADER, locale) + event.request = request + event.response.headers.append("set-cookie", cookie(locale)) + } - url.pathname = strip(url.pathname) - const request = new Request(url, event.request) - request.headers.set(LOCALE_HEADER, locale) - event.request = request - event.response.headers.append("set-cookie", cookie(locale)) + const inviteCode = normalizeInviteCode(url.searchParams.get("invite")) + if (inviteCode) event.response.headers.append("set-cookie", inviteCookie(inviteCode)) }, }) diff --git a/packages/console/app/src/routes/auth/[...callback].ts b/packages/console/app/src/routes/auth/[...callback].ts index 00bb89406f..c2f6263242 100644 --- a/packages/console/app/src/routes/auth/[...callback].ts +++ b/packages/console/app/src/routes/auth/[...callback].ts @@ -1,9 +1,11 @@ import { redirect } from "@solidjs/router" import type { APIEvent } from "@solidjs/start/server" +import { Referral } from "@opencode-ai/console-core/referral.js" import { AuthClient } from "~/context/auth" import { useAuthSession } from "~/context/auth" import { i18n } from "~/i18n" import { localeFromRequest, route } from "~/lib/language" +import { clearInviteCookie, inviteFromCookieHeader } from "~/lib/referral-invite" export async function GET(input: APIEvent) { const url = new URL(input.request.url) @@ -17,6 +19,7 @@ export async function GET(input: APIEvent) { if (result.err) throw new Error(result.err.message) const decoded = AuthClient.decode(result.tokens.access, {} as any) if (decoded.err) throw new Error(decoded.err.message) + const inviteCode = inviteFromCookieHeader(input.request.headers.get("cookie")) const session = await useAuthSession() const id = decoded.subject.properties.accountID await session.update((value) => { @@ -32,8 +35,15 @@ export async function GET(input: APIEvent) { current: id, } }) + if (decoded.subject.properties.newAccount && inviteCode) { + await Referral.createFromAccount({ accountID: id, inviteCode }).catch((error) => { + console.error("Referral create failed", error) + }) + } const next = url.pathname === "/auth/callback" ? "/auth" : url.pathname.replace("/auth/callback", "") - return redirect(route(locale, next)) + const response = redirect(route(locale, next)) + if (inviteCode) response.headers.append("set-cookie", clearInviteCookie()) + return response } catch (e: any) { return new Response( JSON.stringify({ diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 6757470f3e..83bab09097 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -37,8 +37,6 @@ const models = [ { name: "DeepSeek V4 Flash", provider: "DeepSeek" }, ] -const INVITE_STORAGE_KEY = "opencode.go.invite" - function LimitsGraph(props: { href: string }) { let root!: HTMLElement const [visible, setVisible] = createSignal(false) @@ -229,15 +227,10 @@ export default function Home() { const location = useLocation() const workspaceID = createAsync(() => checkLoggedIn()) const inviteCode = createMemo(() => new URLSearchParams(location.search).get("invite") ?? undefined) - onMount(() => { - const code = inviteCode() - if (!code) return - window.localStorage.setItem(INVITE_STORAGE_KEY, code) - }) const subscribeUrl = createMemo(() => { const invite = inviteCode() ? `?invite=${encodeURIComponent(inviteCode()!)}` : "" if (workspaceID()) return `/workspace/${workspaceID()}/go${invite}` - return "/auth" + return `/auth${invite}` }) const i18n = useI18n() const language = useLanguage() diff --git a/packages/console/app/src/routes/stripe/webhook.ts b/packages/console/app/src/routes/stripe/webhook.ts index 798d7c0ca9..7b1db7dac2 100644 --- a/packages/console/app/src/routes/stripe/webhook.ts +++ b/packages/console/app/src/routes/stripe/webhook.ts @@ -112,7 +112,6 @@ export async function POST(input: APIEvent) { const userID = body.data.object.metadata?.userID const userEmail = body.data.object.metadata?.userEmail const coupon = body.data.object.metadata?.coupon - const inviteCode = body.data.object.metadata?.inviteCode const customerID = body.data.object.customer as string const invoiceID = body.data.object.latest_invoice as string const subscriptionID = body.data.object.id as string @@ -174,10 +173,9 @@ export async function POST(input: APIEvent) { } }) - await Referral.createFromLiteSubscription({ + await Referral.completeFromLiteSubscription({ workspaceID, userID, - inviteCode, }).catch((error) => { console.error("Referral sync failed", error) }) diff --git a/packages/console/app/src/routes/workspace/[id]/go/index.tsx b/packages/console/app/src/routes/workspace/[id]/go/index.tsx index 5f75d50bc8..002ab9e910 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/index.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/index.tsx @@ -30,7 +30,11 @@ export default function () {
{i18n.t("workspace.lite.loading")}}> - {(summary) => } + {(summary) => ( + + + + )}
diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index f80e34e8dd..054752a7a9 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -1,6 +1,6 @@ -import { action, useParams, useAction, useSubmission, json, query, createAsync, useLocation } from "@solidjs/router" +import { action, useParams, useAction, useSubmission, json, query, createAsync } from "@solidjs/router" import { createStore } from "solid-js/store" -import { createMemo, createSignal, For, onMount, Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" import { Modal } from "~/component/modal" import { Billing } from "@opencode-ai/console-core/billing.js" import { Database, eq, and, isNull } from "@opencode-ai/console-core/drizzle/index.js" @@ -17,8 +17,6 @@ import { formError } from "~/lib/form-error" import { IconAlipay, IconUpi } from "~/component/icon" -const INVITE_STORAGE_KEY = "opencode.go.invite" - export const queryLiteSubscription = query(async (workspaceID: string) => { "use server" return withActor(async () => { @@ -84,12 +82,12 @@ export function formatResetTime(seconds: number, i18n: ReturnType { + async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi") => { "use server" return json( await withActor( () => - Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method, inviteCode }) + Billing.generateLiteCheckoutUrl({ successUrl, cancelUrl, method }) .then((data) => ({ error: undefined, data })) .catch((e) => ({ error: e.message as string, @@ -144,14 +142,11 @@ const setLiteUseBalance = action(async (form: FormData) => { export function LiteSection() { const params = useParams() - const location = useLocation() const i18n = useI18n() const language = useLanguage() const billingInfo = createAsync(() => queryBillingInfo(params.id!)) const isBlack = createMemo(() => billingInfo()?.subscriptionID || billingInfo()?.timeSubscriptionBooked) const lite = createAsync(() => queryLiteSubscription(params.id!)) - const [storedInviteCode, setStoredInviteCode] = createSignal() - const inviteCode = createMemo(() => new URLSearchParams(location.search).get("invite") ?? storedInviteCode()) const sessionAction = useAction(createSessionUrl) const sessionSubmission = useSubmission(createSessionUrl) const checkoutAction = useAction(createLiteCheckoutUrl) @@ -164,13 +159,6 @@ export function LiteSection() { const busy = createMemo(() => !!store.loading) - onMount(() => { - const code = inviteCode() ?? window.localStorage.getItem(INVITE_STORAGE_KEY) ?? undefined - if (!code) return - window.localStorage.setItem(INVITE_STORAGE_KEY, code) - setStoredInviteCode(code) - }) - async function onClickSession() { setStore("loading", "session") const result = await sessionAction(params.id!, window.location.href) @@ -183,7 +171,7 @@ export function LiteSection() { async function onClickSubscribe(method?: "alipay" | "upi") { setStore("loading", method ?? "checkout") - const result = await checkoutAction(params.id!, window.location.href, window.location.href, method, inviteCode()) + const result = await checkoutAction(params.id!, window.location.href, window.location.href, method) if (result.data) { window.location.href = result.data return diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index 5ce830acb9..ca3a40878e 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -279,11 +279,10 @@ export namespace Billing { successUrl: z.string(), cancelUrl: z.string(), method: z.enum(["alipay", "upi"]).optional(), - inviteCode: z.string().optional(), }), async (input) => { const user = Actor.assert("user") - const { successUrl, cancelUrl, method, inviteCode } = input + const { successUrl, cancelUrl, method } = input const email = (await User.getAuthEmail(user.properties.userID))! const billing = await Billing.get() @@ -365,7 +364,6 @@ export namespace Billing { userEmail: email, coupon, type: "lite", - ...(inviteCode ? { inviteCode } : {}), }, }, }) diff --git a/packages/console/core/src/referral.ts b/packages/console/core/src/referral.ts index f71acd1383..989eaad5db 100644 --- a/packages/console/core/src/referral.ts +++ b/packages/console/core/src/referral.ts @@ -56,11 +56,13 @@ export namespace Referral { export const summary = fn(z.void(), async () => { const workspaceID = Actor.workspace() + const accountID = Actor.account() const code = await ensureCode(workspaceID) const rows = await Database.use(async (tx) => { const rewards = await tx .select({ id: ReferralRewardTable.id, + referralID: ReferralRewardTable.referralID, workspaceID: ReferralRewardTable.workspaceID, referralWorkspaceID: ReferralTable.workspaceID, amount: ReferralRewardTable.amount, @@ -79,27 +81,68 @@ export namespace Referral { .orderBy(desc(ReferralRewardTable.timeCreated)) const invites = await tx - .select({ id: ReferralTable.id }) + .select({ id: ReferralTable.id, timeCreated: ReferralTable.timeCreated }) .from(ReferralTable) .where(and(eq(ReferralTable.workspaceID, workspaceID), isNull(ReferralTable.timeDeleted))) + const inviteeReferrals = await tx + .select({ id: ReferralTable.id, timeCreated: ReferralTable.timeCreated }) + .from(ReferralTable) + .where(and(eq(ReferralTable.inviteeAccountID, accountID), isNull(ReferralTable.timeDeleted))) + + const inviteeRewards = await tx + .select({ referralID: ReferralRewardTable.referralID }) + .from(ReferralRewardTable) + .innerJoin(ReferralTable, eq(ReferralTable.id, ReferralRewardTable.referralID)) + .where( + and( + eq(ReferralTable.inviteeAccountID, accountID), + isNull(ReferralRewardTable.timeDeleted), + isNull(ReferralTable.timeDeleted), + ), + ) + const lite = await tx .select({ id: LiteTable.id }) .from(LiteTable) .where(and(eq(LiteTable.workspaceID, workspaceID), isNull(LiteTable.timeDeleted))) .then((result) => result[0]) - return { invites, lite, rewards } + return { inviteeReferrals, inviteeRewards, invites, lite, rewards } }) + const rewardReferralIDs = new Set(rows.rewards.map((reward) => reward.referralID)) + const inviteeRewardReferralIDs = new Set(rows.inviteeRewards.map((reward) => reward.referralID)) const rewards = rows.rewards.map((reward) => ({ id: reward.id, source: reward.workspaceID === reward.referralWorkspaceID ? ("inviter" as const) : ("invitee" as const), + status: reward.timeApplied ? ("applied" as const) : ("available" as const), amount: microCentsToCents(reward.amount), timeCreated: reward.timeCreated, timeApplied: reward.timeApplied, })) - + const pending = [ + ...rows.invites + .filter((referral) => !rewardReferralIDs.has(referral.id)) + .map((referral) => ({ + id: `${referral.id}:inviter`, + source: "inviter" as const, + status: "pending" as const, + amount: microCentsToCents(REWARD_AMOUNT), + timeCreated: referral.timeCreated, + timeApplied: null, + })), + ...rows.inviteeReferrals + .filter((referral) => !inviteeRewardReferralIDs.has(referral.id)) + .map((referral) => ({ + id: `${referral.id}:invitee`, + source: "invitee" as const, + status: "pending" as const, + amount: microCentsToCents(REWARD_AMOUNT), + timeCreated: referral.timeCreated, + timeApplied: null, + })), + ].sort((a, b) => new Date(b.timeCreated).getTime() - new Date(a.timeCreated).getTime()) return { inviteCode: code.code, validInviteCount: rows.invites.length, @@ -109,7 +152,9 @@ export namespace Referral { totalApplied: rewards .filter((reward) => reward.timeApplied) .reduce((total, reward) => total + reward.amount, 0), - rewards, + rewards: [...pending, ...rewards].sort( + (a, b) => new Date(b.timeCreated).getTime() - new Date(a.timeCreated).getTime(), + ), } }) @@ -184,9 +229,8 @@ export namespace Referral { }) }) - export async function createFromLiteSubscription(input: { - workspaceID: string - userID: string + export async function createFromAccount(input: { + accountID: string inviteCode?: string }) { const inviteCode = normalizeCode(input.inviteCode) @@ -200,19 +244,10 @@ export namespace Referral { .then((rows) => rows[0]) if (!code) return { status: "invalid-code" as const } - const invitee = await tx - .select({ accountID: UserTable.accountID }) - .from(UserTable) - .where( - and(eq(UserTable.workspaceID, input.workspaceID), eq(UserTable.id, input.userID), isNull(UserTable.timeDeleted)), - ) - .then((rows) => rows[0]) - if (!invitee?.accountID) return { status: "missing-account" as const } - const existingReferral = await tx .select({ id: ReferralTable.id }) .from(ReferralTable) - .where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted))) + .where(and(eq(ReferralTable.inviteeAccountID, input.accountID), isNull(ReferralTable.timeDeleted))) .then((rows) => rows[0]) if (existingReferral) return { status: "already-redeemed" as const } @@ -222,27 +257,20 @@ export namespace Referral { .where( and( eq(UserTable.workspaceID, code.workspaceID), - eq(UserTable.accountID, invitee.accountID), + eq(UserTable.accountID, input.accountID), isNull(UserTable.timeDeleted), ), ) .then((rows) => rows[0]) if (selfReferral) return { status: "self-referral" as const } - const existingGo = await tx - .select({ workspaceID: LiteTable.workspaceID }) - .from(LiteTable) - .innerJoin(UserTable, and(eq(UserTable.workspaceID, LiteTable.workspaceID), eq(UserTable.id, LiteTable.userID))) - .where(and(eq(UserTable.accountID, invitee.accountID), isNull(UserTable.timeDeleted), isNull(LiteTable.timeDeleted))) - if (existingGo.some((row) => row.workspaceID !== input.workspaceID)) return { status: "already-subscribed" as const } - const referralID = Identifier.create("referral") await tx .insert(ReferralTable) .values({ workspaceID: code.workspaceID, id: referralID, - inviteeAccountID: invitee.accountID, + inviteeAccountID: input.accountID, }) .onDuplicateKeyUpdate({ set: { @@ -253,11 +281,42 @@ export namespace Referral { const referral = await tx .select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID }) .from(ReferralTable) - .where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted))) + .where(and(eq(ReferralTable.inviteeAccountID, input.accountID), isNull(ReferralTable.timeDeleted))) .then((rows) => rows[0]) if (!referral) return { status: "duplicate" as const } if (referral.id !== referralID) return { status: "already-redeemed" as const } + return { status: "created" as const } + }) + } + + export async function completeFromLiteSubscription(input: { + workspaceID: string + userID: string + }) { + return Database.transaction(async (tx) => { + const invitee = await tx + .select({ accountID: UserTable.accountID }) + .from(UserTable) + .where( + and(eq(UserTable.workspaceID, input.workspaceID), eq(UserTable.id, input.userID), isNull(UserTable.timeDeleted)), + ) + .then((rows) => rows[0]) + if (!invitee?.accountID) return { status: "missing-account" as const } + + const referral = await tx + .select({ id: ReferralTable.id, workspaceID: ReferralTable.workspaceID }) + .from(ReferralTable) + .where(and(eq(ReferralTable.inviteeAccountID, invitee.accountID), isNull(ReferralTable.timeDeleted))) + .then((rows) => rows[0]) + if (!referral) return { status: "missing-referral" as const } + + const existingRewards = await tx + .select({ id: ReferralRewardTable.id }) + .from(ReferralRewardTable) + .where(and(eq(ReferralRewardTable.referralID, referral.id), isNull(ReferralRewardTable.timeDeleted))) + if (existingRewards.length > 0) return { status: "already-completed" as const } + await tx .insert(ReferralRewardTable) .values([ diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts index 28724ec157..613dccf997 100644 --- a/packages/console/function/src/auth.ts +++ b/packages/console/function/src/auth.ts @@ -26,6 +26,7 @@ export const subjects = createSubjects({ account: z.object({ accountID: z.string(), email: z.string(), + newAccount: z.boolean().optional(), }), user: z.object({ userID: z.string(), @@ -142,6 +143,7 @@ export default { } // Get account + let newAccount = false const accountID = await (async () => { const matches = await Database.use(async (tx) => tx @@ -166,6 +168,7 @@ export default { if (!accountID) { console.log("creating account for", email) accountID = await Account.create({}) + newAccount = true } await Database.use(async (tx) => @@ -215,7 +218,7 @@ export default { await Workspace.create({ name: "Default" }) } }) - return ctx.subject("account", accountID, { accountID, email }) + return ctx.subject("account", accountID, { accountID, email, newAccount }) }, }).fetch(request, env, ctx) return result