@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
package/src/app-url.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type AppName, getAppDomainByUrl } from './internal-domains';
|
|
2
|
+
|
|
3
|
+
type AppUrlCandidate = string | null | undefined;
|
|
4
|
+
|
|
5
|
+
interface ResolveAppUrlOptions {
|
|
6
|
+
candidates: readonly AppUrlCandidate[];
|
|
7
|
+
fallback: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ResolveInternalAppUrlOptions extends ResolveAppUrlOptions {
|
|
11
|
+
appName: AppName;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function trimTrailingSlashes(value: string) {
|
|
15
|
+
let end = value.length;
|
|
16
|
+
|
|
17
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47) {
|
|
18
|
+
end -= 1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return end === value.length ? value : value.slice(0, end);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isWildcardListenerHostname(hostname: string) {
|
|
25
|
+
return hostname === '0.0.0.0' || hostname === '::' || hostname === '[::]';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeHttpUrl(value: AppUrlCandidate) {
|
|
29
|
+
const trimmed = value?.trim();
|
|
30
|
+
|
|
31
|
+
if (!trimmed) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const url = new URL(trimmed);
|
|
37
|
+
|
|
38
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (isWildcardListenerHostname(url.hostname)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return trimTrailingSlashes(trimmed);
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveAppUrl({ candidates, fallback }: ResolveAppUrlOptions) {
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
const resolvedUrl = normalizeHttpUrl(candidate);
|
|
55
|
+
|
|
56
|
+
if (resolvedUrl) {
|
|
57
|
+
return resolvedUrl;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return normalizeHttpUrl(fallback) ?? fallback;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getRegisteredAppNameForUrl(value: string) {
|
|
65
|
+
return getAppDomainByUrl(value)?.name ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function resolveInternalAppUrl({
|
|
69
|
+
appName,
|
|
70
|
+
candidates,
|
|
71
|
+
fallback,
|
|
72
|
+
}: ResolveInternalAppUrlOptions) {
|
|
73
|
+
for (const candidate of candidates) {
|
|
74
|
+
const resolvedUrl = normalizeHttpUrl(candidate);
|
|
75
|
+
|
|
76
|
+
if (!resolvedUrl) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const registeredAppName = getRegisteredAppNameForUrl(resolvedUrl);
|
|
81
|
+
|
|
82
|
+
if (registeredAppName && registeredAppName !== appName) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const registeredAppUrl = getAppDomainByUrl(resolvedUrl);
|
|
87
|
+
|
|
88
|
+
if (registeredAppUrl?.kind === 'internal') {
|
|
89
|
+
return trimTrailingSlashes(registeredAppUrl.canonicalUrl);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return resolvedUrl;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return normalizeHttpUrl(fallback) ?? fallback;
|
|
96
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
const UUID_PATTERN =
|
|
2
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
3
|
+
const SUPABASE_PUBLIC_AVATAR_PATH = '/storage/v1/object/public/avatars/';
|
|
4
|
+
const SUPABASE_MALFORMED_PUBLIC_AVATAR_PATH =
|
|
5
|
+
'/storage/v1/object/v1/public/avatars/';
|
|
6
|
+
|
|
7
|
+
function getSupabasePublicAvatarObjectPath(pathname: string) {
|
|
8
|
+
if (pathname.startsWith(SUPABASE_PUBLIC_AVATAR_PATH)) {
|
|
9
|
+
return pathname.slice(SUPABASE_PUBLIC_AVATAR_PATH.length);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (pathname.startsWith(SUPABASE_MALFORMED_PUBLIC_AVATAR_PATH)) {
|
|
13
|
+
return pathname.slice(SUPABASE_MALFORMED_PUBLIC_AVATAR_PATH.length);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeSupabasePublicAvatarUrl(src: string) {
|
|
20
|
+
try {
|
|
21
|
+
const sourceUrl = new URL(src);
|
|
22
|
+
const avatarObjectPath = getSupabasePublicAvatarObjectPath(
|
|
23
|
+
sourceUrl.pathname
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
if (
|
|
27
|
+
!sourceUrl.hostname.endsWith('.supabase.co') ||
|
|
28
|
+
avatarObjectPath === null
|
|
29
|
+
) {
|
|
30
|
+
return src;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
sourceUrl.pathname = `${SUPABASE_PUBLIC_AVATAR_PATH}${avatarObjectPath}`;
|
|
34
|
+
|
|
35
|
+
return sourceUrl.toString();
|
|
36
|
+
} catch {
|
|
37
|
+
return src;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function normalizeAvatarImageSrc(
|
|
42
|
+
value: string | null | undefined
|
|
43
|
+
): string | undefined {
|
|
44
|
+
const src = value?.trim();
|
|
45
|
+
|
|
46
|
+
if (!src || src.startsWith('//') || UUID_PATTERN.test(src)) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (/^https?:\/\//iu.test(src)) {
|
|
51
|
+
return normalizeSupabasePublicAvatarUrl(src);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
src.startsWith('/') ||
|
|
56
|
+
src.startsWith('blob:') ||
|
|
57
|
+
/^data:image\//iu.test(src) ||
|
|
58
|
+
src.startsWith('avatars/')
|
|
59
|
+
) {
|
|
60
|
+
return src;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Break Duration Utilities
|
|
3
|
+
*
|
|
4
|
+
* Handles validation and formatting for break durations.
|
|
5
|
+
* Breaks are constrained to multiples of 15 minutes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The interval in minutes that break durations must be multiples of */
|
|
9
|
+
export const BREAK_INTERVAL = 15;
|
|
10
|
+
|
|
11
|
+
/** Minimum break duration in minutes */
|
|
12
|
+
export const MIN_BREAK_MINUTES = 15;
|
|
13
|
+
|
|
14
|
+
/** Maximum break duration in minutes (2 hours) */
|
|
15
|
+
export const MAX_BREAK_MINUTES = 120;
|
|
16
|
+
|
|
17
|
+
/** Available break duration options in minutes */
|
|
18
|
+
export const BREAK_OPTIONS = [15, 30, 45, 60, 75, 90, 105, 120] as const;
|
|
19
|
+
|
|
20
|
+
/** Type for valid break duration values */
|
|
21
|
+
export type BreakDuration = (typeof BREAK_OPTIONS)[number];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if a duration value is a valid break duration
|
|
25
|
+
* (positive, multiple of 15, within bounds)
|
|
26
|
+
*/
|
|
27
|
+
export function isValidBreakDuration(minutes: number): boolean {
|
|
28
|
+
return (
|
|
29
|
+
minutes > 0 &&
|
|
30
|
+
minutes % BREAK_INTERVAL === 0 &&
|
|
31
|
+
minutes >= MIN_BREAK_MINUTES &&
|
|
32
|
+
minutes <= MAX_BREAK_MINUTES
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Round a duration to the nearest valid break interval
|
|
38
|
+
*/
|
|
39
|
+
export function roundToBreakInterval(minutes: number): number {
|
|
40
|
+
const rounded = Math.round(minutes / BREAK_INTERVAL) * BREAK_INTERVAL;
|
|
41
|
+
return Math.max(MIN_BREAK_MINUTES, Math.min(MAX_BREAK_MINUTES, rounded));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Format a break duration for display
|
|
46
|
+
* @param minutes Duration in minutes
|
|
47
|
+
* @returns Formatted string (e.g., "15 min", "1h", "1h 30m")
|
|
48
|
+
*/
|
|
49
|
+
export function formatBreakDuration(minutes: number): string {
|
|
50
|
+
if (minutes < 60) {
|
|
51
|
+
return `${minutes} min`;
|
|
52
|
+
}
|
|
53
|
+
const hours = Math.floor(minutes / 60);
|
|
54
|
+
const mins = minutes % 60;
|
|
55
|
+
if (mins === 0) {
|
|
56
|
+
return `${hours}h`;
|
|
57
|
+
}
|
|
58
|
+
return `${hours}h ${mins}m`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get break options as label-value pairs for use in select components
|
|
63
|
+
*/
|
|
64
|
+
export function getBreakOptionLabels(): Array<{
|
|
65
|
+
value: number;
|
|
66
|
+
label: string;
|
|
67
|
+
}> {
|
|
68
|
+
return BREAK_OPTIONS.map((mins) => ({
|
|
69
|
+
value: mins,
|
|
70
|
+
label: formatBreakDuration(mins),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse a break duration from input, ensuring it's valid
|
|
76
|
+
* Returns null if invalid
|
|
77
|
+
*/
|
|
78
|
+
export function parseBreakDuration(value: unknown): number | null {
|
|
79
|
+
const num = typeof value === 'string' ? parseInt(value, 10) : Number(value);
|
|
80
|
+
if (Number.isNaN(num) || !isValidBreakDuration(num)) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return num;
|
|
84
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
fetchUserWorkspaceCalendarGoogleTokenForClient,
|
|
4
|
+
WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT,
|
|
5
|
+
} from './calendar-auth-token';
|
|
6
|
+
|
|
7
|
+
describe('calendar-auth-token', () => {
|
|
8
|
+
it('does not request OAuth secret columns', () => {
|
|
9
|
+
expect(WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT).not.toContain(
|
|
10
|
+
'access_token'
|
|
11
|
+
);
|
|
12
|
+
expect(WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT).not.toContain(
|
|
13
|
+
'refresh_token'
|
|
14
|
+
);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('scopes token reads to workspace and authenticated user', async () => {
|
|
18
|
+
const maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null });
|
|
19
|
+
const eqUser = vi.fn().mockReturnValue({ maybeSingle });
|
|
20
|
+
const eqWorkspace = vi.fn().mockReturnValue({ eq: eqUser });
|
|
21
|
+
const select = vi.fn().mockReturnValue({ eq: eqWorkspace });
|
|
22
|
+
const from = vi.fn().mockReturnValue({ select });
|
|
23
|
+
|
|
24
|
+
await fetchUserWorkspaceCalendarGoogleTokenForClient({ from } as never, {
|
|
25
|
+
wsId: 'ws-1',
|
|
26
|
+
userId: 'user-1',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(from).toHaveBeenCalledWith('calendar_auth_tokens');
|
|
30
|
+
expect(select).toHaveBeenCalledWith(
|
|
31
|
+
WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT
|
|
32
|
+
);
|
|
33
|
+
expect(eqWorkspace).toHaveBeenCalledWith('ws_id', 'ws-1');
|
|
34
|
+
expect(eqUser).toHaveBeenCalledWith('user_id', 'user-1');
|
|
35
|
+
expect(maybeSingle).toHaveBeenCalledOnce();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
|
|
2
|
+
import type { WorkspaceCalendarGoogleTokenClient } from '@tuturuuu/types/db';
|
|
3
|
+
|
|
4
|
+
export const WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT =
|
|
5
|
+
'id, ws_id, user_id, provider, account_email, account_name, is_active, expires_at, created_at' as const;
|
|
6
|
+
|
|
7
|
+
export async function fetchUserWorkspaceCalendarGoogleTokenForClient(
|
|
8
|
+
supabase: TypedSupabaseClient,
|
|
9
|
+
params: { wsId: string; userId: string }
|
|
10
|
+
): Promise<WorkspaceCalendarGoogleTokenClient | null> {
|
|
11
|
+
const { data } = await supabase
|
|
12
|
+
.from('calendar_auth_tokens')
|
|
13
|
+
.select(WORKSPACE_CALENDAR_GOOGLE_TOKEN_CLIENT_SELECT)
|
|
14
|
+
.eq('ws_id', params.wsId)
|
|
15
|
+
.eq('user_id', params.userId)
|
|
16
|
+
.maybeSingle();
|
|
17
|
+
|
|
18
|
+
return data;
|
|
19
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# Calendar Sync Coordination
|
|
2
|
+
|
|
3
|
+
This module provides utilities to coordinate calendar sync operations between active sync (user-initiated) and background sync (automated) to prevent conflicts and ensure data consistency.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The calendar sync coordination system uses a `lastUpsert` timestamp to enforce a 30-second cooldown period between sync operations for each workspace. Additionally, it includes a 4-week range check to ensure syncs only operate within a reasonable time window from the current week. This prevents:
|
|
8
|
+
|
|
9
|
+
- Race conditions between active and background syncs
|
|
10
|
+
- Excessive API calls to Google Calendar
|
|
11
|
+
- Data inconsistencies from concurrent updates
|
|
12
|
+
- Syncs on date ranges too far in the past or future
|
|
13
|
+
|
|
14
|
+
## Database Schema
|
|
15
|
+
|
|
16
|
+
The system uses a `workspace_calendar_sync_coordination` table:
|
|
17
|
+
|
|
18
|
+
```sql
|
|
19
|
+
CREATE TABLE workspace_calendar_sync_coordination (
|
|
20
|
+
ws_id UUID PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
21
|
+
last_upsert TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
22
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
23
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
24
|
+
);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Functions
|
|
28
|
+
|
|
29
|
+
### `canProceedWithSync(wsId: string, supabase?: any): Promise<boolean>`
|
|
30
|
+
|
|
31
|
+
Checks if a sync operation can proceed for the given workspace.
|
|
32
|
+
|
|
33
|
+
**Parameters:**
|
|
34
|
+
|
|
35
|
+
- `wsId`: The workspace ID
|
|
36
|
+
- `supabase`: Optional Supabase client (will create one if not provided)
|
|
37
|
+
|
|
38
|
+
**Returns:**
|
|
39
|
+
|
|
40
|
+
- `true` if sync can proceed (30+ seconds since last upsert)
|
|
41
|
+
- `false` if sync is blocked (less than 30 seconds since last upsert)
|
|
42
|
+
|
|
43
|
+
**Behavior:**
|
|
44
|
+
|
|
45
|
+
- Creates a coordination record if one doesn't exist for the workspace
|
|
46
|
+
- Allows sync for new workspaces (no existing record)
|
|
47
|
+
- Gracefully handles errors by allowing sync to proceed
|
|
48
|
+
|
|
49
|
+
### `isWithin4WeeksFromCurrentWeek(startDate: Date, endDate: Date): boolean`
|
|
50
|
+
|
|
51
|
+
Checks if a date range is within 4 weeks from the current week.
|
|
52
|
+
|
|
53
|
+
**Parameters:**
|
|
54
|
+
|
|
55
|
+
- `startDate`: Start date to check
|
|
56
|
+
- `endDate`: End date to check
|
|
57
|
+
|
|
58
|
+
**Returns:**
|
|
59
|
+
|
|
60
|
+
- `true` if the date range overlaps with 4 weeks from current week
|
|
61
|
+
- `false` if the date range is outside the 4-week window
|
|
62
|
+
|
|
63
|
+
**Behavior:**
|
|
64
|
+
|
|
65
|
+
- Uses the current week as the reference point (not the current view)
|
|
66
|
+
- Checks if the date range overlaps with the 4-week period
|
|
67
|
+
- Useful for preventing syncs on distant past or future dates
|
|
68
|
+
|
|
69
|
+
### `updateLastUpsert(wsId: string, supabase?: any): Promise<void>`
|
|
70
|
+
|
|
71
|
+
Updates the `last_upsert` timestamp for a workspace after a successful sync operation.
|
|
72
|
+
|
|
73
|
+
**Parameters:**
|
|
74
|
+
|
|
75
|
+
- `wsId`: The workspace ID
|
|
76
|
+
- `supabase`: Optional Supabase client (will create one if not provided)
|
|
77
|
+
|
|
78
|
+
**Behavior:**
|
|
79
|
+
|
|
80
|
+
- Upserts the timestamp using the workspace ID as the conflict target
|
|
81
|
+
- Logs success or error messages
|
|
82
|
+
- Gracefully handles errors
|
|
83
|
+
|
|
84
|
+
### `FOUR_WEEKS_FROM_CURRENT_WEEK`
|
|
85
|
+
|
|
86
|
+
Constant representing 4 weeks (28 days) from the current week.
|
|
87
|
+
|
|
88
|
+
## Usage
|
|
89
|
+
|
|
90
|
+
### Active Sync (use-calendar-sync.tsx)
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
import {
|
|
94
|
+
canProceedWithSync,
|
|
95
|
+
isWithin4WeeksFromCurrentWeek,
|
|
96
|
+
updateLastUpsert,
|
|
97
|
+
} from '@tuturuuu/utils/calendar-sync-coordination';
|
|
98
|
+
|
|
99
|
+
const syncToTuturuuu = async () => {
|
|
100
|
+
// Check if current view is within 4 weeks from current week
|
|
101
|
+
if (dates.length > 0) {
|
|
102
|
+
const startDate = dates[0];
|
|
103
|
+
const endDate = dates[dates.length - 1];
|
|
104
|
+
|
|
105
|
+
if (startDate && endDate) {
|
|
106
|
+
const isWithinRange = isWithin4WeeksFromCurrentWeek(startDate, endDate);
|
|
107
|
+
|
|
108
|
+
if (!isWithinRange) {
|
|
109
|
+
console.log(
|
|
110
|
+
'Sync blocked: Current view is outside 4 weeks from current week'
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check if we can proceed with sync
|
|
118
|
+
const canProceed = await canProceedWithSync(wsId);
|
|
119
|
+
if (!canProceed) {
|
|
120
|
+
console.log('Sync blocked due to 30-second cooldown');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Perform sync operations...
|
|
125
|
+
|
|
126
|
+
// Update timestamp after successful upsert
|
|
127
|
+
await updateLastUpsert(wsId);
|
|
128
|
+
};
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Background Sync (google-calendar-background-sync.ts)
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
import {
|
|
135
|
+
FOUR_WEEKS_FROM_CURRENT_WEEK,
|
|
136
|
+
canProceedWithSync,
|
|
137
|
+
updateLastUpsert,
|
|
138
|
+
} from '@tuturuuu/utils/calendar-sync-coordination';
|
|
139
|
+
|
|
140
|
+
const syncGoogleCalendarEvents = async (supabase: any) => {
|
|
141
|
+
for (const token of tokens) {
|
|
142
|
+
// Check if we can proceed with sync for this workspace
|
|
143
|
+
const canProceed = await canProceedWithSync(token.ws_id, supabase);
|
|
144
|
+
if (!canProceed) {
|
|
145
|
+
console.log(
|
|
146
|
+
`Skipping background sync for wsId ${token.ws_id} due to 30-second cooldown`
|
|
147
|
+
);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Perform sync operations with 4-week range
|
|
152
|
+
const timeMin = new Date();
|
|
153
|
+
const timeMax = new Date();
|
|
154
|
+
timeMax.setDate(timeMax.getDate() + FOUR_WEEKS_FROM_CURRENT_WEEK);
|
|
155
|
+
|
|
156
|
+
// ... sync logic ...
|
|
157
|
+
|
|
158
|
+
// Update timestamp after successful upsert
|
|
159
|
+
await updateLastUpsert(token.ws_id, supabase);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Error Handling
|
|
165
|
+
|
|
166
|
+
All functions are designed to be fault-tolerant:
|
|
167
|
+
|
|
168
|
+
- If the coordination table doesn't exist, sync operations are allowed to proceed
|
|
169
|
+
- If database operations fail, sync operations are allowed to proceed
|
|
170
|
+
- All errors are logged but don't block sync operations
|
|
171
|
+
- This ensures the system degrades gracefully if the coordination mechanism fails
|
|
172
|
+
|
|
173
|
+
## Migration
|
|
174
|
+
|
|
175
|
+
To set up the coordination table, run the migration:
|
|
176
|
+
|
|
177
|
+
```sql
|
|
178
|
+
-- Create table to coordinate calendar sync operations
|
|
179
|
+
CREATE TABLE workspace_calendar_sync_coordination (
|
|
180
|
+
ws_id UUID PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
|
|
181
|
+
last_upsert TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
182
|
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
183
|
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
-- Add RLS policies and triggers as needed
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Benefits
|
|
190
|
+
|
|
191
|
+
1. **Prevents Conflicts**: Ensures only one sync operation per workspace every 30 seconds
|
|
192
|
+
2. **Reduces API Load**: Prevents excessive calls to Google Calendar API
|
|
193
|
+
3. **Maintains Consistency**: Avoids race conditions that could corrupt data
|
|
194
|
+
4. **Graceful Degradation**: System continues to work even if coordination fails
|
|
195
|
+
5. **Workspace Isolation**: Each workspace has its own coordination record
|
|
196
|
+
6. **Reasonable Time Window**: Prevents syncs on distant past or future dates
|
|
197
|
+
7. **Current Week Reference**: Uses current week as reference point for 4-week window
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { CalendarEvent } from '@tuturuuu/types/primitives/calendar-event';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
convertGoogleAllDayEvent,
|
|
5
|
+
createAllDayEvent,
|
|
6
|
+
isAllDayEvent,
|
|
7
|
+
} from './calendar-utils';
|
|
8
|
+
|
|
9
|
+
describe('calendar-utils', () => {
|
|
10
|
+
describe('isAllDayEvent', () => {
|
|
11
|
+
it('should detect all-day events with date-only format', () => {
|
|
12
|
+
const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
|
|
13
|
+
start_at: '2024-01-01T00:00:00.000Z',
|
|
14
|
+
end_at: '2024-01-02T00:00:00.000Z',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
expect(isAllDayEvent(event)).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should detect all-day events spanning multiple days', () => {
|
|
21
|
+
const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
|
|
22
|
+
start_at: '2024-01-01T00:00:00.000Z',
|
|
23
|
+
end_at: '2024-01-03T00:00:00.000Z', // 48 hours
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
expect(isAllDayEvent(event)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should reject timed events', () => {
|
|
30
|
+
const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
|
|
31
|
+
start_at: '2024-01-01T10:00:00.000Z',
|
|
32
|
+
end_at: '2024-01-01T11:00:00.000Z',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
expect(isAllDayEvent(event)).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should reject events not starting at midnight', () => {
|
|
39
|
+
const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
|
|
40
|
+
start_at: '2024-01-01T01:00:00.000Z',
|
|
41
|
+
end_at: '2024-01-02T00:00:00.000Z',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
expect(isAllDayEvent(event)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should reject events not ending at midnight', () => {
|
|
48
|
+
const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
|
|
49
|
+
start_at: '2024-01-01T00:00:00.000Z',
|
|
50
|
+
end_at: '2024-01-02T01:00:00.000Z',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
expect(isAllDayEvent(event)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should reject events with non-24-hour duration', () => {
|
|
57
|
+
const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
|
|
58
|
+
start_at: '2024-01-01T00:00:00.000Z',
|
|
59
|
+
end_at: '2024-01-01T12:00:00.000Z', // 12 hours
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
expect(isAllDayEvent(event)).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should reject events with 0 duration', () => {
|
|
66
|
+
const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
|
|
67
|
+
start_at: '2024-01-01T00:00:00.000Z',
|
|
68
|
+
end_at: '2024-01-01T00:00:00.000Z', // Same time
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
expect(isAllDayEvent(event)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should handle events with seconds and milliseconds', () => {
|
|
75
|
+
const event: Pick<CalendarEvent, 'start_at' | 'end_at'> = {
|
|
76
|
+
start_at: '2024-01-01T00:00:00.123Z',
|
|
77
|
+
end_at: '2024-01-02T00:00:00.456Z',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
expect(isAllDayEvent(event)).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('convertGoogleAllDayEvent', () => {
|
|
85
|
+
it('should convert Google date-only format to user timezone midnight', () => {
|
|
86
|
+
const result = convertGoogleAllDayEvent(
|
|
87
|
+
'2024-01-01',
|
|
88
|
+
'2024-01-02',
|
|
89
|
+
'America/New_York'
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
// Should convert to timezone midnight
|
|
93
|
+
expect(result.start_at).toMatch(/2024-01-01T05:00:00\.000Z/); // UTC equivalent of midnight EST
|
|
94
|
+
expect(result.end_at).toMatch(/2024-01-02T05:00:00\.000Z/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should handle missing dates by using current time', () => {
|
|
98
|
+
const result = convertGoogleAllDayEvent(
|
|
99
|
+
undefined,
|
|
100
|
+
undefined,
|
|
101
|
+
'America/New_York'
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(result.start_at).toBeDefined();
|
|
105
|
+
expect(result.end_at).toBeDefined();
|
|
106
|
+
expect(
|
|
107
|
+
new Date(result.end_at).getTime() - new Date(result.start_at).getTime()
|
|
108
|
+
).toBe(60 * 60 * 1000); // 1 hour
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should pass through existing dateTime format unchanged', () => {
|
|
112
|
+
const result = convertGoogleAllDayEvent(
|
|
113
|
+
'2024-01-01T10:00:00Z',
|
|
114
|
+
'2024-01-01T11:00:00Z',
|
|
115
|
+
'America/New_York'
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
expect(result.start_at).toBe('2024-01-01T10:00:00Z');
|
|
119
|
+
expect(result.end_at).toBe('2024-01-01T11:00:00Z');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should handle mixed date formats', () => {
|
|
123
|
+
const result = convertGoogleAllDayEvent(
|
|
124
|
+
'2024-01-01',
|
|
125
|
+
'2024-01-01T11:00:00Z',
|
|
126
|
+
'America/New_York'
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
// Should pass through as-is since not both are date-only
|
|
130
|
+
expect(result.start_at).toBe('2024-01-01');
|
|
131
|
+
expect(result.end_at).toBe('2024-01-01T11:00:00Z');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('createAllDayEvent', () => {
|
|
136
|
+
it('should create all-day event in user timezone', () => {
|
|
137
|
+
const date = new Date('2024-01-01T10:00:00Z');
|
|
138
|
+
const result = createAllDayEvent(date, 'America/New_York');
|
|
139
|
+
|
|
140
|
+
expect(result.start_at).toMatch(/2024-01-01T05:00:00\.000Z/); // UTC equivalent of midnight EST
|
|
141
|
+
expect(result.end_at).toMatch(/2024-01-02T05:00:00\.000Z/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should create multi-day all-day event', () => {
|
|
145
|
+
const date = new Date('2024-01-01T10:00:00Z');
|
|
146
|
+
const result = createAllDayEvent(date, 'America/New_York', 3);
|
|
147
|
+
|
|
148
|
+
expect(result.start_at).toMatch(/2024-01-01T05:00:00\.000Z/);
|
|
149
|
+
expect(result.end_at).toMatch(/2024-01-04T05:00:00\.000Z/); // 3 days later
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should handle undefined timezone', () => {
|
|
153
|
+
const date = new Date('2024-01-01T10:00:00Z');
|
|
154
|
+
const result = createAllDayEvent(date, undefined);
|
|
155
|
+
|
|
156
|
+
// When timezone is undefined, the function should use the system's local timezone.
|
|
157
|
+
// To make this test robust regardless of the environment, we compute the expected UTC start/end.
|
|
158
|
+
const localDate = new Date(date);
|
|
159
|
+
localDate.setHours(0, 0, 0, 0);
|
|
160
|
+
const expectedStart = localDate.toISOString();
|
|
161
|
+
const expectedEnd = new Date(
|
|
162
|
+
localDate.getTime() + 24 * 60 * 60 * 1000
|
|
163
|
+
).toISOString();
|
|
164
|
+
|
|
165
|
+
expect(result.start_at).toBe(expectedStart);
|
|
166
|
+
expect(result.end_at).toBe(expectedEnd);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|