@tuturuuu/utils 0.0.3 → 0.6.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.
Files changed (186) hide show
  1. package/CHANGELOG.md +305 -0
  2. package/biome.json +5 -0
  3. package/jsr.json +8 -8
  4. package/package.json +63 -32
  5. package/src/__tests__/ai-temp-auth.test.ts +309 -0
  6. package/src/__tests__/api-proxy-guard.test.ts +1451 -0
  7. package/src/__tests__/app-url.test.ts +270 -0
  8. package/src/__tests__/avatar-url.test.ts +97 -0
  9. package/src/__tests__/color-helper.test.ts +179 -0
  10. package/src/__tests__/constants.test.ts +351 -0
  11. package/src/__tests__/crypto.test.ts +107 -0
  12. package/src/__tests__/date-helper.test.ts +408 -0
  13. package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
  14. package/src/__tests__/format.test.ts +317 -0
  15. package/src/__tests__/html-sanitizer.test.ts +360 -0
  16. package/src/__tests__/interest-calculator.test.ts +336 -0
  17. package/src/__tests__/interest-detector.test.ts +222 -0
  18. package/src/__tests__/label-colors.test.ts +241 -0
  19. package/src/__tests__/name-helper.test.ts +158 -0
  20. package/src/__tests__/node-diff.test.ts +576 -0
  21. package/src/__tests__/notification-service.test.ts +210 -0
  22. package/src/__tests__/onboarding-helper.test.ts +331 -0
  23. package/src/__tests__/path-helper.test.ts +152 -0
  24. package/src/__tests__/permissions.test.tsx +81 -0
  25. package/src/__tests__/request-emoji-limit.test.ts +172 -0
  26. package/src/__tests__/search-helper.test.ts +51 -0
  27. package/src/__tests__/storage-display-name.test.ts +37 -0
  28. package/src/__tests__/storage-path.test.ts +238 -0
  29. package/src/__tests__/tag-utils.test.ts +205 -0
  30. package/src/__tests__/task-description-yjs-state.test.ts +581 -0
  31. package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
  32. package/src/__tests__/task-helper-create-task.test.ts +129 -0
  33. package/src/__tests__/task-helpers.test.ts +464 -0
  34. package/src/__tests__/task-overrides.test.ts +305 -0
  35. package/src/__tests__/task-reorder-cache.test.ts +74 -0
  36. package/src/__tests__/task-sort-keys.test.ts +36 -0
  37. package/src/__tests__/task-transformers.test.ts +62 -0
  38. package/src/__tests__/text-helper.test.ts +776 -0
  39. package/src/__tests__/time-helper.test.ts +70 -0
  40. package/src/__tests__/time-tracker-period.test.ts +55 -0
  41. package/src/__tests__/timezone.test.ts +117 -0
  42. package/src/__tests__/upstash-rest.test.ts +77 -0
  43. package/src/__tests__/uuid-helper.test.ts +133 -0
  44. package/src/__tests__/workspace-helper.test.ts +859 -0
  45. package/src/__tests__/workspace-limits.test.ts +255 -0
  46. package/src/__tests__/yjs-helper.test.ts +581 -0
  47. package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
  48. package/src/abuse-protection/__tests__/edge.test.ts +136 -0
  49. package/src/abuse-protection/__tests__/index.test.ts +562 -0
  50. package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
  51. package/src/abuse-protection/backend-rate-limit.ts +44 -0
  52. package/src/abuse-protection/constants.ts +117 -0
  53. package/src/abuse-protection/edge.ts +223 -0
  54. package/src/abuse-protection/index.ts +1545 -0
  55. package/src/abuse-protection/reputation.ts +587 -0
  56. package/src/abuse-protection/types.ts +97 -0
  57. package/src/abuse-protection/user-agent.ts +124 -0
  58. package/src/abuse-protection/user-suspension.ts +231 -0
  59. package/src/ai-temp-auth.ts +315 -0
  60. package/src/api-proxy-guard.ts +965 -0
  61. package/src/app-url.ts +96 -0
  62. package/src/avatar-url.ts +64 -0
  63. package/src/break-duration.ts +84 -0
  64. package/src/calendar-auth-token.test.ts +37 -0
  65. package/src/calendar-auth-token.ts +19 -0
  66. package/src/calendar-sync-coordination.md +197 -0
  67. package/src/calendar-utils.test.ts +169 -0
  68. package/src/calendar-utils.ts +91 -0
  69. package/src/color-helper.ts +110 -0
  70. package/src/common/nextjs.tsx +99 -0
  71. package/src/common/scan.tsx +15 -0
  72. package/src/configs/reports.ts +160 -0
  73. package/src/constants.ts +85 -0
  74. package/src/crypto.ts +21 -0
  75. package/src/currencies.ts +97 -0
  76. package/src/date-helper.ts +313 -0
  77. package/src/editor/convert-to-task.ts +264 -0
  78. package/src/editor/index.ts +5 -0
  79. package/src/email/__tests__/client.test.ts +141 -0
  80. package/src/email/__tests__/validation.test.ts +46 -0
  81. package/src/email/client.ts +92 -0
  82. package/src/email/server.ts +128 -0
  83. package/src/email/validation.ts +11 -0
  84. package/src/encryption/__tests__/calendar-events.test.ts +411 -0
  85. package/src/encryption/__tests__/configuration.test.ts +114 -0
  86. package/src/encryption/__tests__/field-encryption.test.ts +232 -0
  87. package/src/encryption/__tests__/key-generation.test.ts +30 -0
  88. package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
  89. package/src/encryption/__tests__/test-helpers.ts +22 -0
  90. package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
  91. package/src/encryption/encryption-service.ts +343 -0
  92. package/src/encryption/index.ts +25 -0
  93. package/src/encryption/types.ts +57 -0
  94. package/src/exchange-rates.ts +49 -0
  95. package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
  96. package/src/feature-flags/core.ts +322 -0
  97. package/src/feature-flags/data.ts +16 -0
  98. package/src/feature-flags/default.ts +18 -0
  99. package/src/feature-flags/index.ts +7 -0
  100. package/src/feature-flags/requestable-features.ts +79 -0
  101. package/src/feature-flags/types.ts +4 -0
  102. package/src/fetcher.ts +2 -0
  103. package/src/finance/index.ts +4 -0
  104. package/src/finance/interest-calculator.ts +456 -0
  105. package/src/finance/interest-detector.ts +141 -0
  106. package/src/finance/transform-invoice-results.ts +219 -0
  107. package/src/finance/wallet-permissions.test.ts +169 -0
  108. package/src/finance/wallet-permissions.ts +82 -0
  109. package/src/format.ts +120 -1
  110. package/src/generated/platform-build-metadata.ts +11 -0
  111. package/src/hooks/use-platform.ts +64 -0
  112. package/src/html-sanitizer.ts +155 -0
  113. package/src/internal-domains.ts +497 -0
  114. package/src/keyboard-preset.ts +109 -0
  115. package/src/label-colors.ts +213 -0
  116. package/src/launchable-apps.test.ts +126 -0
  117. package/src/launchable-apps.ts +490 -0
  118. package/src/name-helper.ts +269 -0
  119. package/src/next-config.test.ts +234 -0
  120. package/src/next-config.ts +203 -0
  121. package/src/node-diff.ts +375 -0
  122. package/src/notification-service.ts +379 -0
  123. package/src/nova/scores/__tests__/calculate.test.ts +254 -0
  124. package/src/nova/scores/calculate.ts +132 -0
  125. package/src/nova/submissions/check-permission.ts +132 -0
  126. package/src/onboarding-helper.ts +213 -0
  127. package/src/path-helper.ts +93 -0
  128. package/src/permissions.tsx +1170 -0
  129. package/src/plan-helpers.test.ts +188 -0
  130. package/src/plan-helpers.ts +80 -0
  131. package/src/platform-release.test.ts +74 -0
  132. package/src/platform-release.ts +155 -0
  133. package/src/portless.ts +124 -0
  134. package/src/priority-styles.ts +42 -0
  135. package/src/request-emoji-limit.ts +335 -0
  136. package/src/search-helper.ts +18 -0
  137. package/src/search.test.ts +89 -0
  138. package/src/search.ts +355 -0
  139. package/src/storage-display-name.ts +30 -0
  140. package/src/storage-path.ts +147 -0
  141. package/src/tag-utils.ts +159 -0
  142. package/src/task/reorder.ts +245 -0
  143. package/src/task/transformers.ts +149 -0
  144. package/src/task-date-timezone.ts +133 -0
  145. package/src/task-description-content.ts +240 -0
  146. package/src/task-helper/board.ts +193 -0
  147. package/src/task-helper/bulk-actions.ts +564 -0
  148. package/src/task-helper/personal-external-staging.ts +21 -0
  149. package/src/task-helper/recycle-bin.ts +202 -0
  150. package/src/task-helper/relationships.ts +346 -0
  151. package/src/task-helper/shared.ts +109 -0
  152. package/src/task-helper/sort-keys.ts +337 -0
  153. package/src/task-helper/task-hooks-basic.ts +342 -0
  154. package/src/task-helper/task-hooks-move.ts +264 -0
  155. package/src/task-helper/task-operations.ts +278 -0
  156. package/src/task-helper.ts +12 -0
  157. package/src/task-helpers.ts +241 -0
  158. package/src/task-list-status.ts +62 -0
  159. package/src/task-overrides.ts +82 -0
  160. package/src/task-snapshot.ts +374 -0
  161. package/src/text-diff.ts +81 -0
  162. package/src/text-helper.ts +537 -0
  163. package/src/time-helper.ts +63 -0
  164. package/src/time-tracker-period.ts +73 -0
  165. package/src/timeblock-helper.ts +418 -0
  166. package/src/timezone.ts +190 -0
  167. package/src/timezones.json +1271 -0
  168. package/src/upstash-rest.ts +56 -0
  169. package/src/user-helper.ts +296 -0
  170. package/src/uuid-helper.ts +11 -0
  171. package/src/workspace-handle.ts +10 -0
  172. package/src/workspace-helper.ts +1408 -0
  173. package/src/workspace-limits.ts +68 -0
  174. package/src/yjs-helper.ts +217 -0
  175. package/src/yjs-task-description.ts +81 -0
  176. package/tsconfig.json +3 -5
  177. package/tsconfig.typecheck.json +33 -0
  178. package/vitest.config.ts +36 -0
  179. package/dist/index.d.ts +0 -8
  180. package/dist/index.js +0 -2
  181. package/dist/index.js.map +0 -1
  182. package/dist/index.mjs +0 -2
  183. package/dist/index.mjs.map +0 -1
  184. package/eslint.config.mjs +0 -20
  185. package/rollup.config.js +0 -41
  186. package/src/index.ts +0 -1
@@ -0,0 +1,219 @@
1
+ import type { Invoice } from '@tuturuuu/types/primitives/Invoice';
2
+
3
+ /**
4
+ * Type definitions for the raw data structures returned from Supabase queries
5
+ */
6
+
7
+ export interface SearchInvoiceRpcResult {
8
+ id: string;
9
+ ws_id: string;
10
+ customer_id: string;
11
+ notice?: string | null;
12
+ note?: string | null;
13
+ price: number;
14
+ total_diff?: number | null;
15
+ created_at: string;
16
+ creator_id: string;
17
+ platform_creator_id?: string | null;
18
+ transaction_id?: string | null;
19
+ customer_full_name?: string | null;
20
+ customer_avatar_url?: string | null;
21
+ total_count: number;
22
+ }
23
+
24
+ export interface FullInvoiceData {
25
+ id: string;
26
+ ws_id: string;
27
+ customer_id: string | null;
28
+ notice?: string | null;
29
+ note?: string | null;
30
+ price: number;
31
+ total_diff?: number | null;
32
+ created_at: string | null;
33
+ creator_id: string | null;
34
+ platform_creator_id?: string | null;
35
+ transaction_id?: string | null;
36
+ customer?: {
37
+ full_name: string | null;
38
+ avatar_url: string | null;
39
+ } | null;
40
+ legacy_creator: LegacyCreator | null;
41
+ platform_creator: PlatformCreator | null;
42
+ wallet_transactions: WalletTransaction | null;
43
+ }
44
+
45
+ export interface LegacyCreator {
46
+ id: string;
47
+ display_name?: string | null;
48
+ full_name?: string | null;
49
+ email?: string | null;
50
+ avatar_url?: string | null;
51
+ }
52
+
53
+ export interface PlatformCreator {
54
+ id: string;
55
+ display_name: string | null;
56
+ avatar_url: string | null;
57
+ user_private_details: {
58
+ full_name: string | null;
59
+ email: string | null;
60
+ } | null;
61
+ }
62
+
63
+ export interface WalletTransaction {
64
+ wallet_id?: string | null;
65
+ wallet?: {
66
+ name: string | null;
67
+ } | null;
68
+ }
69
+
70
+ /**
71
+ * Normalizes creator data from either platform or legacy creator sources
72
+ * Resolves display_name, full_name, email, and avatar_url with proper fallback chain
73
+ *
74
+ * @param platformCreator Platform creator object (preferred source)
75
+ * @param legacyCreator Legacy creator object (fallback source)
76
+ * @returns Normalized creator object for Invoice type
77
+ */
78
+ function normalizeCreator(
79
+ platformCreator: PlatformCreator | null,
80
+ legacyCreator: LegacyCreator | null
81
+ ): Invoice['creator'] {
82
+ return {
83
+ id: platformCreator?.id ?? legacyCreator?.id ?? '',
84
+ display_name:
85
+ platformCreator?.display_name ??
86
+ legacyCreator?.display_name ??
87
+ platformCreator?.user_private_details?.email ??
88
+ null,
89
+ full_name:
90
+ platformCreator?.user_private_details?.full_name ??
91
+ legacyCreator?.full_name ??
92
+ null,
93
+ email:
94
+ platformCreator?.user_private_details?.email ??
95
+ legacyCreator?.email ??
96
+ null,
97
+ avatar_url:
98
+ platformCreator?.avatar_url ?? legacyCreator?.avatar_url ?? null,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Extracts wallet information from wallet transaction data
104
+ *
105
+ * @param walletTransactions Wallet transaction object containing wallet relation
106
+ * @returns Normalized wallet object for Invoice type, or null if no wallet present
107
+ */
108
+ function normalizeWallet(
109
+ walletTransactions: WalletTransaction | null
110
+ ): { name: string | null } | null {
111
+ return walletTransactions?.wallet
112
+ ? { name: walletTransactions.wallet.name }
113
+ : null;
114
+ }
115
+
116
+ /**
117
+ * Intermediate type for merged search results
118
+ * Combines SearchInvoiceRpcResult with additional creator and wallet data
119
+ */
120
+ interface MergedSearchInvoiceData
121
+ extends Omit<
122
+ SearchInvoiceRpcResult,
123
+ 'customer_full_name' | 'customer_avatar_url'
124
+ > {
125
+ customer: {
126
+ full_name: string | null;
127
+ avatar_url: string | null;
128
+ };
129
+ legacy_creator: LegacyCreator | null;
130
+ platform_creator: PlatformCreator | null;
131
+ wallet_transactions: WalletTransaction | null;
132
+ }
133
+
134
+ /**
135
+ * Transforms and merges search results from RPC with full invoice data from follow-up query
136
+ * Handles creator resolution (platform_creator vs legacy_creator) and wallet mapping
137
+ *
138
+ * @param searchResults Raw results from the search_finance_invoices RPC function
139
+ * @param fullInvoices Full invoice data fetched from finance_invoices table with relations
140
+ * @returns Normalized Invoice[] array ready for UI consumption
141
+ */
142
+ export function transformInvoiceSearchResults(
143
+ searchResults: SearchInvoiceRpcResult[],
144
+ fullInvoices: FullInvoiceData[]
145
+ ): Invoice[] {
146
+ // Create an index map for O(1) lookup instead of O(n) find operations
147
+ const fullInvoiceMap = new Map(fullInvoices?.map((fi) => [fi.id, fi]) ?? []);
148
+
149
+ // First merge: combine search results with full invoice data
150
+ const rawData = searchResults.map((searchRow) => {
151
+ const fullInvoice = fullInvoiceMap.get(searchRow.id);
152
+ return {
153
+ ...searchRow,
154
+ customer: {
155
+ full_name: searchRow.customer_full_name,
156
+ avatar_url: searchRow.customer_avatar_url,
157
+ },
158
+ legacy_creator: fullInvoice?.legacy_creator || null,
159
+ platform_creator: fullInvoice?.platform_creator || null,
160
+ wallet_transactions: fullInvoice?.wallet_transactions || null,
161
+ };
162
+ }) as MergedSearchInvoiceData[];
163
+
164
+ // Second transform: normalize creator data and shape into Invoice type
165
+ return rawData.map(
166
+ ({
167
+ customer,
168
+ legacy_creator,
169
+ platform_creator,
170
+ wallet_transactions,
171
+ ...rest
172
+ }: MergedSearchInvoiceData) => {
173
+ const creator = normalizeCreator(
174
+ platform_creator as PlatformCreator | null,
175
+ legacy_creator as LegacyCreator | null
176
+ );
177
+ const wallet = normalizeWallet(wallet_transactions);
178
+
179
+ return {
180
+ ...rest,
181
+ customer,
182
+ creator,
183
+ wallet,
184
+ } as Invoice;
185
+ }
186
+ );
187
+ }
188
+
189
+ /**
190
+ * Transforms direct query results (non-search path) into Invoice format
191
+ * Used when no search query is present and we fetch via standard query builder
192
+ *
193
+ * @param rawData Array of raw invoice data with creator and wallet relations
194
+ * @returns Normalized Invoice[] array ready for UI consumption
195
+ */
196
+ export function transformInvoiceData(rawData: FullInvoiceData[]): Invoice[] {
197
+ return rawData.map(
198
+ ({
199
+ customer,
200
+ legacy_creator,
201
+ platform_creator,
202
+ wallet_transactions,
203
+ ...rest
204
+ }: FullInvoiceData) => {
205
+ const creator = normalizeCreator(
206
+ platform_creator as PlatformCreator | null,
207
+ legacy_creator as LegacyCreator | null
208
+ );
209
+ const wallet = normalizeWallet(wallet_transactions);
210
+
211
+ return {
212
+ ...rest,
213
+ customer,
214
+ creator,
215
+ wallet,
216
+ } as Invoice;
217
+ }
218
+ );
219
+ }
@@ -0,0 +1,169 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ CHANGE_FINANCE_WALLETS_PERMISSION,
4
+ canChangeLinkedFinanceWallets,
5
+ canReassignFinanceWallet,
6
+ canSetAnyFinanceWalletOnCreate,
7
+ canUseRequestedFinanceWalletOnCreate,
8
+ SET_FINANCE_WALLETS_ON_CREATE_PERMISSION,
9
+ shouldLockFinanceWalletSelectionOnCreate,
10
+ } from './wallet-permissions';
11
+
12
+ function createPermissions(granted: string[]) {
13
+ const grantedSet = new Set(granted);
14
+
15
+ return {
16
+ withoutPermission: (permission: string) => !grantedSet.has(permission),
17
+ };
18
+ }
19
+
20
+ describe('finance wallet permissions', () => {
21
+ it('allows non-default wallet selection on create with either create-only or full wallet permission', () => {
22
+ const noWalletPermissions = createPermissions([]);
23
+ const createOnlyPermissions = createPermissions([
24
+ SET_FINANCE_WALLETS_ON_CREATE_PERMISSION,
25
+ ]);
26
+ const fullPermissions = createPermissions([
27
+ CHANGE_FINANCE_WALLETS_PERMISSION,
28
+ ]);
29
+
30
+ expect(
31
+ canUseRequestedFinanceWalletOnCreate({
32
+ permissions: noWalletPermissions,
33
+ defaultWalletId: 'wallet-default',
34
+ requestedWalletId: 'wallet-alt',
35
+ })
36
+ ).toBe(false);
37
+
38
+ expect(
39
+ canUseRequestedFinanceWalletOnCreate({
40
+ permissions: createOnlyPermissions,
41
+ defaultWalletId: 'wallet-default',
42
+ requestedWalletId: 'wallet-alt',
43
+ })
44
+ ).toBe(true);
45
+
46
+ expect(
47
+ canUseRequestedFinanceWalletOnCreate({
48
+ permissions: fullPermissions,
49
+ defaultWalletId: 'wallet-default',
50
+ requestedWalletId: 'wallet-alt',
51
+ })
52
+ ).toBe(true);
53
+ });
54
+
55
+ it('allows create flows when no override is needed', () => {
56
+ const noWalletPermissions = createPermissions([]);
57
+
58
+ expect(
59
+ canUseRequestedFinanceWalletOnCreate({
60
+ permissions: noWalletPermissions,
61
+ defaultWalletId: undefined,
62
+ requestedWalletId: 'wallet-alt',
63
+ })
64
+ ).toBe(true);
65
+
66
+ expect(
67
+ canUseRequestedFinanceWalletOnCreate({
68
+ permissions: noWalletPermissions,
69
+ defaultWalletId: 'wallet-default',
70
+ requestedWalletId: 'wallet-default',
71
+ })
72
+ ).toBe(true);
73
+ });
74
+
75
+ it('keeps reassignment restricted to the full wallet-change permission', () => {
76
+ const createOnlyPermissions = createPermissions([
77
+ SET_FINANCE_WALLETS_ON_CREATE_PERMISSION,
78
+ ]);
79
+ const fullPermissions = createPermissions([
80
+ CHANGE_FINANCE_WALLETS_PERMISSION,
81
+ ]);
82
+
83
+ expect(
84
+ canReassignFinanceWallet({
85
+ permissions: createOnlyPermissions,
86
+ currentWalletId: 'wallet-default',
87
+ requestedWalletId: 'wallet-alt',
88
+ })
89
+ ).toBe(false);
90
+
91
+ expect(
92
+ canReassignFinanceWallet({
93
+ permissions: fullPermissions,
94
+ currentWalletId: 'wallet-default',
95
+ requestedWalletId: 'wallet-alt',
96
+ })
97
+ ).toBe(true);
98
+ });
99
+
100
+ it('treats unchanged or omitted wallet updates as allowed', () => {
101
+ const noWalletPermissions = createPermissions([]);
102
+
103
+ expect(
104
+ canReassignFinanceWallet({
105
+ permissions: noWalletPermissions,
106
+ currentWalletId: 'wallet-default',
107
+ requestedWalletId: 'wallet-default',
108
+ })
109
+ ).toBe(true);
110
+
111
+ expect(
112
+ canReassignFinanceWallet({
113
+ permissions: noWalletPermissions,
114
+ currentWalletId: 'wallet-default',
115
+ requestedWalletId: undefined,
116
+ })
117
+ ).toBe(true);
118
+ });
119
+
120
+ it('surfaces helper booleans for create-only and full wallet access', () => {
121
+ expect(canChangeLinkedFinanceWallets(createPermissions([]))).toBe(false);
122
+ expect(
123
+ canChangeLinkedFinanceWallets(
124
+ createPermissions([CHANGE_FINANCE_WALLETS_PERMISSION])
125
+ )
126
+ ).toBe(true);
127
+
128
+ expect(canSetAnyFinanceWalletOnCreate(createPermissions([]))).toBe(false);
129
+ expect(
130
+ canSetAnyFinanceWalletOnCreate(
131
+ createPermissions([SET_FINANCE_WALLETS_ON_CREATE_PERMISSION])
132
+ )
133
+ ).toBe(true);
134
+ });
135
+
136
+ it('locks create-form wallet selection only when neither wallet override permission is present', () => {
137
+ expect(
138
+ shouldLockFinanceWalletSelectionOnCreate({
139
+ defaultWalletId: 'wallet-default',
140
+ canChangeFinanceWallets: false,
141
+ canSetFinanceWalletsOnCreate: false,
142
+ })
143
+ ).toBe(true);
144
+
145
+ expect(
146
+ shouldLockFinanceWalletSelectionOnCreate({
147
+ defaultWalletId: 'wallet-default',
148
+ canChangeFinanceWallets: false,
149
+ canSetFinanceWalletsOnCreate: true,
150
+ })
151
+ ).toBe(false);
152
+
153
+ expect(
154
+ shouldLockFinanceWalletSelectionOnCreate({
155
+ defaultWalletId: 'wallet-default',
156
+ canChangeFinanceWallets: true,
157
+ canSetFinanceWalletsOnCreate: false,
158
+ })
159
+ ).toBe(false);
160
+
161
+ expect(
162
+ shouldLockFinanceWalletSelectionOnCreate({
163
+ defaultWalletId: undefined,
164
+ canChangeFinanceWallets: false,
165
+ canSetFinanceWalletsOnCreate: false,
166
+ })
167
+ ).toBe(false);
168
+ });
169
+ });
@@ -0,0 +1,82 @@
1
+ import type { PermissionId } from '@tuturuuu/types/db';
2
+
3
+ export const CHANGE_FINANCE_WALLETS_PERMISSION =
4
+ 'change_finance_wallets' as const;
5
+ export const SET_FINANCE_WALLETS_ON_CREATE_PERMISSION =
6
+ 'set_finance_wallets_on_create' as const;
7
+
8
+ type FinanceWalletPermissionLookup = {
9
+ withoutPermission: (permission: PermissionId) => boolean;
10
+ };
11
+
12
+ interface FinanceWalletCreateAccessOptions {
13
+ permissions: FinanceWalletPermissionLookup;
14
+ defaultWalletId?: string | null;
15
+ requestedWalletId?: string | null;
16
+ }
17
+
18
+ interface FinanceWalletReassignmentOptions {
19
+ permissions: FinanceWalletPermissionLookup;
20
+ currentWalletId?: string | null;
21
+ requestedWalletId?: string | null;
22
+ }
23
+
24
+ interface FinanceWalletCreateLockOptions {
25
+ defaultWalletId?: string | null;
26
+ canChangeFinanceWallets?: boolean;
27
+ canSetFinanceWalletsOnCreate?: boolean;
28
+ }
29
+
30
+ export function canChangeLinkedFinanceWallets(
31
+ permissions: FinanceWalletPermissionLookup
32
+ ) {
33
+ return !permissions.withoutPermission(CHANGE_FINANCE_WALLETS_PERMISSION);
34
+ }
35
+
36
+ export function canSetAnyFinanceWalletOnCreate(
37
+ permissions: FinanceWalletPermissionLookup
38
+ ) {
39
+ return (
40
+ canChangeLinkedFinanceWallets(permissions) ||
41
+ !permissions.withoutPermission(SET_FINANCE_WALLETS_ON_CREATE_PERMISSION)
42
+ );
43
+ }
44
+
45
+ export function canUseRequestedFinanceWalletOnCreate({
46
+ permissions,
47
+ defaultWalletId,
48
+ requestedWalletId,
49
+ }: FinanceWalletCreateAccessOptions) {
50
+ if (!defaultWalletId || requestedWalletId === defaultWalletId) {
51
+ return true;
52
+ }
53
+
54
+ return canSetAnyFinanceWalletOnCreate(permissions);
55
+ }
56
+
57
+ export function canReassignFinanceWallet({
58
+ permissions,
59
+ currentWalletId,
60
+ requestedWalletId,
61
+ }: FinanceWalletReassignmentOptions) {
62
+ if (
63
+ requestedWalletId === undefined ||
64
+ requestedWalletId === currentWalletId
65
+ ) {
66
+ return true;
67
+ }
68
+
69
+ return canChangeLinkedFinanceWallets(permissions);
70
+ }
71
+
72
+ export function shouldLockFinanceWalletSelectionOnCreate({
73
+ defaultWalletId,
74
+ canChangeFinanceWallets = false,
75
+ canSetFinanceWalletsOnCreate = false,
76
+ }: FinanceWalletCreateLockOptions) {
77
+ return (
78
+ !!defaultWalletId &&
79
+ !canChangeFinanceWallets &&
80
+ !canSetFinanceWalletsOnCreate
81
+ );
82
+ }
package/src/format.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { type ClassValue, clsx } from 'clsx';
2
2
  import { twMerge } from 'tailwind-merge';
3
3
 
4
+ import { getCurrencyLocale as getCurrencyLocaleFromConfig } from './currencies';
5
+
4
6
  export function cn(...inputs: ClassValue[]): string {
5
7
  return twMerge(clsx(inputs));
6
8
  }
@@ -25,9 +27,126 @@ export function formatBytes(
25
27
  const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'];
26
28
  if (bytes === 0) return '0 Byte';
27
29
  const i = Math.floor(Math.log(bytes) / Math.log(1024));
28
- return `${(bytes / Math.pow(1024, i)).toFixed(decimals)} ${
30
+ return `${(bytes / 1024 ** i).toFixed(decimals)} ${
29
31
  sizeType === 'accurate'
30
32
  ? (accurateSizes[i] ?? 'Bytest')
31
33
  : (sizes[i] ?? 'Bytes')
32
34
  }`;
33
35
  }
36
+
37
+ export function formatDuration(seconds: number): string {
38
+ if (seconds < 60) {
39
+ return `${seconds} seconds`;
40
+ }
41
+
42
+ const hours = Math.floor(seconds / 3600);
43
+ const minutes = Math.floor((seconds % 3600) / 60);
44
+ const remainingSeconds = seconds % 60;
45
+
46
+ const parts = [];
47
+ if (hours > 0) {
48
+ parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
49
+ }
50
+ if (minutes > 0) {
51
+ parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
52
+ }
53
+ if (remainingSeconds > 0 && hours === 0) {
54
+ // Only show seconds if less than 1 hour
55
+ parts.push(
56
+ `${remainingSeconds} ${remainingSeconds === 1 ? 'second' : 'seconds'}`
57
+ );
58
+ }
59
+
60
+ return parts.join(' ');
61
+ }
62
+
63
+ /**
64
+ * Validates if a URL is a safe blob URL
65
+ */
66
+ export function isValidBlobUrl(url: string | null | undefined): boolean {
67
+ if (typeof url !== 'string') return false;
68
+ const s = url.trim();
69
+ return s.toLowerCase().startsWith('blob:');
70
+ }
71
+
72
+ /**
73
+ * Validates if a URL is a safe HTTP or HTTPS URL
74
+ */
75
+ export function isValidHttpUrl(url: string | null | undefined): boolean {
76
+ if (typeof url !== 'string') return false;
77
+ const s = url.trim();
78
+ try {
79
+ const parsedUrl = new URL(s);
80
+ const protocol = parsedUrl.protocol.toLowerCase();
81
+ // Require a hostname to avoid odd cases like "http:/foo"
82
+ return (
83
+ (protocol === 'http:' || protocol === 'https:') && !!parsedUrl.hostname
84
+ );
85
+ } catch {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Format a number in compact notation (e.g., 1.2K, 125K, 3.4M).
92
+ * Numbers below `threshold` are returned as-is with locale grouping.
93
+ *
94
+ * @param value - The number to format
95
+ * @param opts - Options for formatting
96
+ * @param opts.threshold - Minimum value to trigger compact notation (default: 1000)
97
+ * @param opts.maximumFractionDigits - Max decimal places in compact form (default: 1)
98
+ * @param opts.locale - Locale for Intl formatting (default: 'en')
99
+ * @returns Formatted string (e.g., "999", "1.2K", "3.4M")
100
+ */
101
+ export function formatCompactNumber(
102
+ value: number,
103
+ opts: {
104
+ threshold?: number;
105
+ maximumFractionDigits?: number;
106
+ locale?: string;
107
+ } = {}
108
+ ): string {
109
+ const { threshold = 1000, maximumFractionDigits = 1, locale = 'en' } = opts;
110
+
111
+ if (Math.abs(value) < threshold) {
112
+ return value.toLocaleString(locale);
113
+ }
114
+
115
+ return new Intl.NumberFormat(locale, {
116
+ notation: 'compact',
117
+ maximumFractionDigits,
118
+ }).format(value);
119
+ }
120
+
121
+ /**
122
+ * Get the locale for a specific currency.
123
+ * Re-exported from currencies module for backward compatibility.
124
+ *
125
+ * @param currency - The currency code
126
+ * @returns The locale string
127
+ */
128
+ export const getCurrencyLocale = getCurrencyLocaleFromConfig;
129
+
130
+ /**
131
+ * Format a number as currency with locale-specific formatting
132
+ * @param amount - The amount to format
133
+ * @param currency - The currency code (default: 'VND')
134
+ * @param locale - The locale to use (optional, derived from currency if not provided)
135
+ * @param options - Additional Intl.NumberFormat options
136
+ * @returns Formatted currency string
137
+ */
138
+ export function formatCurrency(
139
+ amount: number,
140
+ currency = 'VND',
141
+ locale?: string,
142
+ options?: Partial<Intl.NumberFormatOptions>
143
+ ): string {
144
+ const { signDisplay = 'auto', ...rest } = options || {};
145
+ const effectiveLocale = locale || getCurrencyLocale(currency);
146
+ return new Intl.NumberFormat(effectiveLocale, {
147
+ style: 'currency',
148
+ currency,
149
+ signDisplay,
150
+ ...rest,
151
+ }).format(amount);
152
+ }
@@ -0,0 +1,11 @@
1
+ import type { PlatformBuildMetadataInput } from '../platform-release';
2
+
3
+ export const PLATFORM_BUILD_METADATA: PlatformBuildMetadataInput = {
4
+ builtAt: 'local',
5
+ commitHash: 'local',
6
+ commitMessage: 'Unknown',
7
+ deploymentStamp: null,
8
+ deploymentUrl: null,
9
+ environment: 'local',
10
+ refName: 'local',
11
+ };
@@ -0,0 +1,64 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore } from 'react';
4
+
5
+ /**
6
+ * Detect if the current platform is Mac
7
+ * This runs synchronously to avoid hydration mismatch
8
+ */
9
+ function getIsMac(): boolean {
10
+ if (typeof window === 'undefined') return false;
11
+
12
+ // Modern API: navigator.userAgentData (Chromium-based browsers)
13
+ if ('userAgentData' in navigator && navigator.userAgentData) {
14
+ const platform = (navigator.userAgentData as { platform?: string })
15
+ .platform;
16
+ return platform?.toLowerCase().includes('mac') ?? false;
17
+ }
18
+
19
+ // Fallback: navigator.platform (deprecated but widely supported)
20
+ return /Mac|iPhone|iPad|iPod/.test(navigator.platform);
21
+ }
22
+
23
+ // Cache the result since it won't change during session
24
+ let cachedIsMac: boolean | null = null;
25
+
26
+ function subscribe() {
27
+ // Platform doesn't change, so no-op
28
+ return () => {};
29
+ }
30
+
31
+ function getSnapshot(): boolean {
32
+ if (cachedIsMac === null) {
33
+ cachedIsMac = getIsMac();
34
+ }
35
+ return cachedIsMac;
36
+ }
37
+
38
+ function getServerSnapshot(): boolean {
39
+ // On server, default to false (non-Mac)
40
+ // The client will hydrate with the correct value
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * Hook to detect the user's platform (Mac vs non-Mac)
46
+ * Uses useSyncExternalStore for proper SSR/hydration handling
47
+ */
48
+ export function usePlatform() {
49
+ const isMac = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
50
+
51
+ return {
52
+ isMac,
53
+ modKey: isMac ? '⌘' : 'Ctrl',
54
+ modKeyAlt: isMac ? '⌥' : 'Alt',
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Get platform-aware modifier key for SSR-safe contexts
60
+ * Returns a default and should be used with the hook for client-side rendering
61
+ */
62
+ export function getModifierKeyLabel(isMac: boolean) {
63
+ return isMac ? '⌘' : 'Ctrl';
64
+ }