Merge branch 'release/1.4.0' into develop
[oweals/peertube.git] / server / models / account / user-notification.ts
1 import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Model, Scopes, Table, UpdatedAt } from 'sequelize-typescript'
2 import { UserNotification, UserNotificationType } from '../../../shared'
3 import { getSort, throwIfNotValid } from '../utils'
4 import { isBooleanValid } from '../../helpers/custom-validators/misc'
5 import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications'
6 import { UserModel } from './user'
7 import { VideoModel } from '../video/video'
8 import { VideoCommentModel } from '../video/video-comment'
9 import { FindOptions, ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
10 import { VideoChannelModel } from '../video/video-channel'
11 import { AccountModel } from './account'
12 import { VideoAbuseModel } from '../video/video-abuse'
13 import { VideoBlacklistModel } from '../video/video-blacklist'
14 import { VideoImportModel } from '../video/video-import'
15 import { ActorModel } from '../activitypub/actor'
16 import { ActorFollowModel } from '../activitypub/actor-follow'
17 import { AvatarModel } from '../avatar/avatar'
18 import { ServerModel } from '../server/server'
19 import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/typings/models/user'
20
21 enum ScopeNames {
22   WITH_ALL = 'WITH_ALL'
23 }
24
25 function buildActorWithAvatarInclude () {
26   return {
27     attributes: [ 'preferredUsername' ],
28     model: ActorModel.unscoped(),
29     required: true,
30     include: [
31       {
32         attributes: [ 'filename' ],
33         model: AvatarModel.unscoped(),
34         required: false
35       },
36       {
37         attributes: [ 'host' ],
38         model: ServerModel.unscoped(),
39         required: false
40       }
41     ]
42   }
43 }
44
45 function buildVideoInclude (required: boolean) {
46   return {
47     attributes: [ 'id', 'uuid', 'name' ],
48     model: VideoModel.unscoped(),
49     required
50   }
51 }
52
53 function buildChannelInclude (required: boolean, withActor = false) {
54   return {
55     required,
56     attributes: [ 'id', 'name' ],
57     model: VideoChannelModel.unscoped(),
58     include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
59   }
60 }
61
62 function buildAccountInclude (required: boolean, withActor = false) {
63   return {
64     required,
65     attributes: [ 'id', 'name' ],
66     model: AccountModel.unscoped(),
67     include: withActor === true ? [ buildActorWithAvatarInclude() ] : []
68   }
69 }
70
71 @Scopes(() => ({
72   [ScopeNames.WITH_ALL]: {
73     include: [
74       Object.assign(buildVideoInclude(false), {
75         include: [ buildChannelInclude(true, true) ]
76       }),
77
78       {
79         attributes: [ 'id', 'originCommentId' ],
80         model: VideoCommentModel.unscoped(),
81         required: false,
82         include: [
83           buildAccountInclude(true, true),
84           buildVideoInclude(true)
85         ]
86       },
87
88       {
89         attributes: [ 'id' ],
90         model: VideoAbuseModel.unscoped(),
91         required: false,
92         include: [ buildVideoInclude(true) ]
93       },
94
95       {
96         attributes: [ 'id' ],
97         model: VideoBlacklistModel.unscoped(),
98         required: false,
99         include: [ buildVideoInclude(true) ]
100       },
101
102       {
103         attributes: [ 'id', 'magnetUri', 'targetUrl', 'torrentName' ],
104         model: VideoImportModel.unscoped(),
105         required: false,
106         include: [ buildVideoInclude(false) ]
107       },
108
109       {
110         attributes: [ 'id', 'state' ],
111         model: ActorFollowModel.unscoped(),
112         required: false,
113         include: [
114           {
115             attributes: [ 'preferredUsername' ],
116             model: ActorModel.unscoped(),
117             required: true,
118             as: 'ActorFollower',
119             include: [
120               {
121                 attributes: [ 'id', 'name' ],
122                 model: AccountModel.unscoped(),
123                 required: true
124               },
125               {
126                 attributes: [ 'filename' ],
127                 model: AvatarModel.unscoped(),
128                 required: false
129               },
130               {
131                 attributes: [ 'host' ],
132                 model: ServerModel.unscoped(),
133                 required: false
134               }
135             ]
136           },
137           {
138             attributes: [ 'preferredUsername' ],
139             model: ActorModel.unscoped(),
140             required: true,
141             as: 'ActorFollowing',
142             include: [
143               buildChannelInclude(false),
144               buildAccountInclude(false)
145             ]
146           }
147         ]
148       },
149
150       buildAccountInclude(false, true)
151     ]
152   }
153 }))
154 @Table({
155   tableName: 'userNotification',
156   indexes: [
157     {
158       fields: [ 'userId' ]
159     },
160     {
161       fields: [ 'videoId' ],
162       where: {
163         videoId: {
164           [Op.ne]: null
165         }
166       }
167     },
168     {
169       fields: [ 'commentId' ],
170       where: {
171         commentId: {
172           [Op.ne]: null
173         }
174       }
175     },
176     {
177       fields: [ 'videoAbuseId' ],
178       where: {
179         videoAbuseId: {
180           [Op.ne]: null
181         }
182       }
183     },
184     {
185       fields: [ 'videoBlacklistId' ],
186       where: {
187         videoBlacklistId: {
188           [Op.ne]: null
189         }
190       }
191     },
192     {
193       fields: [ 'videoImportId' ],
194       where: {
195         videoImportId: {
196           [Op.ne]: null
197         }
198       }
199     },
200     {
201       fields: [ 'accountId' ],
202       where: {
203         accountId: {
204           [Op.ne]: null
205         }
206       }
207     },
208     {
209       fields: [ 'actorFollowId' ],
210       where: {
211         actorFollowId: {
212           [Op.ne]: null
213         }
214       }
215     }
216   ] as (ModelIndexesOptions & { where?: WhereOptions })[]
217 })
218 export class UserNotificationModel extends Model<UserNotificationModel> {
219
220   @AllowNull(false)
221   @Default(null)
222   @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
223   @Column
224   type: UserNotificationType
225
226   @AllowNull(false)
227   @Default(false)
228   @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
229   @Column
230   read: boolean
231
232   @CreatedAt
233   createdAt: Date
234
235   @UpdatedAt
236   updatedAt: Date
237
238   @ForeignKey(() => UserModel)
239   @Column
240   userId: number
241
242   @BelongsTo(() => UserModel, {
243     foreignKey: {
244       allowNull: false
245     },
246     onDelete: 'cascade'
247   })
248   User: UserModel
249
250   @ForeignKey(() => VideoModel)
251   @Column
252   videoId: number
253
254   @BelongsTo(() => VideoModel, {
255     foreignKey: {
256       allowNull: true
257     },
258     onDelete: 'cascade'
259   })
260   Video: VideoModel
261
262   @ForeignKey(() => VideoCommentModel)
263   @Column
264   commentId: number
265
266   @BelongsTo(() => VideoCommentModel, {
267     foreignKey: {
268       allowNull: true
269     },
270     onDelete: 'cascade'
271   })
272   Comment: VideoCommentModel
273
274   @ForeignKey(() => VideoAbuseModel)
275   @Column
276   videoAbuseId: number
277
278   @BelongsTo(() => VideoAbuseModel, {
279     foreignKey: {
280       allowNull: true
281     },
282     onDelete: 'cascade'
283   })
284   VideoAbuse: VideoAbuseModel
285
286   @ForeignKey(() => VideoBlacklistModel)
287   @Column
288   videoBlacklistId: number
289
290   @BelongsTo(() => VideoBlacklistModel, {
291     foreignKey: {
292       allowNull: true
293     },
294     onDelete: 'cascade'
295   })
296   VideoBlacklist: VideoBlacklistModel
297
298   @ForeignKey(() => VideoImportModel)
299   @Column
300   videoImportId: number
301
302   @BelongsTo(() => VideoImportModel, {
303     foreignKey: {
304       allowNull: true
305     },
306     onDelete: 'cascade'
307   })
308   VideoImport: VideoImportModel
309
310   @ForeignKey(() => AccountModel)
311   @Column
312   accountId: number
313
314   @BelongsTo(() => AccountModel, {
315     foreignKey: {
316       allowNull: true
317     },
318     onDelete: 'cascade'
319   })
320   Account: AccountModel
321
322   @ForeignKey(() => ActorFollowModel)
323   @Column
324   actorFollowId: number
325
326   @BelongsTo(() => ActorFollowModel, {
327     foreignKey: {
328       allowNull: true
329     },
330     onDelete: 'cascade'
331   })
332   ActorFollow: ActorFollowModel
333
334   static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
335     const query: FindOptions = {
336       offset: start,
337       limit: count,
338       order: getSort(sort),
339       where: {
340         userId
341       }
342     }
343
344     if (unread !== undefined) query.where['read'] = !unread
345
346     return UserNotificationModel.scope(ScopeNames.WITH_ALL)
347                                 .findAndCountAll(query)
348                                 .then(({ rows, count }) => {
349                                   return {
350                                     data: rows,
351                                     total: count
352                                   }
353                                 })
354   }
355
356   static markAsRead (userId: number, notificationIds: number[]) {
357     const query = {
358       where: {
359         userId,
360         id: {
361           [Op.in]: notificationIds // FIXME: sequelize ANY seems broken
362         }
363       }
364     }
365
366     return UserNotificationModel.update({ read: true }, query)
367   }
368
369   static markAllAsRead (userId: number) {
370     const query = { where: { userId } }
371
372     return UserNotificationModel.update({ read: true }, query)
373   }
374
375   toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
376     const video = this.Video
377       ? Object.assign(this.formatVideo(this.Video),{ channel: this.formatActor(this.Video.VideoChannel) })
378       : undefined
379
380     const videoImport = this.VideoImport ? {
381       id: this.VideoImport.id,
382       video: this.VideoImport.Video ? this.formatVideo(this.VideoImport.Video) : undefined,
383       torrentName: this.VideoImport.torrentName,
384       magnetUri: this.VideoImport.magnetUri,
385       targetUrl: this.VideoImport.targetUrl
386     } : undefined
387
388     const comment = this.Comment ? {
389       id: this.Comment.id,
390       threadId: this.Comment.getThreadId(),
391       account: this.formatActor(this.Comment.Account),
392       video: this.formatVideo(this.Comment.Video)
393     } : undefined
394
395     const videoAbuse = this.VideoAbuse ? {
396       id: this.VideoAbuse.id,
397       video: this.formatVideo(this.VideoAbuse.Video)
398     } : undefined
399
400     const videoBlacklist = this.VideoBlacklist ? {
401       id: this.VideoBlacklist.id,
402       video: this.formatVideo(this.VideoBlacklist.Video)
403     } : undefined
404
405     const account = this.Account ? this.formatActor(this.Account) : undefined
406
407     const actorFollow = this.ActorFollow ? {
408       id: this.ActorFollow.id,
409       state: this.ActorFollow.state,
410       follower: {
411         id: this.ActorFollow.ActorFollower.Account.id,
412         displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
413         name: this.ActorFollow.ActorFollower.preferredUsername,
414         avatar: this.ActorFollow.ActorFollower.Avatar ? { path: this.ActorFollow.ActorFollower.Avatar.getStaticPath() } : undefined,
415         host: this.ActorFollow.ActorFollower.getHost()
416       },
417       following: {
418         type: this.ActorFollow.ActorFollowing.VideoChannel ? 'channel' as 'channel' : 'account' as 'account',
419         displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
420         name: this.ActorFollow.ActorFollowing.preferredUsername
421       }
422     } : undefined
423
424     return {
425       id: this.id,
426       type: this.type,
427       read: this.read,
428       video,
429       videoImport,
430       comment,
431       videoAbuse,
432       videoBlacklist,
433       account,
434       actorFollow,
435       createdAt: this.createdAt.toISOString(),
436       updatedAt: this.updatedAt.toISOString()
437     }
438   }
439
440   formatVideo (this: UserNotificationModelForApi, video: UserNotificationIncludes.VideoInclude) {
441     return {
442       id: video.id,
443       uuid: video.uuid,
444       name: video.name
445     }
446   }
447
448   formatActor (
449     this: UserNotificationModelForApi,
450     accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
451   ) {
452     const avatar = accountOrChannel.Actor.Avatar
453       ? { path: accountOrChannel.Actor.Avatar.getStaticPath() }
454       : undefined
455
456     return {
457       id: accountOrChannel.id,
458       displayName: accountOrChannel.getDisplayName(),
459       name: accountOrChannel.Actor.preferredUsername,
460       host: accountOrChannel.Actor.getHost(),
461       avatar
462     }
463   }
464 }