@tuturuuu/utils 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ formatMoneyFromMinor,
4
+ getAmountStep,
5
+ getCurrencyFractionDigits,
6
+ getMinorUnitFactor,
7
+ majorToMinor,
8
+ minorToMajor,
9
+ normalizeAmount,
10
+ } from './money';
11
+
12
+ describe('currency precision', () => {
13
+ it('knows fraction digits per currency', () => {
14
+ expect(getCurrencyFractionDigits('USD')).toBe(2);
15
+ expect(getCurrencyFractionDigits('EUR')).toBe(2);
16
+ expect(getCurrencyFractionDigits('JPY')).toBe(0);
17
+ expect(getCurrencyFractionDigits('VND')).toBe(0);
18
+ });
19
+
20
+ it('falls back to 2 digits for unknown currencies', () => {
21
+ expect(getCurrencyFractionDigits('XYZ')).toBe(2);
22
+ });
23
+
24
+ it('derives the minor-unit factor', () => {
25
+ expect(getMinorUnitFactor('USD')).toBe(100);
26
+ expect(getMinorUnitFactor('JPY')).toBe(1);
27
+ expect(getMinorUnitFactor('VND')).toBe(1);
28
+ });
29
+ });
30
+
31
+ describe('major <-> minor conversion', () => {
32
+ it('converts dollars to cents', () => {
33
+ expect(majorToMinor(100, 'USD')).toBe(10000);
34
+ expect(majorToMinor(9.99, 'USD')).toBe(999);
35
+ });
36
+
37
+ it('treats zero-decimal currencies 1:1', () => {
38
+ expect(majorToMinor(500, 'JPY')).toBe(500);
39
+ expect(majorToMinor(25000, 'VND')).toBe(25000);
40
+ });
41
+
42
+ it('round-trips cleanly', () => {
43
+ expect(minorToMajor(10000, 'USD')).toBe(100);
44
+ expect(minorToMajor(999, 'USD')).toBe(9.99);
45
+ expect(minorToMajor(500, 'JPY')).toBe(500);
46
+ });
47
+
48
+ it('rounds rather than truncating fractional minor units', () => {
49
+ expect(majorToMinor(1.005, 'USD')).toBe(101);
50
+ });
51
+
52
+ it('handles non-finite input defensively', () => {
53
+ expect(majorToMinor(Number.NaN, 'USD')).toBe(0);
54
+ expect(minorToMajor(Number.POSITIVE_INFINITY, 'USD')).toBe(0);
55
+ });
56
+ });
57
+
58
+ describe('normalizeAmount & getAmountStep', () => {
59
+ it('normalizes to currency precision', () => {
60
+ expect(normalizeAmount(9.999, 'USD')).toBe(10);
61
+ expect(normalizeAmount(9.994, 'USD')).toBe(9.99);
62
+ expect(normalizeAmount(500.7, 'JPY')).toBe(501);
63
+ });
64
+
65
+ it('returns the right input step', () => {
66
+ expect(getAmountStep('USD')).toBe('0.01');
67
+ expect(getAmountStep('JPY')).toBe('1');
68
+ expect(getAmountStep('VND')).toBe('1');
69
+ });
70
+ });
71
+
72
+ describe('formatMoneyFromMinor', () => {
73
+ it('formats USD cents as dollars', () => {
74
+ // Non-breaking space / symbol placement varies by ICU; assert the digits.
75
+ expect(formatMoneyFromMinor(10000, 'USD', 'en-US')).toContain('100.00');
76
+ expect(formatMoneyFromMinor(999, 'USD', 'en-US')).toContain('9.99');
77
+ });
78
+
79
+ it('formats zero-decimal currencies without scaling', () => {
80
+ expect(formatMoneyFromMinor(500, 'JPY', 'ja-JP')).toContain('500');
81
+ });
82
+ });
package/src/money.ts ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Centralized money/minor-unit helpers.
3
+ *
4
+ * Money is modeled as integer **minor units** (the currency's smallest unit:
5
+ * cents for USD/EUR, but whole units for zero-decimal currencies like JPY/VND,
6
+ * and thousandths for three-decimal currencies like BHD). This matches how
7
+ * payment processors (e.g. Polar/Stripe) represent amounts and avoids floating
8
+ * point drift on money.
9
+ *
10
+ * Use {@link majorToMinor} when persisting a user-entered amount, and
11
+ * {@link minorToMajor} / {@link formatMoneyFromMinor} when reading a stored
12
+ * amount back for display. The conversion factor is currency-aware, so never
13
+ * hard-code `* 100` / `/ 100`.
14
+ */
15
+
16
+ import { getCurrencyLocale } from './currencies';
17
+ import { formatCurrency } from './format';
18
+
19
+ const DEFAULT_FRACTION_DIGITS = 2;
20
+
21
+ // Intl construction is comparatively expensive; cache per currency code.
22
+ const fractionDigitsCache = new Map<string, number>();
23
+
24
+ /**
25
+ * Round a scaled value to the nearest integer while neutralizing binary
26
+ * floating-point error (e.g. `1.005 * 100` is `100.4999…`, which must round to
27
+ * `101`, not `100`). Trimming to 4 extra decimals before rounding removes the
28
+ * noise without affecting legitimate values.
29
+ */
30
+ function roundScaled(value: number): number {
31
+ return Math.round(Number(value.toFixed(4)));
32
+ }
33
+
34
+ /**
35
+ * Number of decimal places a currency uses (USD → 2, JPY/VND → 0, BHD → 3).
36
+ * Falls back to 2 for unknown/invalid currency codes.
37
+ */
38
+ export function getCurrencyFractionDigits(currency = 'USD'): number {
39
+ const code = currency.toUpperCase();
40
+ const cached = fractionDigitsCache.get(code);
41
+ if (cached !== undefined) return cached;
42
+
43
+ let digits = DEFAULT_FRACTION_DIGITS;
44
+ try {
45
+ digits =
46
+ new Intl.NumberFormat(getCurrencyLocale(code), {
47
+ style: 'currency',
48
+ currency: code,
49
+ }).resolvedOptions().maximumFractionDigits ?? DEFAULT_FRACTION_DIGITS;
50
+ } catch {
51
+ digits = DEFAULT_FRACTION_DIGITS;
52
+ }
53
+
54
+ fractionDigitsCache.set(code, digits);
55
+ return digits;
56
+ }
57
+
58
+ /**
59
+ * Multiplier between a currency's major and minor units
60
+ * (USD → 100, JPY/VND → 1, BHD → 1000).
61
+ */
62
+ export function getMinorUnitFactor(currency = 'USD'): number {
63
+ return 10 ** getCurrencyFractionDigits(currency);
64
+ }
65
+
66
+ /**
67
+ * Convert a major-unit amount (e.g. dollars `100`, or `9.99`) into integer
68
+ * minor units (e.g. `10000`, `999`) for storage / sending to a payment API.
69
+ */
70
+ export function majorToMinor(major: number, currency = 'USD'): number {
71
+ if (!Number.isFinite(major)) return 0;
72
+ return roundScaled(major * getMinorUnitFactor(currency));
73
+ }
74
+
75
+ /**
76
+ * Convert integer minor units (e.g. `10000`) back into a major-unit amount
77
+ * (e.g. `100`) for display or for ledgers that store major units.
78
+ */
79
+ export function minorToMajor(minor: number, currency = 'USD'): number {
80
+ if (!Number.isFinite(minor)) return 0;
81
+ return minor / getMinorUnitFactor(currency);
82
+ }
83
+
84
+ /**
85
+ * Round a major-unit amount to the currency's precision (drops sub-minor-unit
86
+ * noise from user input before it is converted/stored).
87
+ */
88
+ export function normalizeAmount(major: number, currency = 'USD'): number {
89
+ if (!Number.isFinite(major)) return 0;
90
+ const factor = getMinorUnitFactor(currency);
91
+ return roundScaled(major * factor) / factor;
92
+ }
93
+
94
+ /**
95
+ * The numeric step a major-unit price input should use for the currency
96
+ * ('1' for zero-decimal currencies, '0.01' for two-decimal, etc.).
97
+ */
98
+ export function getAmountStep(currency = 'USD'): string {
99
+ const fractionDigits = getCurrencyFractionDigits(currency);
100
+ if (fractionDigits <= 0) return '1';
101
+ return (1 / 10 ** fractionDigits).toFixed(fractionDigits);
102
+ }
103
+
104
+ /**
105
+ * Format an amount stored in minor units as a localized currency string.
106
+ * This is the canonical display path for minor-unit-stored money.
107
+ */
108
+ export function formatMoneyFromMinor(
109
+ minorAmount: number,
110
+ currency = 'USD',
111
+ locale?: string,
112
+ options?: Partial<Intl.NumberFormatOptions>
113
+ ): string {
114
+ return formatCurrency(
115
+ minorToMajor(minorAmount, currency),
116
+ currency,
117
+ locale,
118
+ options
119
+ );
120
+ }
@@ -23,6 +23,19 @@ describe('createTuturuuuNextConfig', () => {
23
23
  expect(config.typescript?.ignoreBuildErrors).toBe(true);
24
24
  });
25
25
 
26
+ it('does not expose loopback hosts to the shared image optimizer allowlist', () => {
27
+ const config = createTuturuuuNextConfig();
28
+
29
+ expect(config.images?.remotePatterns).not.toContainEqual({
30
+ protocol: 'http',
31
+ hostname: 'localhost',
32
+ });
33
+ expect(config.images?.remotePatterns).not.toContainEqual({
34
+ protocol: 'http',
35
+ hostname: '127.0.0.1',
36
+ });
37
+ });
38
+
26
39
  it('allows the exact worktree-prefixed Portless host for Next dev assets', () => {
27
40
  const originalPortlessUrl = process.env.PORTLESS_URL;
28
41
 
@@ -19,14 +19,6 @@ type NextImageRemotePattern = NonNullable<
19
19
  >[number];
20
20
 
21
21
  export const TUTURUUU_NEXT_IMAGE_REMOTE_PATTERNS = [
22
- {
23
- protocol: 'http',
24
- hostname: 'localhost',
25
- },
26
- {
27
- protocol: 'http',
28
- hostname: '127.0.0.1',
29
- },
30
22
  {
31
23
  protocol: 'https',
32
24
  hostname: '**.supabase.co',
@@ -7,7 +7,7 @@ import {
7
7
 
8
8
  describe('platform release metadata', () => {
9
9
  it('uses the centralized shared browser app version', () => {
10
- expect(TUTURUUU_PLATFORM_VERSION).toBe('0.11.0'); // x-release-please-version
10
+ expect(TUTURUUU_PLATFORM_VERSION).toBe('0.13.0'); // x-release-please-version
11
11
  });
12
12
 
13
13
  it('normalizes generated metadata and derives a short hash', () => {
@@ -1,6 +1,6 @@
1
1
  import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
2
2
 
3
- export const TUTURUUU_PLATFORM_VERSION = '0.11.0'; // x-release-please-version
3
+ export const TUTURUUU_PLATFORM_VERSION = '0.13.0'; // x-release-please-version
4
4
 
5
5
  export type PlatformBuildMetadataInput = {
6
6
  builtAt?: string | null;
package/src/portless.ts CHANGED
@@ -15,6 +15,7 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
15
15
  finance: `https://finance.${TUTURUUU_PORTLESS_ROOT_HOST}`,
16
16
  hive: `https://hive.${TUTURUUU_PORTLESS_ROOT_HOST}`,
17
17
  'hive-realtime': `https://realtime.hive.${TUTURUUU_PORTLESS_ROOT_HOST}`,
18
+ infra: `https://infra.${TUTURUUU_PORTLESS_ROOT_HOST}`,
18
19
  inventory: `https://inventory.${TUTURUUU_PORTLESS_ROOT_HOST}`,
19
20
  learn: `https://learn.${TUTURUUU_PORTLESS_ROOT_HOST}`,
20
21
  mail: `https://mail.${TUTURUUU_PORTLESS_ROOT_HOST}`,
@@ -27,6 +28,7 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
27
28
  rewise: `https://rewise.${TUTURUUU_PORTLESS_ROOT_HOST}`,
28
29
  shortener: `https://shortener.${TUTURUUU_PORTLESS_ROOT_HOST}`,
29
30
  storefront: `https://storefront.${TUTURUUU_PORTLESS_ROOT_HOST}`,
31
+ 'tanstack-web': `https://tanstack.${TUTURUUU_PORTLESS_ROOT_HOST}`,
30
32
  tasks: `https://tasks.${TUTURUUU_PORTLESS_ROOT_HOST}`,
31
33
  teach: `https://teach.${TUTURUUU_PORTLESS_ROOT_HOST}`,
32
34
  track: `https://track.${TUTURUUU_PORTLESS_ROOT_HOST}`,
@@ -165,6 +165,9 @@ export async function invalidateTaskCaches(
165
165
  promises.push(
166
166
  queryClient.invalidateQueries({ queryKey: ['task_lists', boardId] })
167
167
  );
168
+ promises.push(
169
+ queryClient.invalidateQueries({ queryKey: ['kanban-deadline-tasks'] })
170
+ );
168
171
  }
169
172
 
170
173
  promises.push(
@@ -2,7 +2,7 @@ import type { Redis } from '@upstash/redis';
2
2
 
3
3
  export type UpstashRestRedisClient = Pick<
4
4
  Redis,
5
- 'decr' | 'del' | 'expire' | 'get' | 'incr' | 'set' | 'ttl'
5
+ 'decr' | 'del' | 'expire' | 'get' | 'incr' | 'mget' | 'scan' | 'set' | 'ttl'
6
6
  >;
7
7
  export type UpstashRatelimitRedisClient = Pick<
8
8
  Redis,
@@ -30,6 +30,8 @@ export async function getUpstashRestRedisClient(): Promise<UpstashRestRedisClien
30
30
  expire: (key, seconds) => client.expire(key, seconds),
31
31
  get: <T = unknown>(key: string) => client.get<T>(key),
32
32
  incr: (key) => client.incr(key),
33
+ mget: (...args) => client.mget(...args),
34
+ scan: client.scan.bind(client),
33
35
  set: (key, value, options) => client.set(key, value, options),
34
36
  ttl: (key) => client.ttl(key),
35
37
  };
@@ -12,6 +12,7 @@ import type {
12
12
  } from '@tuturuuu/types';
13
13
  import type { WorkspaceSecret } from '@tuturuuu/types/primitives/WorkspaceSecret';
14
14
  import type { NextRequest } from 'next/server';
15
+ import { cache } from 'react';
15
16
  import {
16
17
  PERSONAL_WORKSPACE_SLUG,
17
18
  ROOT_WORKSPACE_ID,
@@ -703,7 +704,33 @@ async function resolveWorkspaceIdForPermissions({
703
704
  return workspace.id;
704
705
  }
705
706
 
706
- export async function getPermissions({
707
+ /**
708
+ * Resolve the caller's workspace permissions.
709
+ *
710
+ * The common Server Component flow (`getPermissions({ wsId })`, no explicit
711
+ * `user`/`request`) is memoized per request via React `cache()` keyed on the
712
+ * primitive `wsId`, so multiple callers in the same render tree (e.g. a layout
713
+ * and its page) share a single permission lookup instead of each running ~3
714
+ * queries. Flows that pass an explicit `user` or `request` bypass the cache to
715
+ * avoid any cross-request contamination.
716
+ */
717
+ export async function getPermissions(args: {
718
+ user?: AuthenticatedWorkspacePrincipal | null;
719
+ wsId: string;
720
+ request?: Request;
721
+ }): Promise<PermissionsResult | null> {
722
+ if (!args.user && !args.request) {
723
+ return getPermissionsByWsId(args.wsId);
724
+ }
725
+ return getPermissionsImpl(args);
726
+ }
727
+
728
+ const getPermissionsByWsId = cache(
729
+ async (wsId: string): Promise<PermissionsResult | null> =>
730
+ getPermissionsImpl({ wsId })
731
+ );
732
+
733
+ async function getPermissionsImpl({
707
734
  user,
708
735
  wsId,
709
736
  request,