]> git.immae.eu Git - github/Chocobozzz/PeerTube.git/blob - server/models/user/user-notification.ts
Move typescript utils in its own directory
[github/Chocobozzz/PeerTube.git] / server / models / user / user-notification.ts
1 import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
2 import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
3 import { uuidToShort } from '@shared/core-utils/uuid'
4 import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user'
5 import { AttributesOnly } from '@shared/typescript-utils'
6 import { UserNotification, UserNotificationType } from '../../../shared'
7 import { isBooleanValid } from '../../helpers/custom-validators/misc'
8 import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
9 import { AbuseModel } from '../abuse/abuse'
10 import { VideoAbuseModel } from '../abuse/video-abuse'
11 import { VideoCommentAbuseModel } from '../abuse/video-comment-abuse'
12 import { AccountModel } from '../account/account'
13 import { ActorModel } from '../actor/actor'
14 import { ActorFollowModel } from '../actor/actor-follow'
15 import { ActorImageModel } from '../actor/actor-image'
16 import { ApplicationModel } from '../application/application'
17 import { PluginModel } from '../server/plugin'
18 import { ServerModel } from '../server/server'
19 import { getSort, throwIfNotValid } from '../utils'
20 import { VideoModel } from '../video/video'
21 import { VideoBlacklistModel } from '../video/video-blacklist'
22 import { VideoChannelModel } from '../video/video-channel'
23 import { VideoCommentModel } from '../video/video-comment'
24 import { VideoImportModel } from '../video/video-import'
25 import { UserModel } from './user'
26
27 enum ScopeNames {
28 WITH_ALL = 'WITH_ALL'
29 }
30
31 function buildActorWithAvatarInclude () {
32 return {
33 attributes: [ 'preferredUsername' ],
34 model: ActorModel.unscoped(),
35 required: true,
36 include: [
37 {
38 attributes: [ 'filename' ],
39 as: 'Avatar',
40 model: ActorImageModel.unscoped(),
41 required: false
42 },
43 {
44 attributes: [ 'host' ],
45 model: ServerModel.unscoped(),
46 required: false
47 }
48 ]
49 }
50 }
51
52 function buildVideoInclude (required: boolean) {
53 return {
54 attributes: [ 'id', 'uuid', 'name' ],
55 model: VideoModel.unscoped(),
56 required
57 }
58 }
59
60 function buildChannelInclude (required: boolean, withActor = false) {
61 return {
62 required,
63 attributes: [ 'id', 'name' ],
64 model: VideoChannelModel.unscoped(),
65 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
66 }
67 }
68
69 function buildAccountInclude (required: boolean, withActor = false) {
70 return {
71 required,
72 attributes: [ 'id', 'name' ],
73 model: AccountModel.unscoped(),
74 include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
75 }
76 }
77
78 @Scopes(() => ({
79 [ScopeNames.WITH_ALL]: {
80 include: [
81 Object.assign(buildVideoInclude(false), {
82 include: [ buildChannelInclude(true, true) ]
83 }),
84
85 {
86 attributes: [ 'id', 'originCommentId' ],
87 model: VideoCommentModel.unscoped(),
88 required: false,
89 include: [
90 buildAccountInclude(true, true),
91 buildVideoInclude(true)
92 ]
93 },
94
95 {
96 attributes: [ 'id', 'state' ],
97 model: AbuseModel.unscoped(),
98 required: false,
99 include: [
100 {
101 attributes: [ 'id' ],
102 model: VideoAbuseModel.unscoped(),
103 required: false,
104 include: [ buildVideoInclude(false) ]
105 },
106 {
107 attributes: [ 'id' ],
108 model: VideoCommentAbuseModel.unscoped(),
109 required: false,
110 include: [
111 {
112 attributes: [ 'id', 'originCommentId' ],
113 model: VideoCommentModel.unscoped(),
114 required: false,
115 include: [
116 {
117 attributes: [ 'id', 'name', 'uuid' ],
118 model: VideoModel.unscoped(),
119 required: false
120 }
121 ]
122 }
123 ]
124 },
125 {
126 model: AccountModel,
127 as: 'FlaggedAccount',
128 required: false,
129 include: [ buildActorWithAvatarInclude() ]
130 }
131 ]
132 },
133
134 {
135 attributes: [ 'id' ],
136 model: VideoBlacklistModel.unscoped(),
137 required: false,
138 include: [ buildVideoInclude(true) ]
139 },
140
141 {
142 attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
143 model: VideoImportModel.unscoped(),
144 required: false,
145 include: [ buildVideoInclude(false) ]
146 },
147
148 {
149 attributes: [ 'id', 'name', 'type', 'latestVersion' ],
150 model: PluginModel.unscoped(),
151 required: false
152 },
153
154 {
155 attributes: [ 'id', 'latestPeerTubeVersion' ],
156 model: ApplicationModel.unscoped(),
157 required: false
158 },
159
160 {
161 attributes: [ 'id', 'state' ],
162 model: ActorFollowModel.unscoped(),
163 required: false,
164 include: [
165 {
166 attributes: [ 'preferredUsername' ],
167 model: ActorModel.unscoped(),
168 required: true,
169 as: 'ActorFollower',
170 include: [
171 {
172 attributes: [ 'id', 'name' ],
173 model: AccountModel.unscoped(),
174 required: true
175 },
176 {
177 attributes: [ 'filename' ],
178 as: 'Avatar',
179 model: ActorImageModel.unscoped(),
180 required: false
181 },
182 {
183 attributes: [ 'host' ],
184 model: ServerModel.unscoped(),
185 required: false
186 }
187 ]
188 },
189 {
190 attributes: [ 'preferredUsername', 'type' ],
191 model: ActorModel.unscoped(),
192 required: true,
193 as: 'ActorFollowing',
194 include: [
195 buildChannelInclude(false),
196 buildAccountInclude(false),
197 {
198 attributes: [ 'host' ],
199 model: ServerModel.unscoped(),
200 required: false
201 }
202 ]
203 }
204 ]
205 },
206
207 buildAccountInclude(false, true)
208 ]
209 }
210 }))
211 @Table({
212 tableName: 'userNotification',
213 indexes: [
214 {
215 fields: [ 'userId' ]
216 },
217 {
218 fields: [ 'videoId' ],
219 where: {
220 videoId: {
221 [Op.ne]: null
222 }
223 }
224 },
225 {
226 fields: [ 'commentId' ],
227 where: {
228 commentId: {
229 [Op.ne]: null
230 }
231 }
232 },
233 {
234 fields: [ 'abuseId' ],
235 where: {
236 abuseId: {
237 [Op.ne]: null
238 }
239 }
240 },
241 {
242 fields: [ 'videoBlacklistId' ],
243 where: {
244 videoBlacklistId: {
245 [Op.ne]: null
246 }
247 }
248 },
249 {
250 fields: [ 'videoImportId' ],
251 where: {
252 videoImportId: {
253 [Op.ne]: null
254 }
255 }
256 },
257 {
258 fields: [ 'accountId' ],
259 where: {
260 accountId: {
261 [Op.ne]: null
262 }
263 }
264 },
265 {
266 fields: [ 'actorFollowId' ],
267 where: {
268 actorFollowId: {
269 [Op.ne]: null
270 }
271 }
272 },
273 {
274 fields: [ 'pluginId' ],
275 where: {
276 pluginId: {
277 [Op.ne]: null
278 }
279 }
280 },
281 {
282 fields: [ 'applicationId' ],
283 where: {
284 applicationId: {
285 [Op.ne]: null
286 }
287 }
288 }
289 ] as (ModelIndexesOptions & { where?: WhereOptions })[]
290 })
291 export class UserNotificationModel extends Model<Partial<AttributesOnly<UserNotificationModel>>> {
292
293 @AllowNull(false)
294 @Default(null)
295 @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
296 @Column
297 type: UserNotificationType
298
299 @AllowNull(false)
300 @Default(false)
301 @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
302 @Column
303 read: boolean
304
305 @CreatedAt
306 createdAt: Date
307
308 @UpdatedAt
309 updatedAt: Date
310
311 @ForeignKey(() => UserModel)
312 @Column
313 userId: number
314
315 @BelongsTo(() => UserModel, {
316 foreignKey: {
317 allowNull: false
318 },
319 onDelete: 'cascade'
320 })
321 User: UserModel
322
323 @ForeignKey(() => VideoModel)
324 @Column
325 videoId: number
326
327 @BelongsTo(() => VideoModel, {
328 foreignKey: {
329 allowNull: true
330 },
331 onDelete: 'cascade'
332 })
333 Video: VideoModel
334
335 @ForeignKey(() => VideoCommentModel)
336 @Column
337 commentId: number
338
339 @BelongsTo(() => VideoCommentModel, {
340 foreignKey: {
341 allowNull: true
342 },
343 onDelete: 'cascade'
344 })
345 Comment: VideoCommentModel
346
347 @ForeignKey(() => AbuseModel)
348 @Column
349 abuseId: number
350
351 @BelongsTo(() => AbuseModel, {
352 foreignKey: {
353 allowNull: true
354 },
355 onDelete: 'cascade'
356 })
357 Abuse: AbuseModel
358
359 @ForeignKey(() => VideoBlacklistModel)
360 @Column
361 videoBlacklistId: number
362
363 @BelongsTo(() => VideoBlacklistModel, {
364 foreignKey: {
365 allowNull: true
366 },
367 onDelete: 'cascade'
368 })
369 VideoBlacklist: VideoBlacklistModel
370
371 @ForeignKey(() => VideoImportModel)
372 @Column
373 videoImportId: number
374
375 @BelongsTo(() => VideoImportModel, {
376 foreignKey: {
377 allowNull: true
378 },
379 onDelete: 'cascade'
380 })
381 VideoImport: VideoImportModel
382
383 @ForeignKey(() => AccountModel)
384 @Column
385 accountId: number
386
387 @BelongsTo(() => AccountModel, {
388 foreignKey: {
389 allowNull: true
390 },
391 onDelete: 'cascade'
392 })
393 Account: AccountModel
394
395 @ForeignKey(() => ActorFollowModel)
396 @Column
397 actorFollowId: number
398
399 @BelongsTo(() => ActorFollowModel, {
400 foreignKey: {
401 allowNull: true
402 },
403 onDelete: 'cascade'
404 })
405 ActorFollow: ActorFollowModel
406
407 @ForeignKey(() => PluginModel)
408 @Column
409 pluginId: number
410
411 @BelongsTo(() => PluginModel, {
412 foreignKey: {
413 allowNull: true
414 },
415 onDelete: 'cascade'
416 })
417 Plugin: PluginModel
418
419 @ForeignKey(() => ApplicationModel)
420 @Column
421 applicationId: number
422
423 @BelongsTo(() => ApplicationModel, {
424 foreignKey: {
425 allowNull: true
426 },
427 onDelete: 'cascade'
428 })
429 Application: ApplicationModel
430
431 static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
432 const where = { userId }
433
434 const query: FindOptions = {
435 offset: start,
436 limit: count,
437 order: getSort(sort),
438 where
439 }
440
441 if (unread !== undefined) query.where['read'] = !unread
442
443 return Promise.all([
444 UserNotificationModel.count({ where })
445 .then(count => count || 0),
446
447 count === 0
448 ? []
449 : UserNotificationModel.scope(ScopeNames.WITH_ALL).findAll(query)
450 ]).then(([ total, data ]) => ({ total, data }))
451 }
452
453 static markAsRead (userId: number, notificationIds: number[]) {
454 const query = {
455 where: {
456 userId,
457 id: {
458 [Op.in]: notificationIds
459 }
460 }
461 }
462
463 return UserNotificationModel.update({ read: true }, query)
464 }
465
466 static markAllAsRead (userId: number) {
467 const query = { where: { userId } }
468
469 return UserNotificationModel.update({ read: true }, query)
470 }
471
472 static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
473 const id = parseInt(options.id + '', 10)
474
475 function buildAccountWhereQuery (base: string) {
476 const whereSuffix = options.forUserId
477 ? ` AND "userNotification"."userId" = ${options.forUserId}`
478 : ''
479
480 if (options.type === 'account') {
481 return base +
482 ` WHERE "account"."id" = ${id} ${whereSuffix}`
483 }
484
485 return base +
486 ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
487 }
488
489 const queries = [
490 buildAccountWhereQuery(
491 `SELECT "userNotification"."id" FROM "userNotification" ` +
492 `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
493 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
494 ),
495
496 // Remove notifications from muted accounts that followed ours
497 buildAccountWhereQuery(
498 `SELECT "userNotification"."id" FROM "userNotification" ` +
499 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
500 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
501 `INNER JOIN account ON account."actorId" = actor.id `
502 ),
503
504 // Remove notifications from muted accounts that commented something
505 buildAccountWhereQuery(
506 `SELECT "userNotification"."id" FROM "userNotification" ` +
507 `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
508 `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
509 `INNER JOIN account ON account."actorId" = actor.id `
510 ),
511
512 buildAccountWhereQuery(
513 `SELECT "userNotification"."id" FROM "userNotification" ` +
514 `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
515 `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
516 `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
517 )
518 ]
519
520 const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
521
522 return UserNotificationModel.sequelize.query(query)
523 }
524
525 toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
526 const video = this.Video
527 ? Object.assign(this.formatVideo(this.Video), { channel: this.formatActor(this.Video.VideoChannel) })
528 : undefined
529
530 const videoImport = this.VideoImport
531 ? {
532 id: this.VideoImport.id,
533 video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined,
534 torrentName: this.VideoImport.torrentName,
535 magnetUri: this.VideoImport.magnetUri,
536 targetUrl: this.VideoImport.targetUrl
537 }
538 : undefined
539
540 const comment = this.Comment
541 ? {
542 id: this.Comment.id,
543 threadId: this.Comment.getThreadId(),
544 account: this.formatActor(this.Comment.Account),
545 video: this.formatVideo(this.Comment.Video)
546 }
547 : undefined
548
549 const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
550
551 const videoBlacklist = this.VideoBlacklist
552 ? {
553 id: this.VideoBlacklist.id,
554 video: this.formatVideo(this.VideoBlacklist.Video)
555 }
556 : undefined
557
558 const account = this.Account ? this.formatActor(this.Account) : undefined
559
560 const actorFollowingType = {
561 Application: 'instance' as 'instance',
562 Group: 'channel' as 'channel',
563 Person: 'account' as 'account'
564 }
565 const actorFollow = this.ActorFollow
566 ? {
567 id: this.ActorFollow.id,
568 state: this.ActorFollow.state,
569 follower: {
570 id: this.ActorFollow.ActorFollower.Account.id,
571 displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
572 name: this.ActorFollow.ActorFollower.preferredUsername,
573 avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined,
574 host: this.ActorFollow.ActorFollower.getHost()
575 },
576 following: {
577 type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
578 displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
579 name: this.ActorFollow.ActorFollowing.preferredUsername,
580 host: this.ActorFollow.ActorFollowing.getHost()
581 }
582 }
583 : undefined
584
585 const plugin = this.Plugin
586 ? {
587 name: this.Plugin.name,
588 type: this.Plugin.type,
589 latestVersion: this.Plugin.latestVersion
590 }
591 : undefined
592
593 const peertube = this.Application
594 ? { latestVersion: this.Application.latestPeerTubeVersion }
595 : undefined
596
597 return {
598 id: this.id,
599 type: this.type,
600 read: this.read,
601 video,
602 videoImport,
603 comment,
604 abuse,
605 videoBlacklist,
606 account,
607 actorFollow,
608 plugin,
609 peertube,
610 createdAt: this.createdAt.toISOString(),
611 updatedAt: this.updatedAt.toISOString()
612 }
613 }
614
615 formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) {
616 return {
617 id: video.id,
618 uuid: video.uuid,
619 shortUUID: uuidToShort(video.uuid),
620 name: video.name
621 }
622 }
623
624 formatAbuse (this: UserNotificationModelForApi, abuse: UserNotificationIncludes.AbuseInclude) {
625 const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
626 ? {
627 threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
628
629 video: abuse.VideoCommentAbuse.VideoComment.Video
630 ? {
631 id: abuse.VideoCommentAbuse.VideoComment.Video.id,
632 name: abuse.VideoCommentAbuse.VideoComment.Video.name,
633 shortUUID: uuidToShort(abuse.VideoCommentAbuse.VideoComment.Video.uuid),
634 uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
635 }
636 : undefined
637 }
638 : undefined
639
640 const videoAbuse = abuse.VideoAbuse?.Video ? this.formatVideo(abuse.VideoAbuse.Video) : undefined
641
642 const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount) ? this.formatActor(abuse.FlaggedAccount) : undefined
643
644 return {
645 id: abuse.id,
646 state: abuse.state,
647 video: videoAbuse,
648 comment: commentAbuse,
649 account: accountAbuse
650 }
651 }
652
653 formatActor (
654 this: UserNotificationModelForApi,
655 accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
656 ) {
657 const avatar = accountOrChannel.Actor.Avatar
658 ? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
659 : undefined
660
661 return {
662 id: accountOrChannel.id,
663 displayName: accountOrChannel.getDisplayName(),
664 name: accountOrChannel.Actor.preferredUsername,
665 host: accountOrChannel.Actor.getHost(),
666 avatar
667 }
668 }
669 }