stack/packages/stack-shared/src/utils/arrays.tsx
BilalG1 d14317c787
batch sending (#875)
<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->

<!-- ELLIPSIS_HIDDEN -->


----

> [!IMPORTANT]
> Implement batch email rendering and sending with enhanced processing
and error handling.
> 
>   - **Behavior**:
> - Implement batch email rendering and sending in `route.tsx` using
`renderEmailsWithTemplateBatched()` and `sendEmailResendBatched()`.
> - Add per-recipient notification category resolution and unsubscribe
link generation.
> - Support templates from IDs, raw HTML, or drafts with dynamic theme
handling.
> - Enhanced result reporting, including users without primary emails.
>   - **Functions**:
> - Add `renderEmailsWithTemplateBatched()` in `email-rendering.tsx` for
batch email rendering.
> - Add `sendEmailResendBatched()` in `emails.tsx` for batch email
sending.
>     - Add `getChunks()` in `arrays.tsx` to split arrays into chunks.
>   - **Tests**:
> - Add timed waits in `send-email.test.ts` and
`unsubscribe-link.test.ts` to stabilize email delivery checks.
>   - **Dependencies**:
> - Add `@react-email/render` and `resend` to `package.json` for email
rendering and sending.
> 
> <sup>This description was created by </sup>[<img alt="Ellipsis"
src="https://img.shields.io/badge/Ellipsis-blue?color=175173">](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral)<sup>
for ff1dea6c31. You can
[customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this
summary. It will automatically update as commits are pushed.</sup>

----


<!-- ELLIPSIS_HIDDEN -->

<!-- RECURSEML_SUMMARY:START -->
## Review by RecurseML

_🔍 Review performed on
[3c34140..1267879](3c34140aba...1267879cfd)_

| Severity | Location | Issue |
|----------|----------|-------|
| ![Medium](https://img.shields.io/badge/Medium-yellow?style=plastic) |
[apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts:593](https://github.com/stack-auth/stack-auth/pull/875#discussion_r2317293698)
| Asynchronous wait function not wrapped with runAsynchronously |
| ![Medium](https://img.shields.io/badge/Medium-yellow?style=plastic) |
[apps/e2e/tests/backend/endpoints/api/v1/send-email.test.ts:743](https://github.com/stack-auth/stack-auth/pull/875#discussion_r2317293796)
| Asynchronous wait function not wrapped with runAsynchronously |

<details>
<summary> Files analyzed, no issues (4)</summary>

  • `apps/backend/src/app/api/latest/emails/send-email/route.tsx`
  • `apps/backend/src/lib/email-rendering.tsx`
  • `apps/backend/src/lib/emails.tsx`
  • `packages/stack-shared/src/utils/arrays.tsx`
</details>

<details>
<summary>⏭️ Files skipped (low suspicion) (2)</summary>

  • `apps/backend/package.json`
  • `pnpm-lock.yaml`
</details>

[![Need help? Join our
Discord](https://img.shields.io/badge/Need%20help%3F%20Join%20our%20Discord-5865F2?style=plastic&logo=discord&logoColor=white)](https://discord.gg/n3SsVDAW6U)
<!-- RECURSEML_SUMMARY:END -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- New Features
- Batch email rendering and sending to multiple recipients with
background processing.
- Per-recipient notification category resolution and unsubscribe link
generation.
- Support for templates from IDs, raw HTML, or drafts with dynamic theme
handling.
  - Enhanced result reporting, including users without primary emails.
- Chores
  - Added dependencies for email rendering and bulk sending.
- Tests
- Stabilized email delivery checks with timed waits across relevant e2e
tests.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com>
2025-09-11 15:42:51 -07:00

249 lines
9.4 KiB
TypeScript

import { remainder } from "./math";
export function typedIncludes<T extends readonly any[]>(arr: T, item: unknown): item is T[number] {
return arr.includes(item);
}
import.meta.vitest?.test("typedIncludes", ({ expect }) => {
const arr = [1, 2, 3] as const;
expect(typedIncludes(arr, 1)).toBe(true);
expect(typedIncludes(arr, 4)).toBe(false);
expect(typedIncludes(arr, "1")).toBe(false);
const strArr = ["a", "b", "c"] as const;
expect(typedIncludes(strArr, "a")).toBe(true);
expect(typedIncludes(strArr, "d")).toBe(false);
});
export function enumerate<T extends readonly any[]>(arr: T): [number, T[number]][] {
return arr.map((item, index) => [index, item]);
}
import.meta.vitest?.test("enumerate", ({ expect }) => {
expect(enumerate([])).toEqual([]);
expect(enumerate([1, 2, 3])).toEqual([[0, 1], [1, 2], [2, 3]]);
expect(enumerate(["a", "b", "c"])).toEqual([[0, "a"], [1, "b"], [2, "c"]]);
});
export function isShallowEqual(a: readonly any[], b: readonly any[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false;
}
return true;
}
import.meta.vitest?.test("isShallowEqual", ({ expect }) => {
expect(isShallowEqual([], [])).toBe(true);
expect(isShallowEqual([1, 2, 3], [1, 2, 3])).toBe(true);
expect(isShallowEqual([1, 2, 3], [1, 2, 4])).toBe(false);
expect(isShallowEqual([1, 2, 3], [1, 2])).toBe(false);
expect(isShallowEqual([1, 2], [1, 2, 3])).toBe(false);
// Test with objects (reference equality)
const obj1 = { a: 1 };
const obj2 = { a: 1 };
expect(isShallowEqual([obj1], [obj1])).toBe(true);
expect(isShallowEqual([obj1], [obj2])).toBe(false);
});
/**
* Ponyfill for ES2023's findLastIndex.
*/
export function findLastIndex<T>(arr: readonly T[], predicate: (item: T) => boolean): number {
for (let i = arr.length - 1; i >= 0; i--) {
if (predicate(arr[i])) return i;
}
return -1;
}
import.meta.vitest?.test("findLastIndex", ({ expect }) => {
expect(findLastIndex([], () => true)).toBe(-1);
expect(findLastIndex([1, 2, 3, 4, 5], x => x % 2 === 0)).toBe(3); // 4 is at index 3
expect(findLastIndex([1, 2, 3, 4, 5], x => x > 10)).toBe(-1);
expect(findLastIndex([1, 2, 3, 2, 1], x => x === 2)).toBe(3);
expect(findLastIndex([1, 2, 3], x => x === 1)).toBe(0);
});
export function groupBy<T extends any, K>(
arr: Iterable<T>,
key: (item: T) => K,
): Map<K, T[]> {
const result = new Map<K, T[]>;
for (const item of arr) {
const k = key(item);
if (result.get(k) === undefined) result.set(k, []);
result.get(k)!.push(item);
}
return result;
}
import.meta.vitest?.test("groupBy", ({ expect }) => {
expect(groupBy([], (x) => x)).toEqual(new Map());
const numbers = [1, 2, 3, 4, 5, 6];
const grouped = groupBy(numbers, (n) => n % 2 === 0 ? "even" : "odd");
expect(grouped.get("even")).toEqual([2, 4, 6]);
expect(grouped.get("odd")).toEqual([1, 3, 5]);
// Check the actual lengths of the words to ensure our test is correct
const words = ["apple", "banana", "cherry", "date", "elderberry"];
const byLength = groupBy(words, (w) => w.length);
// Adjust expectations based on actual word lengths
expect(byLength.get(5)).toEqual(["apple"]);
expect(byLength.get(6)).toEqual(["banana", "cherry"]);
expect(byLength.get(4)).toEqual(["date"]);
expect(byLength.get(10)).toEqual(["elderberry"]);
});
export function range(endExclusive: number): number[];
export function range(startInclusive: number, endExclusive: number): number[];
export function range(startInclusive: number, endExclusive: number, step: number): number[];
export function range(startInclusive: number, endExclusive?: number, step?: number): number[] {
if (endExclusive === undefined) {
endExclusive = startInclusive;
startInclusive = 0;
}
if (step === undefined) step = 1;
const result = [];
for (let i = startInclusive; step > 0 ? (i < endExclusive) : (i > endExclusive); i += step) {
result.push(i);
}
return result;
}
import.meta.vitest?.test("range", ({ expect }) => {
expect(range(5)).toEqual([0, 1, 2, 3, 4]);
expect(range(2, 5)).toEqual([2, 3, 4]);
expect(range(1, 10, 2)).toEqual([1, 3, 5, 7, 9]);
expect(range(5, 0, -1)).toEqual([5, 4, 3, 2, 1]);
expect(range(0, 0)).toEqual([]);
expect(range(0, 10, 3)).toEqual([0, 3, 6, 9]);
});
export function rotateLeft(arr: readonly any[], n: number): any[] {
if (arr.length === 0) return [];
const index = remainder(n, arr.length);
return [...arr.slice(index), ...arr.slice(0, index)];
}
import.meta.vitest?.test("rotateLeft", ({ expect }) => {
expect(rotateLeft([], 1)).toEqual([]);
expect(rotateLeft([1, 2, 3, 4, 5], 0)).toEqual([1, 2, 3, 4, 5]);
expect(rotateLeft([1, 2, 3, 4, 5], 1)).toEqual([2, 3, 4, 5, 1]);
expect(rotateLeft([1, 2, 3, 4, 5], 3)).toEqual([4, 5, 1, 2, 3]);
expect(rotateLeft([1, 2, 3, 4, 5], 5)).toEqual([1, 2, 3, 4, 5]);
expect(rotateLeft([1, 2, 3, 4, 5], 6)).toEqual([2, 3, 4, 5, 1]);
});
export function rotateRight(arr: readonly any[], n: number): any[] {
return rotateLeft(arr, -n);
}
import.meta.vitest?.test("rotateRight", ({ expect }) => {
expect(rotateRight([], 1)).toEqual([]);
expect(rotateRight([1, 2, 3, 4, 5], 0)).toEqual([1, 2, 3, 4, 5]);
expect(rotateRight([1, 2, 3, 4, 5], 1)).toEqual([5, 1, 2, 3, 4]);
expect(rotateRight([1, 2, 3, 4, 5], 3)).toEqual([3, 4, 5, 1, 2]);
expect(rotateRight([1, 2, 3, 4, 5], 5)).toEqual([1, 2, 3, 4, 5]);
expect(rotateRight([1, 2, 3, 4, 5], 6)).toEqual([5, 1, 2, 3, 4]);
});
export function shuffle<T>(arr: readonly T[]): T[] {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
import.meta.vitest?.test("shuffle", ({ expect }) => {
// Test empty array
expect(shuffle([])).toEqual([]);
// Test single element array
expect(shuffle([1])).toEqual([1]);
// Test that shuffle returns a new array
const original = [1, 2, 3, 4, 5];
const shuffled = shuffle(original);
expect(shuffled).not.toBe(original);
// Test that all elements are preserved
expect(shuffled.sort((a, b) => a - b)).toEqual(original);
// Test with a larger array to ensure randomness
// This is a probabilistic test, but it's very unlikely to fail
const large = Array.from({ length: 100 }, (_, i) => i);
const shuffledLarge = shuffle(large);
expect(shuffledLarge).not.toEqual(large);
expect(shuffledLarge.sort((a, b) => a - b)).toEqual(large);
});
export function outerProduct<T, U>(arr1: readonly T[], arr2: readonly U[]): [T, U][] {
return arr1.flatMap((item1) => arr2.map((item2) => [item1, item2] as [T, U]));
}
import.meta.vitest?.test("outerProduct", ({ expect }) => {
expect(outerProduct([], [])).toEqual([]);
expect(outerProduct([1], [])).toEqual([]);
expect(outerProduct([], [1])).toEqual([]);
expect(outerProduct([1], [2])).toEqual([[1, 2]]);
expect(outerProduct([1, 2], [3, 4])).toEqual([[1, 3], [1, 4], [2, 3], [2, 4]]);
expect(outerProduct(["a", "b"], [1, 2])).toEqual([["a", 1], ["a", 2], ["b", 1], ["b", 2]]);
});
export function unique<T>(arr: readonly T[]): T[] {
return [...new Set(arr)];
}
import.meta.vitest?.test("unique", ({ expect }) => {
expect(unique([])).toEqual([]);
expect(unique([1, 2, 3])).toEqual([1, 2, 3]);
expect(unique([1, 2, 2, 3, 1, 3])).toEqual([1, 2, 3]);
// Test with objects (reference equality)
const obj = { a: 1 };
expect(unique([obj, obj])).toEqual([obj]);
// Test with different types
expect(unique([1, "1", true, 1, "1", true])).toEqual([1, "1", true]);
});
export function getChunks<T>(arr: readonly T[], size: number): T[][] {
const result: T[][] = [];
if (size <= 0) return result;
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
import.meta.vitest?.test("getChunks", ({ expect }) => {
expect(getChunks([], 2)).toEqual([]);
expect(getChunks([1], 2)).toEqual([[1]]);
expect(getChunks([1, 2], 2)).toEqual([[1, 2]]);
expect(getChunks([1, 2, 3], 2)).toEqual([[1, 2], [3]]);
expect(getChunks([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
expect(getChunks([1, 2, 3, 4], 3)).toEqual([[1, 2, 3], [4]]);
expect(getChunks([1, 2, 3], 0)).toEqual([]);
expect(getChunks([1, 2, 3], -1)).toEqual([]);
});
export function isStringArray(arr: unknown): arr is string[] {
return Array.isArray(arr) && arr.every((item) => typeof item === "string");
}
export function isNumberArray(arr: unknown): arr is number[] {
return Array.isArray(arr) && arr.every((item) => typeof item === "number");
}
export function isBooleanArray(arr: unknown): arr is boolean[] {
return Array.isArray(arr) && arr.every((item) => typeof item === "boolean");
}
export function isObjectArray(arr: unknown): arr is object[] {
return Array.isArray(arr) && arr.every((item) => typeof item === "object" && item !== null);
}
import.meta.vitest?.test("is<Type>Array", ({ expect }) => {
expect(isStringArray([])).toBe(true);
expect(isNumberArray([1, 2, 3])).toBe(true);
expect(isBooleanArray([true, false, true])).toBe(true);
expect(isObjectArray([{ a: 1 }, { b: 2 }, { c: 3 }])).toBe(true);
expect(isStringArray([1, 2, 3])).toBe(false);
expect(isNumberArray(["a", "b", "c"])).toBe(false);
expect(isBooleanArray([1, 2, 3])).toBe(false);
expect(isObjectArray([1, 2, 3])).toBe(false);
expect(isObjectArray([{ a: 1 }, null, { b: 2 }])).toBe(false);
expect(isObjectArray([{ a: 1 }, undefined, { b: 2 }])).toBe(false);
});