mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 03:45:23 +00:00
core: keep Go referral credit visible until subscription unlock
New invitees keep referral credit through sign-in and both sides see pending rewards until the invitee subscribes to Go.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<p>
|
||||
{i18n.t("workspace.referral.rewards.subtitle", {
|
||||
applied: appliedCount(),
|
||||
total: props.summary.rewards.length,
|
||||
total: earnedCount(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
@@ -309,32 +324,40 @@ export function GoReferralSection(props: { workspaceID: string; summary: GoRefer
|
||||
<tr>
|
||||
<th>{i18n.t("workspace.referral.table.reward")}</th>
|
||||
<th>{i18n.t("workspace.referral.table.referral")}</th>
|
||||
<th>{i18n.t("workspace.referral.table.earned")}</th>
|
||||
<th>{i18n.t("workspace.referral.table.date")}</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.summary.rewards}>
|
||||
{(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 (
|
||||
<tr data-status={applied() ? "applied" : "available"}>
|
||||
<tr data-status={reward.status} data-source={reward.source}>
|
||||
<td data-slot="referral-amount">{formatCurrency(reward.amount)}</td>
|
||||
<td data-slot="referral-source">{i18n.t(rewardSourceKey(reward.source))}</td>
|
||||
<td data-slot="referral-source">
|
||||
<span>{i18n.t(rewardSourceKey(reward))}</span>
|
||||
<Show when={pending()}>
|
||||
<span data-slot="pending-note">{i18n.t(rewardPendingKey(reward.source))}</span>
|
||||
</Show>
|
||||
</td>
|
||||
<td data-slot="referral-date" title={earnedAt()}>
|
||||
{earnedAt()}
|
||||
</td>
|
||||
<td data-slot="referral-action">
|
||||
<button
|
||||
type="button"
|
||||
disabled={applied() || !props.summary.hasActiveGo || submission.pending}
|
||||
disabled={reward.status !== "available" || !props.summary.hasActiveGo || submission.pending}
|
||||
onClick={() => setSelected(reward)}
|
||||
>
|
||||
<Show when={!applied()} fallback={i18n.t("workspace.referral.reward.status.applied")}>
|
||||
{props.summary.hasActiveGo
|
||||
? i18n.t("workspace.referral.apply.action")
|
||||
: i18n.t("workspace.referral.apply.noGo")}
|
||||
{pending()
|
||||
? i18n.t(rewardPendingStatusKey(reward.source))
|
||||
: props.summary.hasActiveGo
|
||||
? i18n.t("workspace.referral.apply.action")
|
||||
: i18n.t("workspace.referral.apply.noGo")}
|
||||
</Show>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "使用",
|
||||
|
||||
26
packages/console/app/src/lib/referral-invite.ts
Normal file
26
packages/console/app/src/lib/referral-invite.ts
Normal file
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -30,7 +30,11 @@ export default function () {
|
||||
<div data-slot="sections">
|
||||
<LiteSection />
|
||||
<Show when={referral()} fallback={<section>{i18n.t("workspace.lite.loading")}</section>}>
|
||||
{(summary) => <GoReferralSection workspaceID={params.id!} summary={summary()} />}
|
||||
{(summary) => (
|
||||
<Show when={summary().hasActiveGo}>
|
||||
<GoReferralSection workspaceID={params.id!} summary={summary()} />
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<typeof useI18n
|
||||
}
|
||||
|
||||
const createLiteCheckoutUrl = action(
|
||||
async (workspaceID: string, successUrl: string, cancelUrl: string, method?: "alipay" | "upi", inviteCode?: string) => {
|
||||
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<string>()
|
||||
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
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user