Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ jobs:
E2E_TOKEN_MEMBER: ${{ secrets.E2E_TOKEN_MEMBER }}
E2E_TOKEN_OLD: ${{ secrets.E2E_TOKEN_OLD }}
E2E_PUBLISHABLE_KEY: ${{ secrets.E2E_PUBLISHABLE_KEY }}
QSTASH_URL: "https://qstash-us-east-1.upstash.io"
QSTASH_TOKEN: ${{ secrets.QSTASH_TOKEN }}
QSTASH_CURRENT_SIGNING_KEY: ${{ secrets.QSTASH_CURRENT_SIGNING_KEY }}
NEXT_PUBLIC_NGROK_URL: ${{ github.event.deployment_status.environment_url }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/playwright.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ jobs:
UPSTASH_REDIS_REST_TOKEN: "e2e_srh_token"
UPSTASH_VECTOR_REST_URL: "https://sensible-camel-xxxx.upstash.io"
UPSTASH_VECTOR_REST_TOKEN: "xx"
QSTASH_URL: "https://qstash-us-east-1.upstash.io"
QSTASH_TOKEN: "xx"
QSTASH_CURRENT_SIGNING_KEY: "xx"
QSTASH_NEXT_SIGNING_KEY: "xx"
Expand Down
1 change: 1 addition & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ UPSTASH_REDIS_REST_TOKEN=

# Upstash QStash – required for queues and background jobs
# Get your QStash Token here: https://upstash.com/docs/qstash/overall/getstarted
QSTASH_URL="https://qstash-us-east-1.upstash.io"
Comment thread
devkiran marked this conversation as resolved.
QSTASH_TOKEN=
QSTASH_CURRENT_SIGNING_KEY=
QSTASH_NEXT_SIGNING_KEY=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,9 @@ export async function checkoutSessionCompleted(
sale: {
products,
amount: saleData.amount,
...(charge.metadata && Object.keys(charge.metadata).length > 0
? { metadata: charge.metadata }
: {}),
},
},
clickEvent: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,12 @@ export async function invoicePaid(
| undefined = undefined;

if (link.programId && link.partnerId) {
const saleMetadata = {
...invoice.parent?.subscription_details?.metadata,
...invoice.lines.data[0]?.metadata,
...invoice.metadata,
};

const products = invoice.lines.data
.map((line) => {
const productId = line.pricing?.price_details?.product;
Expand Down Expand Up @@ -351,6 +357,9 @@ export async function invoicePaid(
sale: {
products,
amount: saleData.amount,
...(Object.keys(saleMetadata).length > 0
? { metadata: saleMetadata }
: {}),
},
},
clickEvent: {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/lib/api/conversions/track-lead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ export const trackLead = async ({
country: customer.country,
source,
},
lead: {
...(metadata != null && { metadata }),
},
},
clickEvent: {
url: clickData.url,
Expand Down
1 change: 1 addition & 0 deletions apps/web/lib/api/conversions/track-sale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,6 +553,7 @@ const _trackSale = async ({
sale: {
productId: metadata?.productId,
amount: saleData.amount,
...(metadata != null && { metadata }),
},
},
clickEvent: {
Expand Down
84 changes: 84 additions & 0 deletions apps/web/lib/api/rewards/reward-condition-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { RewardCondition } from "@/lib/types";
import {
METADATA_CONDITION_OPERATORS,
METADATA_NUMBER_CONDITION_OPERATORS,
} from "@/lib/zod/schemas/rewards";

function toNumber(fieldKey: unknown): number | undefined {
if (fieldKey == null) {
return undefined;
}

if (typeof fieldKey === "number" && !Number.isNaN(fieldKey)) {
return fieldKey;
}

if (typeof fieldKey === "boolean") {
return undefined;
}

if (typeof fieldKey === "string") {
if (fieldKey.trim() === "" || Number.isNaN(Number(fieldKey))) {
return undefined;
}

return Number(fieldKey);
}

const n = Number(fieldKey);
Comment thread
devkiran marked this conversation as resolved.
return Number.isNaN(n) ? undefined : n;
}

function toString(fieldKey: unknown) {
return typeof fieldKey === "string" ? fieldKey : String(fieldKey);
}

/**
* Normalizes a raw metadata value (from lead/sale JSON) into the type that
* `evaluateCondition` expects for the given operator.
*
* Metadata is stored as loosely typed JSON, so the same field may arrive as a
* string, number, or other primitive. Text operators (starts_with, contains,
* etc.) always receive a string; numeric operators receive a parsed number or
* undefined when parsing fails. For equals_to / not_equals, the return type is
* aligned with `condition.value` so strict equality checks compare like types.
*/
export function prepareMetadataFieldValue(
fieldKey: unknown,
condition: RewardCondition,
): string | number | string[] | number[] | undefined {
if (fieldKey == null) {
return undefined;
}

const { operator, value: conditionValue } = condition;

// Metadata conditions do not support in / not_in and other non-metadata operators.
if (!METADATA_CONDITION_OPERATORS.includes(operator)) {
return undefined;
}

// Pattern text operators always compare against a stringified metadata value.
if (
operator === "starts_with" ||
operator === "ends_with" ||
operator === "contains" ||
operator === "not_contains"
) {
return toString(fieldKey);
}

// Numeric operators require a parsed number, or undefined when coercion fails.
if (METADATA_NUMBER_CONDITION_OPERATORS.includes(operator)) {
return toNumber(fieldKey);
}

// For equals_to / not_equals with a numeric condition value, prefer number coercion.
if (typeof conditionValue === "number") {
const numeric = toNumber(fieldKey);
return numeric !== undefined ? numeric : toString(fieldKey);
}

// All other operators use string comparison.
return toString(fieldKey);
}
4 changes: 2 additions & 2 deletions apps/web/lib/partners/determine-partner-reward.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { toCentsNumber } from "@dub/utils";
import { prettyPrint, toCentsNumber } from "@dub/utils";
import { EventType, Link, Prisma, Reward } from "@prisma/client";
import { serializeReward } from "../api/partners/serialize-reward";
import { RewardContext, RewardProps } from "../types";
Expand Down Expand Up @@ -177,7 +177,7 @@ export const determinePartnerRewards = ({
}
}

console.info("Resolved rewards", rewards);
console.log("Reward context", prettyPrint(context));

return rewards;
};
92 changes: 80 additions & 12 deletions apps/web/lib/partners/evaluate-reward-conditions.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { prepareMetadataFieldValue } from "../api/rewards/reward-condition-metadata";
import {
RewardCondition,
RewardConditions,
Expand All @@ -22,15 +23,7 @@ export const evaluateRewardConditions = ({
for (const conditionGroup of conditions) {
// Evaluate each condition in the group
const conditionResults = conditionGroup.conditions.map((condition) => {
let fieldValue = undefined;

if (condition.entity === "customer") {
fieldValue = context.customer?.[condition.attribute];
} else if (condition.entity === "sale") {
fieldValue = context.sale?.[condition.attribute];
} else if (condition.entity === "partner") {
fieldValue = context.partner?.[condition.attribute];
}
const fieldValue = resolveConditionFieldValue({ condition, context });

if (fieldValue === undefined) {
return false;
Expand Down Expand Up @@ -75,6 +68,43 @@ export const evaluateRewardConditions = ({
)[0];
};

function resolveConditionFieldValue({
condition,
context,
}: {
condition: RewardCondition;
context: RewardContext;
}): string | number | string[] | number[] | undefined {
if (condition.attribute === "metadata") {
const metaKey = condition.metadataField?.trim();

if (!metaKey) {
return undefined;
}

const entityMap = {
partner: undefined,
customer: undefined,
lead: context.lead,
sale: context.sale,
} as const;

return prepareMetadataFieldValue(
entityMap[condition.entity]?.metadata?.[metaKey],
condition,
);
}

const entityMap = {
partner: context.partner,
customer: context.customer,
lead: undefined,
sale: context.sale,
} as const;

return entityMap[condition.entity]?.[condition.attribute];
}

const evaluateCondition = ({
condition,
fieldValue,
Expand All @@ -94,7 +124,11 @@ const evaluateCondition = ({

// Starts with
if (condition.operator === "starts_with") {
if (typeof fieldValue !== "string" || typeof condition.value !== "string") {
if (
typeof fieldValue !== "string" ||
typeof condition.value !== "string" ||
condition.value === ""
) {
return false;
}

Expand All @@ -103,13 +137,47 @@ const evaluateCondition = ({

// Ends with
if (condition.operator === "ends_with") {
if (typeof fieldValue !== "string" || typeof condition.value !== "string") {
if (
typeof fieldValue !== "string" ||
typeof condition.value !== "string" ||
condition.value === ""
) {
return false;
}

return fieldValue.endsWith(condition.value);
}

// Contains
if (condition.operator === "contains") {
if (typeof fieldValue !== "string" || typeof condition.value !== "string") {
return false;
}

const trimmedValue = condition.value.trim();

if (trimmedValue === "") {
return false;
}

return String(fieldValue).includes(trimmedValue);
}

// Not contains
if (condition.operator === "not_contains") {
if (typeof fieldValue !== "string" || typeof condition.value !== "string") {
return false;
}

const trimmedValue = condition.value.trim();

if (trimmedValue === "") {
return false;
}

return !String(fieldValue).includes(trimmedValue);
}

// In
if (condition.operator === "in") {
if (!Array.isArray(condition.value)) {
Expand All @@ -123,7 +191,7 @@ const evaluateCondition = ({

// Not in
if (condition.operator === "not_in") {
if (!Array.isArray(condition.value)) {
if (!Array.isArray(condition.value) || condition.value.length === 0) {
return false;
}

Expand Down
Loading
Loading