@tuturuuu/utils 0.0.3 → 0.6.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 +313 -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,379 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification Service Utilities
|
|
3
|
+
*
|
|
4
|
+
* Provides utilities for creating, managing, and querying notifications
|
|
5
|
+
* across web, email, SMS, and push notification channels.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createClient } from '@tuturuuu/supabase/next/server';
|
|
9
|
+
|
|
10
|
+
export type NotificationType =
|
|
11
|
+
| 'task_assigned'
|
|
12
|
+
| 'task_updated'
|
|
13
|
+
| 'task_mention'
|
|
14
|
+
| 'deadline_reminder'
|
|
15
|
+
| 'time_tracking_request_submitted'
|
|
16
|
+
| 'time_tracking_request_resubmitted'
|
|
17
|
+
| 'time_tracking_request_approved'
|
|
18
|
+
| 'time_tracking_request_rejected'
|
|
19
|
+
| 'time_tracking_request_needs_info'
|
|
20
|
+
| 'workspace_invite';
|
|
21
|
+
|
|
22
|
+
export type NotificationChannel = 'web' | 'email' | 'sms' | 'push';
|
|
23
|
+
|
|
24
|
+
export type NotificationStatus = 'pending' | 'sent' | 'failed';
|
|
25
|
+
|
|
26
|
+
export interface CreateNotificationParams {
|
|
27
|
+
wsId: string;
|
|
28
|
+
userId: string;
|
|
29
|
+
type: NotificationType;
|
|
30
|
+
title: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
data?: Record<string, any>;
|
|
33
|
+
entityType?: string;
|
|
34
|
+
entityId?: string;
|
|
35
|
+
createdBy?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface GetNotificationsParams {
|
|
39
|
+
wsId: string;
|
|
40
|
+
userId: string;
|
|
41
|
+
limit?: number;
|
|
42
|
+
offset?: number;
|
|
43
|
+
unreadOnly?: boolean;
|
|
44
|
+
type?: NotificationType;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Creates a notification for a user
|
|
49
|
+
* Respects user preferences for web and email notifications
|
|
50
|
+
*/
|
|
51
|
+
export async function createNotification(
|
|
52
|
+
params: CreateNotificationParams
|
|
53
|
+
): Promise<string | null> {
|
|
54
|
+
const supabase = await createClient();
|
|
55
|
+
|
|
56
|
+
const { data, error } = await supabase.rpc('create_notification', {
|
|
57
|
+
p_ws_id: params.wsId,
|
|
58
|
+
p_user_id: params.userId,
|
|
59
|
+
p_type: params.type,
|
|
60
|
+
p_title: params.title,
|
|
61
|
+
p_description: params.description ?? undefined,
|
|
62
|
+
p_data: params.data || {},
|
|
63
|
+
p_entity_type: params.entityType ?? undefined,
|
|
64
|
+
p_entity_id: params.entityId ?? undefined,
|
|
65
|
+
p_created_by: params.createdBy ?? undefined,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (error) {
|
|
69
|
+
console.error('Error creating notification:', error);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return data as string | null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Gets notifications for a user with pagination and filtering
|
|
78
|
+
*/
|
|
79
|
+
export async function getNotifications(params: GetNotificationsParams) {
|
|
80
|
+
const supabase = await createClient();
|
|
81
|
+
|
|
82
|
+
let query = supabase
|
|
83
|
+
.from('notifications')
|
|
84
|
+
.select('*', { count: 'exact' })
|
|
85
|
+
.eq('ws_id', params.wsId)
|
|
86
|
+
.eq('user_id', params.userId)
|
|
87
|
+
.order('created_at', { ascending: false });
|
|
88
|
+
|
|
89
|
+
if (params.unreadOnly) {
|
|
90
|
+
query = query.is('read_at', null);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (params.type) {
|
|
94
|
+
query = query.eq('type', params.type);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (params.limit) {
|
|
98
|
+
query = query.limit(params.limit);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (params.offset) {
|
|
102
|
+
query = query.range(
|
|
103
|
+
params.offset,
|
|
104
|
+
params.offset + (params.limit || 10) - 1
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { data, error, count } = await query;
|
|
109
|
+
|
|
110
|
+
if (error) {
|
|
111
|
+
console.error('Error fetching notifications:', error);
|
|
112
|
+
return { notifications: [], count: 0 };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
notifications: data || [],
|
|
117
|
+
count: count || 0,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Gets the unread notification count for a user
|
|
123
|
+
*/
|
|
124
|
+
export async function getUnreadCount(
|
|
125
|
+
wsId: string,
|
|
126
|
+
userId: string
|
|
127
|
+
): Promise<number> {
|
|
128
|
+
const supabase = await createClient();
|
|
129
|
+
|
|
130
|
+
const { count, error } = await supabase
|
|
131
|
+
.from('notifications')
|
|
132
|
+
.select('*', { count: 'exact', head: true })
|
|
133
|
+
.eq('ws_id', wsId)
|
|
134
|
+
.eq('user_id', userId)
|
|
135
|
+
.is('read_at', null);
|
|
136
|
+
|
|
137
|
+
if (error) {
|
|
138
|
+
console.error('Error fetching unread count:', error);
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return count || 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Marks a notification as read
|
|
147
|
+
*/
|
|
148
|
+
export async function markAsRead(notificationId: string): Promise<boolean> {
|
|
149
|
+
const supabase = await createClient();
|
|
150
|
+
|
|
151
|
+
const { error } = await supabase
|
|
152
|
+
.from('notifications')
|
|
153
|
+
.update({ read_at: new Date().toISOString() })
|
|
154
|
+
.eq('id', notificationId);
|
|
155
|
+
|
|
156
|
+
if (error) {
|
|
157
|
+
console.error('Error marking notification as read:', error);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Marks a notification as unread
|
|
166
|
+
*/
|
|
167
|
+
export async function markAsUnread(notificationId: string): Promise<boolean> {
|
|
168
|
+
const supabase = await createClient();
|
|
169
|
+
|
|
170
|
+
const { error } = await supabase
|
|
171
|
+
.from('notifications')
|
|
172
|
+
.update({ read_at: null })
|
|
173
|
+
.eq('id', notificationId);
|
|
174
|
+
|
|
175
|
+
if (error) {
|
|
176
|
+
console.error('Error marking notification as unread:', error);
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Marks all notifications as read for a user in a workspace
|
|
185
|
+
*/
|
|
186
|
+
export async function markAllAsRead(
|
|
187
|
+
wsId: string,
|
|
188
|
+
userId: string
|
|
189
|
+
): Promise<boolean> {
|
|
190
|
+
const supabase = await createClient();
|
|
191
|
+
|
|
192
|
+
const { error } = await supabase
|
|
193
|
+
.from('notifications')
|
|
194
|
+
.update({ read_at: new Date().toISOString() })
|
|
195
|
+
.eq('ws_id', wsId)
|
|
196
|
+
.eq('user_id', userId)
|
|
197
|
+
.is('read_at', null);
|
|
198
|
+
|
|
199
|
+
if (error) {
|
|
200
|
+
console.error('Error marking all notifications as read:', error);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Deletes a notification
|
|
209
|
+
*/
|
|
210
|
+
export async function deleteNotification(
|
|
211
|
+
notificationId: string
|
|
212
|
+
): Promise<boolean> {
|
|
213
|
+
const supabase = await createClient();
|
|
214
|
+
|
|
215
|
+
const { error } = await supabase
|
|
216
|
+
.from('notifications')
|
|
217
|
+
.delete()
|
|
218
|
+
.eq('id', notificationId);
|
|
219
|
+
|
|
220
|
+
if (error) {
|
|
221
|
+
console.error('Error deleting notification:', error);
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Gets notification preferences for a user
|
|
230
|
+
*/
|
|
231
|
+
export async function getNotificationPreferences(wsId: string, userId: string) {
|
|
232
|
+
const supabase = await createClient();
|
|
233
|
+
|
|
234
|
+
const { data, error } = await supabase
|
|
235
|
+
.from('notification_preferences')
|
|
236
|
+
.select('*')
|
|
237
|
+
.eq('ws_id', wsId)
|
|
238
|
+
.eq('user_id', userId);
|
|
239
|
+
|
|
240
|
+
if (error) {
|
|
241
|
+
console.error('Error fetching notification preferences:', error);
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return data || [];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Sets notification preference for a specific event type and channel
|
|
250
|
+
*/
|
|
251
|
+
export async function setNotificationPreference(
|
|
252
|
+
wsId: string,
|
|
253
|
+
userId: string,
|
|
254
|
+
eventType: NotificationType,
|
|
255
|
+
channel: NotificationChannel,
|
|
256
|
+
enabled: boolean
|
|
257
|
+
): Promise<boolean> {
|
|
258
|
+
const supabase = await createClient();
|
|
259
|
+
|
|
260
|
+
const { error } = await supabase.from('notification_preferences').upsert(
|
|
261
|
+
{
|
|
262
|
+
ws_id: wsId,
|
|
263
|
+
user_id: userId,
|
|
264
|
+
event_type: eventType,
|
|
265
|
+
channel,
|
|
266
|
+
enabled,
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
onConflict: 'ws_id,user_id,event_type,channel',
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
if (error) {
|
|
274
|
+
console.error('Error setting notification preference:', error);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Checks if a notification should be sent based on user preferences
|
|
283
|
+
* Defaults to true if no preference is set
|
|
284
|
+
*/
|
|
285
|
+
export async function shouldSendNotification(
|
|
286
|
+
wsId: string,
|
|
287
|
+
userId: string,
|
|
288
|
+
eventType: NotificationType,
|
|
289
|
+
channel: NotificationChannel
|
|
290
|
+
): Promise<boolean> {
|
|
291
|
+
const supabase = await createClient();
|
|
292
|
+
|
|
293
|
+
const { data, error } = await supabase.rpc('should_send_notification', {
|
|
294
|
+
p_ws_id: wsId,
|
|
295
|
+
p_user_id: userId,
|
|
296
|
+
p_event_type: eventType,
|
|
297
|
+
p_channel: channel,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
if (error) {
|
|
301
|
+
console.error('Error checking notification preference:', error);
|
|
302
|
+
// Default to true (enabled) if there's an error
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return data as boolean;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Scans text for @mentions and returns user IDs
|
|
311
|
+
* Mentions format: @[userId] or @username
|
|
312
|
+
*/
|
|
313
|
+
export function extractMentions(text: string): string[] {
|
|
314
|
+
if (!text) return [];
|
|
315
|
+
|
|
316
|
+
// Match @[uuid] pattern
|
|
317
|
+
const uuidPattern =
|
|
318
|
+
/@\[([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\]/gi;
|
|
319
|
+
const matches = text.matchAll(uuidPattern);
|
|
320
|
+
|
|
321
|
+
const userIds: string[] = [];
|
|
322
|
+
for (const match of matches) {
|
|
323
|
+
if (match[1]) {
|
|
324
|
+
userIds.push(match[1]);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return [...new Set(userIds)]; // Remove duplicates
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Creates mention notifications for users mentioned in text
|
|
333
|
+
*/
|
|
334
|
+
export async function createMentionNotifications(
|
|
335
|
+
wsId: string,
|
|
336
|
+
text: string,
|
|
337
|
+
entityType: string,
|
|
338
|
+
entityId: string,
|
|
339
|
+
entityName: string,
|
|
340
|
+
createdBy: string
|
|
341
|
+
): Promise<void> {
|
|
342
|
+
const mentionedUserIds = extractMentions(text);
|
|
343
|
+
|
|
344
|
+
if (mentionedUserIds.length === 0) return;
|
|
345
|
+
|
|
346
|
+
// Get creator name
|
|
347
|
+
const supabase = await createClient();
|
|
348
|
+
const { data: creator } = await supabase
|
|
349
|
+
.from('users')
|
|
350
|
+
.select('display_name')
|
|
351
|
+
.eq('id', createdBy)
|
|
352
|
+
.single();
|
|
353
|
+
|
|
354
|
+
const creatorName = creator?.display_name || 'Someone';
|
|
355
|
+
|
|
356
|
+
// Create notifications for each mentioned user
|
|
357
|
+
for (const userId of mentionedUserIds) {
|
|
358
|
+
// Don't notify the creator
|
|
359
|
+
if (userId === createdBy) continue;
|
|
360
|
+
|
|
361
|
+
await createNotification({
|
|
362
|
+
wsId,
|
|
363
|
+
userId,
|
|
364
|
+
type: 'task_mention',
|
|
365
|
+
title: 'You were mentioned',
|
|
366
|
+
description: `${creatorName} mentioned you in "${entityName}"`,
|
|
367
|
+
data: {
|
|
368
|
+
entity_type: entityType,
|
|
369
|
+
entity_id: entityId,
|
|
370
|
+
entity_name: entityName,
|
|
371
|
+
mentioned_by: createdBy,
|
|
372
|
+
mentioned_by_name: creatorName,
|
|
373
|
+
},
|
|
374
|
+
entityType,
|
|
375
|
+
entityId,
|
|
376
|
+
createdBy,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
aggregateByChallenge,
|
|
4
|
+
calculateBestScores,
|
|
5
|
+
calculatePercentage,
|
|
6
|
+
calculateScore,
|
|
7
|
+
formatScore,
|
|
8
|
+
type ScoreInput,
|
|
9
|
+
} from '../calculate';
|
|
10
|
+
|
|
11
|
+
describe('calculateScore', () => {
|
|
12
|
+
it('should correctly calculate score with only tests', () => {
|
|
13
|
+
const input: ScoreInput = {
|
|
14
|
+
total_tests: 10,
|
|
15
|
+
passed_tests: 5,
|
|
16
|
+
total_criteria: 0,
|
|
17
|
+
sum_criterion_score: 0,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// With updated logic, only tests should get 100% weight
|
|
21
|
+
expect(calculateScore(input)).toBe(5); // (5/10) * 10 * 1.0 = 5
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('should correctly calculate score with only criteria', () => {
|
|
25
|
+
const input: ScoreInput = {
|
|
26
|
+
total_tests: 0,
|
|
27
|
+
passed_tests: 0,
|
|
28
|
+
total_criteria: 5,
|
|
29
|
+
sum_criterion_score: 25, // 5 criteria with average score of 5
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
expect(calculateScore(input)).toBe(5); // (25/(5*10)) * 10 * 1.0 = 5
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should correctly calculate score with both tests and criteria', () => {
|
|
36
|
+
const input: ScoreInput = {
|
|
37
|
+
total_tests: 10,
|
|
38
|
+
passed_tests: 8,
|
|
39
|
+
total_criteria: 5,
|
|
40
|
+
sum_criterion_score: 40, // 5 criteria with average score of 8
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Test score: (8/10) * 10 * 0.5 = 4
|
|
44
|
+
// Criteria score: (40/(5*10)) * 10 * 0.5 = 4
|
|
45
|
+
// Total: 4 + 4 = 8
|
|
46
|
+
expect(calculateScore(input)).toBe(8);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should give full weight to tests when criteria do not exist', () => {
|
|
50
|
+
const input: ScoreInput = {
|
|
51
|
+
total_tests: 10,
|
|
52
|
+
passed_tests: 7,
|
|
53
|
+
total_criteria: 0,
|
|
54
|
+
sum_criterion_score: 0,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// No criteria, so tests get 100% weight
|
|
58
|
+
// Test score: (7/10) * 10 * 1.0 = 7
|
|
59
|
+
expect(calculateScore(input)).toBe(7);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should handle zero test cases with criteria', () => {
|
|
63
|
+
const input: ScoreInput = {
|
|
64
|
+
total_tests: 0,
|
|
65
|
+
passed_tests: 0,
|
|
66
|
+
total_criteria: 5,
|
|
67
|
+
sum_criterion_score: 25, // 5 criteria with average score of 5
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// No tests, so criteria gets 100% weight
|
|
71
|
+
// Criteria score: (25/(5*10)) * 10 * 1.0 = 5
|
|
72
|
+
expect(calculateScore(input)).toBe(5);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should handle null values', () => {
|
|
76
|
+
const input: ScoreInput = {
|
|
77
|
+
total_tests: null,
|
|
78
|
+
passed_tests: null,
|
|
79
|
+
total_criteria: null,
|
|
80
|
+
sum_criterion_score: null,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
expect(calculateScore(input)).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('should handle undefined values', () => {
|
|
87
|
+
const input: ScoreInput = {};
|
|
88
|
+
|
|
89
|
+
expect(calculateScore(input)).toBe(0);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('calculateBestScores', () => {
|
|
94
|
+
it('should return the best score for each problem', () => {
|
|
95
|
+
const submissions = [
|
|
96
|
+
{ problem_id: 'p1', total_tests: 10, passed_tests: 5 },
|
|
97
|
+
{ problem_id: 'p1', total_tests: 10, passed_tests: 7 },
|
|
98
|
+
{ problem_id: 'p2', total_tests: 10, passed_tests: 3 },
|
|
99
|
+
{ problem_id: 'p2', total_tests: 10, passed_tests: 6 },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
const bestScores = calculateBestScores(submissions);
|
|
103
|
+
|
|
104
|
+
// With the updated logic: (passed/total) * 10 * 1.0 for tests only
|
|
105
|
+
expect(bestScores.get('p1')).toBe(7); // (7/10) * 10 * 1.0 = 7
|
|
106
|
+
expect(bestScores.get('p2')).toBe(6); // (6/10) * 10 * 1.0 = 6
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should handle submissions with both tests and criteria', () => {
|
|
110
|
+
const submissions = [
|
|
111
|
+
{
|
|
112
|
+
problem_id: 'p1',
|
|
113
|
+
total_tests: 10,
|
|
114
|
+
passed_tests: 8,
|
|
115
|
+
total_criteria: 4,
|
|
116
|
+
sum_criterion_score: 32, // Average of 8 per criterion
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
problem_id: 'p1',
|
|
120
|
+
total_tests: 10,
|
|
121
|
+
passed_tests: 6,
|
|
122
|
+
total_criteria: 5,
|
|
123
|
+
sum_criterion_score: 45, // Average of 9 per criterion
|
|
124
|
+
},
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
const bestScores = calculateBestScores(submissions);
|
|
128
|
+
|
|
129
|
+
// First submission:
|
|
130
|
+
// Tests: (8/10) * 10 * 0.5 = 4
|
|
131
|
+
// Criteria: (32/(4*10)) * 10 * 0.5 = 4
|
|
132
|
+
// Total: 8
|
|
133
|
+
|
|
134
|
+
// Second submission:
|
|
135
|
+
// Tests: (6/10) * 10 * 0.5 = 3
|
|
136
|
+
// Criteria: (45/(5*10)) * 10 * 0.5 = 4.5
|
|
137
|
+
// Total: 7.5
|
|
138
|
+
|
|
139
|
+
expect(bestScores.get('p1')).toBe(8);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should skip submissions without problem_id', () => {
|
|
143
|
+
const submissions = [
|
|
144
|
+
{ problem_id: 'p1', total_tests: 10, passed_tests: 5 },
|
|
145
|
+
{ problem_id: '', total_tests: 10, passed_tests: 7 },
|
|
146
|
+
{ problem_id: undefined as any, total_tests: 10, passed_tests: 3 },
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const bestScores = calculateBestScores(submissions);
|
|
150
|
+
|
|
151
|
+
expect(bestScores.get('p1')).toBe(5); // (5/10) * 10 * 1.0 = 5
|
|
152
|
+
expect(bestScores.size).toBe(1);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should return an empty map when no valid submissions exist', () => {
|
|
156
|
+
const submissions: Array<ScoreInput & { problem_id: string }> = [];
|
|
157
|
+
|
|
158
|
+
const bestScores = calculateBestScores(submissions);
|
|
159
|
+
|
|
160
|
+
expect(bestScores.size).toBe(0);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe('aggregateByChallenge', () => {
|
|
165
|
+
it('should correctly aggregate scores by challenge', () => {
|
|
166
|
+
const problemScores = new Map([
|
|
167
|
+
['p1', 5],
|
|
168
|
+
['p2', 7],
|
|
169
|
+
['p3', 3],
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
const problemChallengeMap = new Map([
|
|
173
|
+
['p1', 'c1'],
|
|
174
|
+
['p2', 'c1'],
|
|
175
|
+
['p3', 'c2'],
|
|
176
|
+
]);
|
|
177
|
+
|
|
178
|
+
const challengeScores = aggregateByChallenge(
|
|
179
|
+
problemScores,
|
|
180
|
+
problemChallengeMap
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
expect(challengeScores.c1).toBe(12); // 5 + 7
|
|
184
|
+
expect(challengeScores.c2).toBe(3);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('should ignore problems that are not in the challenge map', () => {
|
|
188
|
+
const problemScores = new Map([
|
|
189
|
+
['p1', 5],
|
|
190
|
+
['p2', 7],
|
|
191
|
+
['p3', 3],
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
const problemChallengeMap = new Map([
|
|
195
|
+
['p1', 'c1'],
|
|
196
|
+
['p3', 'c2'],
|
|
197
|
+
]);
|
|
198
|
+
|
|
199
|
+
const challengeScores = aggregateByChallenge(
|
|
200
|
+
problemScores,
|
|
201
|
+
problemChallengeMap
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
expect(challengeScores.c1).toBe(5);
|
|
205
|
+
expect(challengeScores.c2).toBe(3);
|
|
206
|
+
expect(Object.keys(challengeScores).length).toBe(2);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('should return an empty object when no problems match challenges', () => {
|
|
210
|
+
const problemScores = new Map([
|
|
211
|
+
['p1', 5],
|
|
212
|
+
['p2', 7],
|
|
213
|
+
]);
|
|
214
|
+
|
|
215
|
+
const problemChallengeMap = new Map([
|
|
216
|
+
['p3', 'c1'],
|
|
217
|
+
['p4', 'c2'],
|
|
218
|
+
]);
|
|
219
|
+
|
|
220
|
+
const challengeScores = aggregateByChallenge(
|
|
221
|
+
problemScores,
|
|
222
|
+
problemChallengeMap
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
expect(Object.keys(challengeScores).length).toBe(0);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('formatScore', () => {
|
|
230
|
+
it('should format score with default decimal places (1)', () => {
|
|
231
|
+
expect(formatScore(5)).toBe('5.0');
|
|
232
|
+
expect(formatScore(5.678)).toBe('5.7');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('should format score with specified decimal places', () => {
|
|
236
|
+
expect(formatScore(5, 0)).toBe('5');
|
|
237
|
+
expect(formatScore(5.678, 2)).toBe('5.68');
|
|
238
|
+
expect(formatScore(5.678, 3)).toBe('5.678');
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('calculatePercentage', () => {
|
|
243
|
+
it('should correctly calculate percentage', () => {
|
|
244
|
+
expect(calculatePercentage(5, 10)).toBe(50);
|
|
245
|
+
expect(calculatePercentage(7.5, 10)).toBe(75);
|
|
246
|
+
expect(calculatePercentage(0, 10)).toBe(0);
|
|
247
|
+
expect(calculatePercentage(10, 10)).toBe(100);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('should return 0 when maxScore is 0 or negative', () => {
|
|
251
|
+
expect(calculatePercentage(5, 0)).toBe(0);
|
|
252
|
+
expect(calculatePercentage(5, -10)).toBe(0);
|
|
253
|
+
});
|
|
254
|
+
});
|