@tuturuuu/utils 0.28.0 → 0.29.1
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 +38 -0
- package/biome.json +1 -1
- package/package.json +17 -16
- package/src/__tests__/api-proxy-guard.test.ts +20 -27
- package/src/__tests__/app-url.test.ts +1 -0
- package/src/__tests__/workspace-principal-resolution.test.ts +34 -0
- package/src/abuse-protection/__tests__/reputation.test.ts +107 -0
- package/src/abuse-protection/reputation.ts +42 -3
- package/src/api-proxy-guard.ts +0 -15
- package/src/commercial-comparison-0.ts +294 -0
- package/src/commercial-comparison-1.ts +283 -0
- package/src/commercial-comparison-2.ts +262 -0
- package/src/commercial-comparison-types.ts +20 -0
- package/src/commercial-comparison.test.ts +32 -0
- package/src/commercial-comparison.ts +96 -0
- package/src/coordination-protocol.ts +61 -0
- package/src/coordination.test.ts +84 -0
- package/src/coordination.ts +57 -0
- package/src/feature-tier-policy.ts +26 -0
- package/src/internal-domains.ts +3 -0
- package/src/launchable-apps.ts +13 -0
- package/src/meet-creation-policy.test.ts +11 -0
- package/src/meet-creation-policy.ts +9 -3
- package/src/meet-duration.ts +71 -0
- package/src/meet-hosting.test.ts +58 -0
- package/src/meet-hosting.ts +19 -0
- package/src/meeting-invitations.test.ts +102 -0
- package/src/meeting-invitations.ts +81 -0
- package/src/onboarding.ts +1 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/portless.ts +1 -0
- package/src/task-helper/task-sort.test.ts +22 -3
- package/src/task-helper/task-sort.ts +33 -3
- package/src/workspace-helper.ts +3 -3
- package/src/workspace-limits.ts +1 -1
|
@@ -19,3 +19,14 @@ describe('online meeting creator domain', () => {
|
|
|
19
19
|
expect(canCreateOnlineMeeting(email)).toBe(false);
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
|
+
|
|
23
|
+
it.each(['PLUS', 'PRO', 'ENTERPRISE'])(
|
|
24
|
+
'allows verified external %s eligibility',
|
|
25
|
+
(tier) => {
|
|
26
|
+
expect(canCreateOnlineMeeting('host@example.test', tier)).toBe(true);
|
|
27
|
+
expect(canCreateOnlineMeeting('bad@@example.test', tier)).toBe(false);
|
|
28
|
+
}
|
|
29
|
+
);
|
|
30
|
+
it('does not unlock hosting for an unknown tier', () => {
|
|
31
|
+
expect(canCreateOnlineMeeting('host@example.test', 'UNKNOWN')).toBe(false);
|
|
32
|
+
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Use a current authenticated email and server-resolved personal subscription tier. */
|
|
2
2
|
export function canCreateOnlineMeeting(
|
|
3
|
-
email: string | null | undefined
|
|
3
|
+
email: string | null | undefined,
|
|
4
|
+
tier: string = 'FREE'
|
|
4
5
|
): boolean {
|
|
5
|
-
|
|
6
|
+
if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+$/u.test(email))
|
|
7
|
+
return false;
|
|
8
|
+
return (
|
|
9
|
+
/^[^\s@]+@tuturuuu\.com$/iu.test(email) ||
|
|
10
|
+
['PLUS', 'PRO', 'ENTERPRISE'].includes(tier)
|
|
11
|
+
);
|
|
6
12
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createAdminClient } from '@tuturuuu/supabase/next/server';
|
|
3
|
+
import type { WorkspaceProductTier } from '@tuturuuu/types';
|
|
4
|
+
import { extractTierFromSubscriptions } from './workspace-helper';
|
|
5
|
+
export class MeetDurationError extends Error {
|
|
6
|
+
readonly status = 503;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function meetingDurationSeconds(tier: string) {
|
|
10
|
+
return (
|
|
11
|
+
(tier === 'PLUS' ? 10 : tier === 'PRO' || tier === 'ENTERPRISE' ? 24 : 2) *
|
|
12
|
+
3600
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Entitlements belong to the host's personal account, never the joining guest. */
|
|
17
|
+
export async function getHostMeetingTier(
|
|
18
|
+
hostId: string
|
|
19
|
+
): Promise<WorkspaceProductTier> {
|
|
20
|
+
const db = await createAdminClient({ noCookie: true });
|
|
21
|
+
const { data, error } = await db
|
|
22
|
+
.from('workspaces')
|
|
23
|
+
.select('id')
|
|
24
|
+
.eq('creator_id', hostId)
|
|
25
|
+
.eq('personal', true)
|
|
26
|
+
.or('deleted.is.null,deleted.eq.false')
|
|
27
|
+
.maybeSingle();
|
|
28
|
+
if (error) throw new MeetDurationError('Meeting entitlement lookup failed');
|
|
29
|
+
if (!data) return 'FREE';
|
|
30
|
+
const subscriptions = await db
|
|
31
|
+
.from('workspace_subscriptions')
|
|
32
|
+
.select('created_at, status, product_id')
|
|
33
|
+
.eq('ws_id', data.id)
|
|
34
|
+
.eq('status', 'active');
|
|
35
|
+
if (subscriptions.error)
|
|
36
|
+
throw new MeetDurationError('Meeting entitlement lookup failed');
|
|
37
|
+
const ids = [
|
|
38
|
+
...new Set(
|
|
39
|
+
subscriptions.data
|
|
40
|
+
.map((subscription) => subscription.product_id)
|
|
41
|
+
.filter((id): id is string => !!id)
|
|
42
|
+
),
|
|
43
|
+
];
|
|
44
|
+
if (!ids.length) return 'FREE';
|
|
45
|
+
const products = await db
|
|
46
|
+
.schema('private')
|
|
47
|
+
.from('workspace_subscription_products')
|
|
48
|
+
.select('id, tier')
|
|
49
|
+
.in('id', ids);
|
|
50
|
+
if (products.error)
|
|
51
|
+
throw new MeetDurationError('Meeting entitlement lookup failed');
|
|
52
|
+
const tiers = new Map(
|
|
53
|
+
products.data.map((product) => [product.id, product.tier])
|
|
54
|
+
);
|
|
55
|
+
if (ids.some((id) => !tiers.get(id)))
|
|
56
|
+
throw new MeetDurationError('Meeting entitlement product unavailable');
|
|
57
|
+
const tier = extractTierFromSubscriptions(
|
|
58
|
+
subscriptions.data.map((subscription) => ({
|
|
59
|
+
...subscription,
|
|
60
|
+
product_tier: subscription.product_id
|
|
61
|
+
? (tiers.get(subscription.product_id) ?? null)
|
|
62
|
+
: null,
|
|
63
|
+
}))
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return tier ?? 'FREE';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function getHostMeetingDurationSeconds(hostId: string) {
|
|
70
|
+
return meetingDurationSeconds(await getHostMeetingTier(hostId));
|
|
71
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const tier = vi.hoisted(() => vi.fn());
|
|
4
|
+
vi.mock('server-only', () => ({}));
|
|
5
|
+
vi.mock('./meet-duration', () => ({ getHostMeetingTier: tier }));
|
|
6
|
+
|
|
7
|
+
import { canVerifiedAccountHostMeeting } from './meet-hosting';
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
vi.resetAllMocks();
|
|
11
|
+
tier.mockResolvedValue('FREE');
|
|
12
|
+
});
|
|
13
|
+
it.each(['PLUS', 'PRO', 'ENTERPRISE'])(
|
|
14
|
+
'unlocks verified %s accounts',
|
|
15
|
+
async (value) => {
|
|
16
|
+
tier.mockResolvedValue(value);
|
|
17
|
+
expect(
|
|
18
|
+
await canVerifiedAccountHostMeeting('actor', {
|
|
19
|
+
email: 'host@example.com',
|
|
20
|
+
email_confirmed_at: '2026-01-01',
|
|
21
|
+
})
|
|
22
|
+
).toBe(true);
|
|
23
|
+
expect(tier).toHaveBeenCalledWith('actor');
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
it('requires verification before considering a paid subscription', async () => {
|
|
27
|
+
tier.mockResolvedValue('PLUS');
|
|
28
|
+
expect(
|
|
29
|
+
await canVerifiedAccountHostMeeting('actor', { email: 'host@example.com' })
|
|
30
|
+
).toBe(false);
|
|
31
|
+
expect(tier).not.toHaveBeenCalled();
|
|
32
|
+
});
|
|
33
|
+
it('preserves verified company hosting without paid entitlement', async () => {
|
|
34
|
+
expect(
|
|
35
|
+
await canVerifiedAccountHostMeeting('actor', {
|
|
36
|
+
email: 'host@tuturuuu.com',
|
|
37
|
+
email_confirmed_at: '2026-01-01',
|
|
38
|
+
})
|
|
39
|
+
).toBe(true);
|
|
40
|
+
expect(tier).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
it('denies external Free accounts', async () => {
|
|
43
|
+
expect(
|
|
44
|
+
await canVerifiedAccountHostMeeting('actor', {
|
|
45
|
+
email: 'host@example.com',
|
|
46
|
+
email_confirmed_at: '2026-01-01',
|
|
47
|
+
})
|
|
48
|
+
).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
it('fails closed when entitlement cannot be resolved', async () => {
|
|
51
|
+
tier.mockRejectedValue(new Error('Unavailable'));
|
|
52
|
+
await expect(
|
|
53
|
+
canVerifiedAccountHostMeeting('actor', {
|
|
54
|
+
email: 'host@example.com',
|
|
55
|
+
email_confirmed_at: '2026-01-01',
|
|
56
|
+
})
|
|
57
|
+
).rejects.toThrow('Unavailable');
|
|
58
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { canCreateOnlineMeeting } from './meet-creation-policy';
|
|
3
|
+
import { getHostMeetingTier } from './meet-duration';
|
|
4
|
+
|
|
5
|
+
/** Pass only the current auth.admin.getUserById result, never handoff/profile metadata. */
|
|
6
|
+
export async function canVerifiedAccountHostMeeting(
|
|
7
|
+
accountId: string,
|
|
8
|
+
identity:
|
|
9
|
+
| { email?: string | null; email_confirmed_at?: string | null }
|
|
10
|
+
| null
|
|
11
|
+
| undefined
|
|
12
|
+
) {
|
|
13
|
+
if (!identity?.email_confirmed_at || !identity.email) return false;
|
|
14
|
+
if (canCreateOnlineMeeting(identity.email)) return true;
|
|
15
|
+
return canCreateOnlineMeeting(
|
|
16
|
+
identity.email,
|
|
17
|
+
await getHostMeetingTier(accountId)
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
MeetingInvitationInputSchema,
|
|
4
|
+
normalizeGoogleResponse,
|
|
5
|
+
normalizeMicrosoftResponse,
|
|
6
|
+
} from './meeting-invitations';
|
|
7
|
+
|
|
8
|
+
describe('meeting invitation contract', () => {
|
|
9
|
+
it('normalizes internal and external guests without accepting response spoofing', () => {
|
|
10
|
+
const invitation = MeetingInvitationInputSchema.parse({
|
|
11
|
+
guests: [
|
|
12
|
+
{ email: 'NAME@TUTURUUU.COM', response: 'accepted' },
|
|
13
|
+
{ email: 'friend@gmail.com', optional: true },
|
|
14
|
+
],
|
|
15
|
+
timeZone: 'Asia/Ho_Chi_Minh',
|
|
16
|
+
});
|
|
17
|
+
expect(invitation.guests[0]).toEqual({ email: 'name@tuturuuu.com' });
|
|
18
|
+
expect(invitation.guests[1]?.optional).toBe(true);
|
|
19
|
+
});
|
|
20
|
+
it.each([
|
|
21
|
+
{ guests: [], timeZone: 'UTC' },
|
|
22
|
+
{ guests: [{ email: 'invalid' }], timeZone: 'UTC' },
|
|
23
|
+
{ guests: [{ email: 'a@example.com' }], timeZone: 'fake/zone' },
|
|
24
|
+
{
|
|
25
|
+
guests: [{ email: 'a@example.com' }, { email: 'A@EXAMPLE.COM' }],
|
|
26
|
+
timeZone: 'UTC',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
guests: Array.from({ length: 101 }, (_, i) => ({
|
|
30
|
+
email: `${i}@example.com`,
|
|
31
|
+
})),
|
|
32
|
+
timeZone: 'UTC',
|
|
33
|
+
},
|
|
34
|
+
])('rejects invalid or ambiguous invitation input', (input) => {
|
|
35
|
+
expect(MeetingInvitationInputSchema.safeParse(input).success).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
it.each(['accepted', 'declined', 'tentative', 'needsAction'])(
|
|
38
|
+
'preserves Google %s',
|
|
39
|
+
(value) => {
|
|
40
|
+
expect(normalizeGoogleResponse(value)).toBe(value);
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
it.each([
|
|
44
|
+
['accepted', 'accepted'],
|
|
45
|
+
['declined', 'declined'],
|
|
46
|
+
['tentativelyAccepted', 'tentative'],
|
|
47
|
+
['notResponded', 'needsAction'],
|
|
48
|
+
['none', 'needsAction'],
|
|
49
|
+
['unexpected', 'needsAction'],
|
|
50
|
+
])('normalizes Microsoft %s', (value, expected) => {
|
|
51
|
+
expect(normalizeMicrosoftResponse(value)).toBe(expected);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('untrusted invitation input boundaries', () => {
|
|
56
|
+
it.each([
|
|
57
|
+
null,
|
|
58
|
+
undefined,
|
|
59
|
+
'accepted',
|
|
60
|
+
[],
|
|
61
|
+
{ guests: [{ email: 'a@example.com', optional: 'yes' }], timeZone: 'UTC' },
|
|
62
|
+
{
|
|
63
|
+
guests: [{ email: 'a@example.com', name: 'a'.repeat(201) }],
|
|
64
|
+
timeZone: 'UTC',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
guests: [{ email: 'a@example.com\r\nBcc: victim@example.com' }],
|
|
68
|
+
timeZone: 'UTC',
|
|
69
|
+
},
|
|
70
|
+
{ guests: [{ email: 'a@example.com' }], timeZone: null },
|
|
71
|
+
])('rejects malformed input without throwing from safeParse', (input) => {
|
|
72
|
+
expect(MeetingInvitationInputSchema.safeParse(input).success).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('accepts the maximum guest count and strips organizer-controlled provider state', () => {
|
|
76
|
+
const result = MeetingInvitationInputSchema.parse({
|
|
77
|
+
guests: Array.from({ length: 100 }, (_, i) => ({
|
|
78
|
+
email: `guest${i}@example.com`,
|
|
79
|
+
self: true,
|
|
80
|
+
response: 'accepted',
|
|
81
|
+
})),
|
|
82
|
+
timeZone: 'UTC',
|
|
83
|
+
organizer: { email: 'spoof@example.com' },
|
|
84
|
+
cancelled: true,
|
|
85
|
+
});
|
|
86
|
+
expect(result.guests).toHaveLength(100);
|
|
87
|
+
expect(result).not.toHaveProperty('organizer');
|
|
88
|
+
expect(result).not.toHaveProperty('cancelled');
|
|
89
|
+
for (const guest of result.guests) {
|
|
90
|
+
expect(guest).not.toHaveProperty('self');
|
|
91
|
+
expect(guest).not.toHaveProperty('response');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it.each([null, undefined, {}, [], 0, 'ACCEPTED', '<script>'])(
|
|
96
|
+
'treats unknown provider responses as unanswered',
|
|
97
|
+
(value) => {
|
|
98
|
+
expect(normalizeGoogleResponse(value)).toBe('needsAction');
|
|
99
|
+
expect(normalizeMicrosoftResponse(value)).toBe('needsAction');
|
|
100
|
+
}
|
|
101
|
+
);
|
|
102
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const MeetingResponseSchema = z.enum([
|
|
4
|
+
'needsAction',
|
|
5
|
+
'accepted',
|
|
6
|
+
'declined',
|
|
7
|
+
'tentative',
|
|
8
|
+
]);
|
|
9
|
+
export type MeetingResponse = z.infer<typeof MeetingResponseSchema>;
|
|
10
|
+
|
|
11
|
+
export const MeetingGuestSchema = z.object({
|
|
12
|
+
email: z
|
|
13
|
+
.email()
|
|
14
|
+
.max(254)
|
|
15
|
+
.transform((value) => value.trim().toLowerCase()),
|
|
16
|
+
name: z.string().trim().max(200).optional(),
|
|
17
|
+
optional: z.boolean().optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Guest responses are provider-owned, never accepted from organizer input. */
|
|
21
|
+
export const MeetingInvitationInputSchema = z
|
|
22
|
+
.object({
|
|
23
|
+
guests: z.array(MeetingGuestSchema).min(1).max(100),
|
|
24
|
+
timeZone: z
|
|
25
|
+
.string()
|
|
26
|
+
.max(100)
|
|
27
|
+
.refine((value) => {
|
|
28
|
+
try {
|
|
29
|
+
new Intl.DateTimeFormat('en', { timeZone: value });
|
|
30
|
+
return true;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}, 'Invalid time zone'),
|
|
35
|
+
})
|
|
36
|
+
.superRefine((value, context) => {
|
|
37
|
+
const emails = new Set<string>();
|
|
38
|
+
value.guests.forEach((guest, index) => {
|
|
39
|
+
if (emails.has(guest.email))
|
|
40
|
+
context.addIssue({
|
|
41
|
+
code: 'custom',
|
|
42
|
+
message: 'Duplicate guest',
|
|
43
|
+
path: ['guests', index, 'email'],
|
|
44
|
+
});
|
|
45
|
+
emails.add(guest.email);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
export type MeetingInvitationInput = z.infer<
|
|
49
|
+
typeof MeetingInvitationInputSchema
|
|
50
|
+
>;
|
|
51
|
+
|
|
52
|
+
export type MeetingAttendee = z.infer<typeof MeetingGuestSchema> & {
|
|
53
|
+
response: MeetingResponse;
|
|
54
|
+
self?: boolean;
|
|
55
|
+
};
|
|
56
|
+
export interface MeetingInvitationState {
|
|
57
|
+
organizer: { email: string; name?: string; self: boolean };
|
|
58
|
+
attendees: MeetingAttendee[];
|
|
59
|
+
timeZone?: string;
|
|
60
|
+
iCalUid?: string;
|
|
61
|
+
cancelled: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function normalizeGoogleResponse(value: unknown): MeetingResponse {
|
|
65
|
+
const parsed = MeetingResponseSchema.safeParse(value);
|
|
66
|
+
return parsed.success ? parsed.data : 'needsAction';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function normalizeMicrosoftResponse(value: unknown): MeetingResponse {
|
|
70
|
+
switch (value) {
|
|
71
|
+
case 'accepted':
|
|
72
|
+
case 'organizer':
|
|
73
|
+
return 'accepted';
|
|
74
|
+
case 'declined':
|
|
75
|
+
return 'declined';
|
|
76
|
+
case 'tentativelyAccepted':
|
|
77
|
+
return 'tentative';
|
|
78
|
+
default:
|
|
79
|
+
return 'needsAction';
|
|
80
|
+
}
|
|
81
|
+
}
|
package/src/onboarding.ts
CHANGED
|
@@ -109,6 +109,7 @@ const MISSIONS = [
|
|
|
109
109
|
'confirmation_required',
|
|
110
110
|
['store', 'preview', 'checkout'],
|
|
111
111
|
],
|
|
112
|
+
['lettin', 'build', 'confirmation_required', ['world', 'write', 'publish']],
|
|
112
113
|
['learn', 'learn', 'reversible', ['courses', 'study', 'progress']],
|
|
113
114
|
['mail', 'focus', 'confirmation_required', ['inbox', 'compose', 'follow_up']],
|
|
114
115
|
['meet', 'collaborate', 'confirmation_required', ['room', 'invite', 'meet']],
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
|
|
8
8
|
describe('platform release metadata', () => {
|
|
9
9
|
it('uses the centralized shared browser app version', () => {
|
|
10
|
-
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.53.0'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('preserves the source timestamp in builtAt and derives a short hash', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.
|
|
3
|
+
export const TUTURUUU_PLATFORM_VERSION = '0.53.0'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
6
|
/** ISO timestamp of the source commit used for the build, not deployment time. */
|
package/src/portless.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const TUTURUUU_PORTLESS_APP_ORIGINS = {
|
|
|
24
24
|
infra: `https://infra.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
25
25
|
inventory: `https://inventory.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
26
26
|
learn: `https://learn.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
27
|
+
lettin: `https://lettin.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
27
28
|
mail: `https://mail.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
28
29
|
meet: `https://meet.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
29
30
|
mind: `https://mind.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
@@ -22,7 +22,7 @@ describe('sortTasksByCriterion', () => {
|
|
|
22
22
|
expect(parseTaskSortBy(null)).toBeUndefined();
|
|
23
23
|
});
|
|
24
24
|
|
|
25
|
-
it('sorts every priority direction with unset priorities
|
|
25
|
+
it('sorts every priority direction with unset priorities first by default', () => {
|
|
26
26
|
const tasks = [
|
|
27
27
|
task('none'),
|
|
28
28
|
task('high', { priority: 'high' }),
|
|
@@ -33,12 +33,31 @@ describe('sortTasksByCriterion', () => {
|
|
|
33
33
|
|
|
34
34
|
expect(
|
|
35
35
|
sortTasksByCriterion(tasks, 'priority-high').map(({ id }) => id)
|
|
36
|
-
).toEqual(['critical', 'high', 'normal', 'low'
|
|
36
|
+
).toEqual(['none', 'critical', 'high', 'normal', 'low']);
|
|
37
37
|
expect(
|
|
38
38
|
sortTasksByCriterion(tasks, 'priority-low').map(({ id }) => id)
|
|
39
|
-
).toEqual(['low', 'normal', 'high', 'critical'
|
|
39
|
+
).toEqual(['none', 'low', 'normal', 'high', 'critical']);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it.each(['priority-high', 'priority-low'] as const)(
|
|
43
|
+
'keeps null and undefined priorities last when requested: %s',
|
|
44
|
+
(sortBy) => {
|
|
45
|
+
const tasks = [
|
|
46
|
+
task('null', { priority: null }),
|
|
47
|
+
task('set', { priority: 'high' }),
|
|
48
|
+
task('undefined'),
|
|
49
|
+
];
|
|
50
|
+
expect(
|
|
51
|
+
sortTasksByCriterion(tasks, sortBy, 'last').map((t) => t.id)
|
|
52
|
+
).toEqual(['set', 'null', 'undefined']);
|
|
53
|
+
expect(sortTasksByCriterion(tasks, sortBy).map((t) => t.id)).toEqual([
|
|
54
|
+
'null',
|
|
55
|
+
'undefined',
|
|
56
|
+
'set',
|
|
57
|
+
]);
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
42
61
|
it('keeps missing due dates and estimations last in both directions', () => {
|
|
43
62
|
const tasks = [
|
|
44
63
|
task('none'),
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import type { TaskPriority } from '@tuturuuu/types/primitives/Priority';
|
|
2
2
|
|
|
3
|
+
export const TASK_UNPRIORITIZED_POSITION_CONFIG_ID =
|
|
4
|
+
'TASK_UNPRIORITIZED_POSITION';
|
|
5
|
+
export type UnprioritizedPosition = 'first' | 'last';
|
|
6
|
+
export function normalizeUnprioritizedPosition(
|
|
7
|
+
value: unknown
|
|
8
|
+
): UnprioritizedPosition {
|
|
9
|
+
return value === 'last' ? 'last' : 'first';
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
export type TaskSortBy =
|
|
4
13
|
| 'name-asc'
|
|
5
14
|
| 'name-desc'
|
|
@@ -78,11 +87,29 @@ function compareCreatedFallback(a: SortableTask, b: SortableTask) {
|
|
|
78
87
|
return created || a.id.localeCompare(b.id);
|
|
79
88
|
}
|
|
80
89
|
|
|
90
|
+
export function compareUnprioritizedPlacement(
|
|
91
|
+
a: TaskPriority | null | undefined,
|
|
92
|
+
b: TaskPriority | null | undefined,
|
|
93
|
+
position: UnprioritizedPosition = 'first'
|
|
94
|
+
) {
|
|
95
|
+
if ((a == null) === (b == null)) return 0;
|
|
96
|
+
return (a == null ? -1 : 1) * (position === 'first' ? 1 : -1);
|
|
97
|
+
}
|
|
98
|
+
|
|
81
99
|
export function compareTasksByCriterion(
|
|
82
100
|
a: SortableTask,
|
|
83
101
|
b: SortableTask,
|
|
84
|
-
sortBy: TaskSortBy
|
|
102
|
+
sortBy: TaskSortBy,
|
|
103
|
+
unprioritizedPosition: UnprioritizedPosition = 'first'
|
|
85
104
|
) {
|
|
105
|
+
if (sortBy === 'priority-high' || sortBy === 'priority-low') {
|
|
106
|
+
const placement = compareUnprioritizedPlacement(
|
|
107
|
+
a.priority,
|
|
108
|
+
b.priority,
|
|
109
|
+
unprioritizedPosition
|
|
110
|
+
);
|
|
111
|
+
if (placement) return placement;
|
|
112
|
+
}
|
|
86
113
|
let result = 0;
|
|
87
114
|
|
|
88
115
|
switch (sortBy) {
|
|
@@ -155,8 +182,11 @@ export function compareTasksByCriterion(
|
|
|
155
182
|
|
|
156
183
|
export function sortTasksByCriterion<T extends SortableTask>(
|
|
157
184
|
tasks: readonly T[],
|
|
158
|
-
sortBy: TaskSortBy | null | undefined
|
|
185
|
+
sortBy: TaskSortBy | null | undefined,
|
|
186
|
+
unprioritizedPosition: UnprioritizedPosition = 'first'
|
|
159
187
|
) {
|
|
160
188
|
if (!sortBy) return [...tasks];
|
|
161
|
-
return [...tasks].sort((a, b) =>
|
|
189
|
+
return [...tasks].sort((a, b) =>
|
|
190
|
+
compareTasksByCriterion(a, b, sortBy, unprioritizedPosition)
|
|
191
|
+
);
|
|
162
192
|
}
|
package/src/workspace-helper.ts
CHANGED
|
@@ -702,7 +702,6 @@ export async function resolveWorkspaceIdForPrincipal({
|
|
|
702
702
|
if (wsId.trim().toLowerCase() !== PERSONAL_WORKSPACE_SLUG) {
|
|
703
703
|
return normalizeWorkspaceId(wsId, authorizationClient);
|
|
704
704
|
}
|
|
705
|
-
|
|
706
705
|
const { data: creatorWorkspace, error: creatorWorkspaceError } =
|
|
707
706
|
await authorizationClient
|
|
708
707
|
.from('workspaces')
|
|
@@ -710,7 +709,6 @@ export async function resolveWorkspaceIdForPrincipal({
|
|
|
710
709
|
.eq('personal', true)
|
|
711
710
|
.eq('creator_id', principal.id)
|
|
712
711
|
.maybeSingle();
|
|
713
|
-
|
|
714
712
|
if (!creatorWorkspaceError && creatorWorkspace?.id) {
|
|
715
713
|
return creatorWorkspace.id;
|
|
716
714
|
}
|
|
@@ -725,7 +723,9 @@ export async function resolveWorkspaceIdForPrincipal({
|
|
|
725
723
|
.maybeSingle();
|
|
726
724
|
|
|
727
725
|
if (memberWorkspaceError || !memberWorkspace?.id) {
|
|
728
|
-
throw
|
|
726
|
+
throw creatorWorkspaceError || memberWorkspaceError
|
|
727
|
+
? new WorkspaceResolutionError('Personal workspace lookup failed')
|
|
728
|
+
: new WorkspaceNotFoundError('Personal workspace not found');
|
|
729
729
|
}
|
|
730
730
|
|
|
731
731
|
return memberWorkspace.id;
|
package/src/workspace-limits.ts
CHANGED
|
@@ -55,7 +55,7 @@ export async function checkWorkspaceCreationLimit(
|
|
|
55
55
|
canCreate: false,
|
|
56
56
|
currentCount,
|
|
57
57
|
limit: MAX_WORKSPACES_FOR_FREE_USERS,
|
|
58
|
-
errorMessage: `You have reached the maximum limit of ${MAX_WORKSPACES_FOR_FREE_USERS} workspaces.
|
|
58
|
+
errorMessage: `You have reached the maximum limit of ${MAX_WORKSPACES_FOR_FREE_USERS} workspaces. Upgrading does not increase this account limit. Contact the Tuturuuu team if you need more workspaces.`,
|
|
59
59
|
errorCode: WORKSPACE_LIMIT_ERROR_CODE,
|
|
60
60
|
};
|
|
61
61
|
}
|