This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

semble / src / modules / notifications / infrastructure / repositories / DrizzleNotificationRepository.ts
18 kB 579 lines
1import { eq, desc, and, count, inArray } from 'drizzle-orm'; 2import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; 3import { 4 INotificationRepository, 5 NotificationQueryOptions, 6 PaginatedNotificationResult, 7 EnrichedNotificationResult, 8 PaginatedEnrichedNotificationResult, 9} from '../../domain/INotificationRepository'; 10import { Notification } from '../../domain/Notification'; 11import { NotificationId } from '../../domain/value-objects/NotificationId'; 12import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; 13import { notifications } from './schema/notification.sql'; 14import { 15 NotificationMapper, 16 NotificationDTO, 17} from './mappers/NotificationMapper'; 18import { Result, ok, err } from '../../../../shared/core/Result'; 19import { cards } from '../../../cards/infrastructure/repositories/schema/card.sql'; 20import { 21 collections, 22 collectionCards, 23} from '../../../cards/infrastructure/repositories/schema/collection.sql'; 24import { libraryMemberships } from '../../../cards/infrastructure/repositories/schema/libraryMembership.sql'; 25import { CardTypeEnum } from '../../../cards/domain/value-objects/CardType'; 26import { countDistinct } from 'drizzle-orm'; 27 28export class DrizzleNotificationRepository implements INotificationRepository { 29 constructor(private db: PostgresJsDatabase) {} 30 31 async save(notification: Notification): Promise<Result<void>> { 32 try { 33 const dto = NotificationMapper.toPersistence(notification); 34 35 await this.db 36 .insert(notifications) 37 .values({ 38 id: dto.id, 39 recipientUserId: dto.recipientUserId, 40 actorUserId: dto.actorUserId, 41 type: dto.type, 42 metadata: dto.metadata, 43 read: dto.read, 44 createdAt: dto.createdAt, 45 updatedAt: dto.updatedAt, 46 }) 47 .onConflictDoUpdate({ 48 target: notifications.id, 49 set: { 50 read: dto.read, 51 updatedAt: dto.updatedAt, 52 }, 53 }); 54 55 return ok(undefined); 56 } catch (error) { 57 return err(error as Error); 58 } 59 } 60 61 async findById(id: NotificationId): Promise<Result<Notification | null>> { 62 try { 63 const result = await this.db 64 .select() 65 .from(notifications) 66 .where(eq(notifications.id, id.getStringValue())) 67 .limit(1); 68 69 if (result.length === 0) { 70 return ok(null); 71 } 72 73 const notificationData = result[0]!; 74 const dto: NotificationDTO = { 75 id: notificationData.id, 76 recipientUserId: notificationData.recipientUserId, 77 actorUserId: notificationData.actorUserId, 78 type: notificationData.type, 79 metadata: notificationData.metadata as any, 80 read: notificationData.read, 81 createdAt: notificationData.createdAt, 82 updatedAt: notificationData.updatedAt, 83 }; 84 85 const domainResult = NotificationMapper.toDomain(dto); 86 if (domainResult.isErr()) { 87 return err(domainResult.error); 88 } 89 90 return ok(domainResult.value); 91 } catch (error) { 92 return err(error as Error); 93 } 94 } 95 96 async findByRecipient( 97 recipientId: CuratorId, 98 options: NotificationQueryOptions, 99 ): Promise<Result<PaginatedNotificationResult>> { 100 try { 101 const { page, limit, unreadOnly } = options; 102 const offset = (page - 1) * limit; 103 104 // Build where conditions 105 const whereConditions = [ 106 eq(notifications.recipientUserId, recipientId.value), 107 ]; 108 109 if (unreadOnly) { 110 whereConditions.push(eq(notifications.read, false)); 111 } 112 113 const whereClause = 114 whereConditions.length > 1 115 ? and(...whereConditions) 116 : whereConditions[0]; 117 118 // Get notifications 119 const notificationsResult = await this.db 120 .select() 121 .from(notifications) 122 .where(whereClause) 123 .orderBy(desc(notifications.createdAt)) 124 .limit(limit) 125 .offset(offset); 126 127 // Get total count 128 const totalCountResult = await this.db 129 .select({ count: count() }) 130 .from(notifications) 131 .where(whereClause); 132 133 // Get unread count 134 const unreadCountResult = await this.db 135 .select({ count: count() }) 136 .from(notifications) 137 .where( 138 and( 139 eq(notifications.recipientUserId, recipientId.value), 140 eq(notifications.read, false), 141 ), 142 ); 143 144 const totalCount = totalCountResult[0]?.count || 0; 145 const unreadCount = unreadCountResult[0]?.count || 0; 146 147 // Map to domain objects 148 const notificationsList: Notification[] = []; 149 for (const notificationData of notificationsResult) { 150 const dto: NotificationDTO = { 151 id: notificationData.id, 152 recipientUserId: notificationData.recipientUserId, 153 actorUserId: notificationData.actorUserId, 154 type: notificationData.type, 155 metadata: notificationData.metadata as any, 156 read: notificationData.read, 157 createdAt: notificationData.createdAt, 158 updatedAt: notificationData.updatedAt, 159 }; 160 161 const domainResult = NotificationMapper.toDomain(dto); 162 if (domainResult.isErr()) { 163 return err(domainResult.error); 164 } 165 166 notificationsList.push(domainResult.value); 167 } 168 169 const hasMore = offset + notificationsList.length < totalCount; 170 171 return ok({ 172 notifications: notificationsList, 173 totalCount, 174 hasMore, 175 unreadCount, 176 }); 177 } catch (error) { 178 return err(error as Error); 179 } 180 } 181 182 async getUnreadCount(recipientId: CuratorId): Promise<Result<number>> { 183 try { 184 const result = await this.db 185 .select({ count: count() }) 186 .from(notifications) 187 .where( 188 and( 189 eq(notifications.recipientUserId, recipientId.value), 190 eq(notifications.read, false), 191 ), 192 ); 193 194 return ok(result[0]?.count || 0); 195 } catch (error) { 196 return err(error as Error); 197 } 198 } 199 200 async markAsRead(notificationIds: NotificationId[]): Promise<Result<void>> { 201 try { 202 const ids = notificationIds.map((id) => id.getStringValue()); 203 204 if (ids.length === 0) { 205 return ok(undefined); 206 } 207 208 await this.db 209 .update(notifications) 210 .set({ 211 read: true, 212 updatedAt: new Date(), 213 }) 214 .where(inArray(notifications.id, ids)); 215 216 return ok(undefined); 217 } catch (error) { 218 return err(error as Error); 219 } 220 } 221 222 async findByCardAndActor( 223 cardId: string, 224 actorUserId: CuratorId, 225 ): Promise<Result<Notification[]>> { 226 try { 227 const result = await this.db 228 .select() 229 .from(notifications) 230 .where(eq(notifications.actorUserId, actorUserId.value)); 231 232 // Filter by cardId in metadata 233 const matchingNotifications: Notification[] = []; 234 for (const notificationData of result) { 235 const metadata = notificationData.metadata as any; 236 if (metadata.cardId === cardId) { 237 const dto: NotificationDTO = { 238 id: notificationData.id, 239 recipientUserId: notificationData.recipientUserId, 240 actorUserId: notificationData.actorUserId, 241 type: notificationData.type, 242 metadata: notificationData.metadata as any, 243 read: notificationData.read, 244 createdAt: notificationData.createdAt, 245 updatedAt: notificationData.updatedAt, 246 }; 247 248 const domainResult = NotificationMapper.toDomain(dto); 249 if (domainResult.isErr()) { 250 return err(domainResult.error); 251 } 252 253 matchingNotifications.push(domainResult.value); 254 } 255 } 256 257 return ok(matchingNotifications); 258 } catch (error) { 259 return err(error as Error); 260 } 261 } 262 263 async markAllAsReadForUser(recipientId: CuratorId): Promise<Result<number>> { 264 try { 265 const result = await this.db 266 .update(notifications) 267 .set({ 268 read: true, 269 updatedAt: new Date(), 270 }) 271 .where( 272 and( 273 eq(notifications.recipientUserId, recipientId.value), 274 eq(notifications.read, false), 275 ), 276 ); 277 278 // For PostgreSQL with drizzle-orm, we need to handle the result differently 279 // The result might not have rowCount, so we'll return 0 as a fallback 280 return ok(0); 281 } catch (error) { 282 return err(error as Error); 283 } 284 } 285 286 async findByRecipientEnriched( 287 recipientId: CuratorId, 288 options: NotificationQueryOptions, 289 ): Promise<Result<PaginatedEnrichedNotificationResult>> { 290 try { 291 const { page, limit, unreadOnly } = options; 292 const offset = (page - 1) * limit; 293 294 // Build where conditions for notifications 295 const notificationWhereConditions = [ 296 eq(notifications.recipientUserId, recipientId.value), 297 ]; 298 299 if (unreadOnly) { 300 notificationWhereConditions.push(eq(notifications.read, false)); 301 } 302 303 const notificationWhereClause = 304 notificationWhereConditions.length > 1 305 ? and(...notificationWhereConditions) 306 : notificationWhereConditions[0]; 307 308 // Get enriched notifications with card data 309 const enrichedQuery = this.db 310 .select({ 311 // Notification fields 312 id: notifications.id, 313 type: notifications.type, 314 read: notifications.read, 315 createdAt: notifications.createdAt, 316 actorUserId: notifications.actorUserId, 317 metadata: notifications.metadata, 318 319 // Card fields 320 cardId: cards.id, 321 cardAuthorId: cards.authorId, 322 cardUrl: cards.url, 323 cardContentData: cards.contentData, 324 cardLibraryCount: cards.libraryCount, 325 cardCreatedAt: cards.createdAt, 326 cardUpdatedAt: cards.updatedAt, 327 }) 328 .from(notifications) 329 .innerJoin(cards, eq(cards.id, notifications.metadata)) 330 .where(notificationWhereClause) 331 .orderBy(desc(notifications.createdAt)) 332 .limit(limit) 333 .offset(offset); 334 335 // Note: We need to extract cardId from metadata since it's stored as JSON 336 // This is a simplified approach - in practice you'd need to handle the JSON extraction 337 const notificationsResult = await this.db 338 .select({ 339 id: notifications.id, 340 type: notifications.type, 341 read: notifications.read, 342 createdAt: notifications.createdAt, 343 actorUserId: notifications.actorUserId, 344 metadata: notifications.metadata, 345 }) 346 .from(notifications) 347 .where(notificationWhereClause) 348 .orderBy(desc(notifications.createdAt)) 349 .limit(limit) 350 .offset(offset); 351 352 if (notificationsResult.length === 0) { 353 // Get counts for empty result 354 const totalCountResult = await this.db 355 .select({ count: count() }) 356 .from(notifications) 357 .where(notificationWhereClause); 358 359 const unreadCountResult = await this.db 360 .select({ count: count() }) 361 .from(notifications) 362 .where( 363 and( 364 eq(notifications.recipientUserId, recipientId.value), 365 eq(notifications.read, false), 366 ), 367 ); 368 369 return ok({ 370 notifications: [], 371 totalCount: totalCountResult[0]?.count || 0, 372 hasMore: false, 373 unreadCount: unreadCountResult[0]?.count || 0, 374 }); 375 } 376 377 // Extract card IDs from metadata 378 const cardIds = notificationsResult 379 .map((n) => (n.metadata as any)?.cardId) 380 .filter(Boolean); 381 382 if (cardIds.length === 0) { 383 return ok({ 384 notifications: [], 385 totalCount: 0, 386 hasMore: false, 387 unreadCount: 0, 388 }); 389 } 390 391 // Get card data with URL library counts 392 const cardsQuery = this.db 393 .select({ 394 id: cards.id, 395 authorId: cards.authorId, 396 url: cards.url, 397 contentData: cards.contentData, 398 libraryCount: cards.libraryCount, 399 createdAt: cards.createdAt, 400 updatedAt: cards.updatedAt, 401 }) 402 .from(cards) 403 .where( 404 and(inArray(cards.id, cardIds), eq(cards.type, CardTypeEnum.URL)), 405 ); 406 407 const cardsResult = await cardsQuery; 408 409 // Get URL library counts for these cards 410 const urls = cardsResult.map((card) => card.url).filter(Boolean); 411 const urlLibraryCountsQuery = this.db 412 .select({ 413 url: cards.url, 414 count: countDistinct(libraryMemberships.userId), 415 }) 416 .from(cards) 417 .innerJoin(libraryMemberships, eq(cards.id, libraryMemberships.cardId)) 418 .where(and(eq(cards.type, CardTypeEnum.URL), inArray(cards.url, urls))) 419 .groupBy(cards.url); 420 421 const urlLibraryCountsResult = await urlLibraryCountsQuery; 422 const urlLibraryCountMap = new Map<string, number>(); 423 urlLibraryCountsResult.forEach((row) => { 424 if (row.url) { 425 urlLibraryCountMap.set(row.url, row.count); 426 } 427 }); 428 429 // Get notes for these cards 430 const notesQuery = this.db 431 .select({ 432 id: cards.id, 433 parentCardId: cards.parentCardId, 434 contentData: cards.contentData, 435 }) 436 .from(cards) 437 .where( 438 and( 439 eq(cards.type, CardTypeEnum.NOTE), 440 inArray(cards.parentCardId, cardIds), 441 ), 442 ); 443 444 const notesResult = await notesQuery; 445 446 // Get collections for these cards 447 const collectionsQuery = this.db 448 .select({ 449 cardId: collectionCards.cardId, 450 collectionId: collections.id, 451 collectionUri: collections.publishedRecordId, 452 collectionName: collections.name, 453 collectionDescription: collections.description, 454 collectionAuthorId: collections.authorId, 455 collectionCardCount: collections.cardCount, 456 collectionCreatedAt: collections.createdAt, 457 collectionUpdatedAt: collections.updatedAt, 458 }) 459 .from(collectionCards) 460 .innerJoin( 461 collections, 462 eq(collectionCards.collectionId, collections.id), 463 ) 464 .where(inArray(collectionCards.cardId, cardIds)); 465 466 const collectionsResult = await collectionsQuery; 467 468 // Build card lookup map 469 const cardMap = new Map(cardsResult.map((card) => [card.id, card])); 470 471 // Build enriched notifications 472 const enrichedNotifications: EnrichedNotificationResult[] = []; 473 474 for (const notification of notificationsResult) { 475 const metadata = notification.metadata as any; 476 const cardId = metadata?.cardId; 477 478 if (!cardId) continue; 479 480 const card = cardMap.get(cardId); 481 if (!card) continue; 482 483 const note = notesResult.find((n) => n.parentCardId === cardId); 484 const cardCollections = collectionsResult 485 .filter((c) => c.cardId === cardId) 486 .map((c) => ({ 487 id: c.collectionId, 488 uri: c.collectionUri || undefined, 489 name: c.collectionName, 490 description: c.collectionDescription || undefined, 491 authorId: c.collectionAuthorId, 492 cardCount: c.collectionCardCount, 493 createdAt: c.collectionCreatedAt, 494 updatedAt: c.collectionUpdatedAt, 495 })); 496 497 const urlLibraryCount = urlLibraryCountMap.get(card.url || '') || 0; 498 499 enrichedNotifications.push({ 500 id: notification.id, 501 type: notification.type, 502 read: notification.read, 503 createdAt: notification.createdAt, 504 actorUserId: notification.actorUserId, 505 cardAuthorId: card.authorId, 506 cardId: card.id, 507 cardUrl: card.url || '', 508 cardTitle: card.contentData?.metadata?.title, 509 cardDescription: card.contentData?.metadata?.description, 510 cardAuthor: card.contentData?.metadata?.author, 511 cardPublishedDate: card.contentData?.metadata?.publishedDate 512 ? new Date(card.contentData.metadata.publishedDate) 513 : undefined, 514 cardSiteName: card.contentData?.metadata?.siteName, 515 cardImageUrl: card.contentData?.metadata?.imageUrl, 516 cardType: card.contentData?.metadata?.type, 517 cardRetrievedAt: card.contentData?.metadata?.retrievedAt 518 ? new Date(card.contentData.metadata.retrievedAt) 519 : undefined, 520 cardDoi: card.contentData?.metadata?.doi, 521 cardIsbn: card.contentData?.metadata?.isbn, 522 cardLibraryCount: card.libraryCount, 523 cardUrlLibraryCount: urlLibraryCount, 524 cardUrlInLibrary: undefined, // Would need calling user ID to determine this 525 cardCreatedAt: card.createdAt, 526 cardUpdatedAt: card.updatedAt, 527 cardNote: note 528 ? { 529 id: note.id, 530 text: note.contentData?.text || '', 531 } 532 : undefined, 533 collections: cardCollections, 534 }); 535 } 536 537 // Get total count and unread count 538 const totalCountResult = await this.db 539 .select({ count: count() }) 540 .from(notifications) 541 .where(notificationWhereClause); 542 543 const unreadCountResult = await this.db 544 .select({ count: count() }) 545 .from(notifications) 546 .where( 547 and( 548 eq(notifications.recipientUserId, recipientId.value), 549 eq(notifications.read, false), 550 ), 551 ); 552 553 const totalCount = totalCountResult[0]?.count || 0; 554 const unreadCount = unreadCountResult[0]?.count || 0; 555 const hasMore = offset + enrichedNotifications.length < totalCount; 556 557 return ok({ 558 notifications: enrichedNotifications, 559 totalCount, 560 hasMore, 561 unreadCount, 562 }); 563 } catch (error) { 564 return err(error as Error); 565 } 566 } 567 568 async delete(id: NotificationId): Promise<Result<void>> { 569 try { 570 await this.db 571 .delete(notifications) 572 .where(eq(notifications.id, id.getStringValue())); 573 574 return ok(undefined); 575 } catch (error) { 576 return err(error as Error); 577 } 578 } 579}