@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.
- package/CHANGELOG.md +305 -0
- package/biome.json +5 -0
- package/jsr.json +8 -8
- package/package.json +63 -32
- package/src/__tests__/ai-temp-auth.test.ts +309 -0
- package/src/__tests__/api-proxy-guard.test.ts +1451 -0
- package/src/__tests__/app-url.test.ts +270 -0
- package/src/__tests__/avatar-url.test.ts +97 -0
- package/src/__tests__/color-helper.test.ts +179 -0
- package/src/__tests__/constants.test.ts +351 -0
- package/src/__tests__/crypto.test.ts +107 -0
- package/src/__tests__/date-helper.test.ts +408 -0
- package/src/__tests__/fixtures/task-description-full-featured.json +456 -0
- package/src/__tests__/format.test.ts +317 -0
- package/src/__tests__/html-sanitizer.test.ts +360 -0
- package/src/__tests__/interest-calculator.test.ts +336 -0
- package/src/__tests__/interest-detector.test.ts +222 -0
- package/src/__tests__/label-colors.test.ts +241 -0
- package/src/__tests__/name-helper.test.ts +158 -0
- package/src/__tests__/node-diff.test.ts +576 -0
- package/src/__tests__/notification-service.test.ts +210 -0
- package/src/__tests__/onboarding-helper.test.ts +331 -0
- package/src/__tests__/path-helper.test.ts +152 -0
- package/src/__tests__/permissions.test.tsx +81 -0
- package/src/__tests__/request-emoji-limit.test.ts +172 -0
- package/src/__tests__/search-helper.test.ts +51 -0
- package/src/__tests__/storage-display-name.test.ts +37 -0
- package/src/__tests__/storage-path.test.ts +238 -0
- package/src/__tests__/tag-utils.test.ts +205 -0
- package/src/__tests__/task-description-yjs-state.test.ts +581 -0
- package/src/__tests__/task-helper-board-api-routing.test.ts +94 -0
- package/src/__tests__/task-helper-create-task.test.ts +129 -0
- package/src/__tests__/task-helpers.test.ts +464 -0
- package/src/__tests__/task-overrides.test.ts +305 -0
- package/src/__tests__/task-reorder-cache.test.ts +74 -0
- package/src/__tests__/task-sort-keys.test.ts +36 -0
- package/src/__tests__/task-transformers.test.ts +62 -0
- package/src/__tests__/text-helper.test.ts +776 -0
- package/src/__tests__/time-helper.test.ts +70 -0
- package/src/__tests__/time-tracker-period.test.ts +55 -0
- package/src/__tests__/timezone.test.ts +117 -0
- package/src/__tests__/upstash-rest.test.ts +77 -0
- package/src/__tests__/uuid-helper.test.ts +133 -0
- package/src/__tests__/workspace-helper.test.ts +859 -0
- package/src/__tests__/workspace-limits.test.ts +255 -0
- package/src/__tests__/yjs-helper.test.ts +581 -0
- package/src/abuse-protection/__tests__/backend-rate-limit.test.ts +113 -0
- package/src/abuse-protection/__tests__/edge.test.ts +136 -0
- package/src/abuse-protection/__tests__/index.test.ts +562 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +192 -0
- package/src/abuse-protection/backend-rate-limit.ts +44 -0
- package/src/abuse-protection/constants.ts +117 -0
- package/src/abuse-protection/edge.ts +223 -0
- package/src/abuse-protection/index.ts +1545 -0
- package/src/abuse-protection/reputation.ts +587 -0
- package/src/abuse-protection/types.ts +97 -0
- package/src/abuse-protection/user-agent.ts +124 -0
- package/src/abuse-protection/user-suspension.ts +231 -0
- package/src/ai-temp-auth.ts +315 -0
- package/src/api-proxy-guard.ts +965 -0
- package/src/app-url.ts +96 -0
- package/src/avatar-url.ts +64 -0
- package/src/break-duration.ts +84 -0
- package/src/calendar-auth-token.test.ts +37 -0
- package/src/calendar-auth-token.ts +19 -0
- package/src/calendar-sync-coordination.md +197 -0
- package/src/calendar-utils.test.ts +169 -0
- package/src/calendar-utils.ts +91 -0
- package/src/color-helper.ts +110 -0
- package/src/common/nextjs.tsx +99 -0
- package/src/common/scan.tsx +15 -0
- package/src/configs/reports.ts +160 -0
- package/src/constants.ts +85 -0
- package/src/crypto.ts +21 -0
- package/src/currencies.ts +97 -0
- package/src/date-helper.ts +313 -0
- package/src/editor/convert-to-task.ts +264 -0
- package/src/editor/index.ts +5 -0
- package/src/email/__tests__/client.test.ts +141 -0
- package/src/email/__tests__/validation.test.ts +46 -0
- package/src/email/client.ts +92 -0
- package/src/email/server.ts +128 -0
- package/src/email/validation.ts +11 -0
- package/src/encryption/__tests__/calendar-events.test.ts +411 -0
- package/src/encryption/__tests__/configuration.test.ts +114 -0
- package/src/encryption/__tests__/field-encryption.test.ts +232 -0
- package/src/encryption/__tests__/key-generation.test.ts +30 -0
- package/src/encryption/__tests__/performance-edge-cases.test.ts +187 -0
- package/src/encryption/__tests__/test-helpers.ts +22 -0
- package/src/encryption/__tests__/workspace-key-encryption.test.ts +129 -0
- package/src/encryption/encryption-service.ts +343 -0
- package/src/encryption/index.ts +25 -0
- package/src/encryption/types.ts +57 -0
- package/src/exchange-rates.ts +49 -0
- package/src/feature-flags/__tests__/feature-flags.test.ts +302 -0
- package/src/feature-flags/core.ts +322 -0
- package/src/feature-flags/data.ts +16 -0
- package/src/feature-flags/default.ts +18 -0
- package/src/feature-flags/index.ts +7 -0
- package/src/feature-flags/requestable-features.ts +79 -0
- package/src/feature-flags/types.ts +4 -0
- package/src/fetcher.ts +2 -0
- package/src/finance/index.ts +4 -0
- package/src/finance/interest-calculator.ts +456 -0
- package/src/finance/interest-detector.ts +141 -0
- package/src/finance/transform-invoice-results.ts +219 -0
- package/src/finance/wallet-permissions.test.ts +169 -0
- package/src/finance/wallet-permissions.ts +82 -0
- package/src/format.ts +120 -1
- package/src/generated/platform-build-metadata.ts +11 -0
- package/src/hooks/use-platform.ts +64 -0
- package/src/html-sanitizer.ts +155 -0
- package/src/internal-domains.ts +497 -0
- package/src/keyboard-preset.ts +109 -0
- package/src/label-colors.ts +213 -0
- package/src/launchable-apps.test.ts +126 -0
- package/src/launchable-apps.ts +490 -0
- package/src/name-helper.ts +269 -0
- package/src/next-config.test.ts +234 -0
- package/src/next-config.ts +203 -0
- package/src/node-diff.ts +375 -0
- package/src/notification-service.ts +379 -0
- package/src/nova/scores/__tests__/calculate.test.ts +254 -0
- package/src/nova/scores/calculate.ts +132 -0
- package/src/nova/submissions/check-permission.ts +132 -0
- package/src/onboarding-helper.ts +213 -0
- package/src/path-helper.ts +93 -0
- package/src/permissions.tsx +1170 -0
- package/src/plan-helpers.test.ts +188 -0
- package/src/plan-helpers.ts +80 -0
- package/src/platform-release.test.ts +74 -0
- package/src/platform-release.ts +155 -0
- package/src/portless.ts +124 -0
- package/src/priority-styles.ts +42 -0
- package/src/request-emoji-limit.ts +335 -0
- package/src/search-helper.ts +18 -0
- package/src/search.test.ts +89 -0
- package/src/search.ts +355 -0
- package/src/storage-display-name.ts +30 -0
- package/src/storage-path.ts +147 -0
- package/src/tag-utils.ts +159 -0
- package/src/task/reorder.ts +245 -0
- package/src/task/transformers.ts +149 -0
- package/src/task-date-timezone.ts +133 -0
- package/src/task-description-content.ts +240 -0
- package/src/task-helper/board.ts +193 -0
- package/src/task-helper/bulk-actions.ts +564 -0
- package/src/task-helper/personal-external-staging.ts +21 -0
- package/src/task-helper/recycle-bin.ts +202 -0
- package/src/task-helper/relationships.ts +346 -0
- package/src/task-helper/shared.ts +109 -0
- package/src/task-helper/sort-keys.ts +337 -0
- package/src/task-helper/task-hooks-basic.ts +342 -0
- package/src/task-helper/task-hooks-move.ts +264 -0
- package/src/task-helper/task-operations.ts +278 -0
- package/src/task-helper.ts +12 -0
- package/src/task-helpers.ts +241 -0
- package/src/task-list-status.ts +62 -0
- package/src/task-overrides.ts +82 -0
- package/src/task-snapshot.ts +374 -0
- package/src/text-diff.ts +81 -0
- package/src/text-helper.ts +537 -0
- package/src/time-helper.ts +63 -0
- package/src/time-tracker-period.ts +73 -0
- package/src/timeblock-helper.ts +418 -0
- package/src/timezone.ts +190 -0
- package/src/timezones.json +1271 -0
- package/src/upstash-rest.ts +56 -0
- package/src/user-helper.ts +296 -0
- package/src/uuid-helper.ts +11 -0
- package/src/workspace-handle.ts +10 -0
- package/src/workspace-helper.ts +1408 -0
- package/src/workspace-limits.ts +68 -0
- package/src/yjs-helper.ts +217 -0
- package/src/yjs-task-description.ts +81 -0
- package/tsconfig.json +3 -5
- package/tsconfig.typecheck.json +33 -0
- package/vitest.config.ts +36 -0
- package/dist/index.d.ts +0 -8
- package/dist/index.js +0 -2
- package/dist/index.js.map +0 -1
- package/dist/index.mjs +0 -2
- package/dist/index.mjs.map +0 -1
- package/eslint.config.mjs +0 -20
- package/rollup.config.js +0 -41
- 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 /
|
|
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
|
+
}
|