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