ニジカ投稿局 https://tv.nizika.tv
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

user-notification.ts 15 KiB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566
  1. import { forceNumber, maxBy } from '@peertube/peertube-core-utils'
  2. import { UserNotification, type UserNotificationType_Type } from '@peertube/peertube-models'
  3. import { uuidToShort } from '@peertube/peertube-node-utils'
  4. import { UserNotificationIncludes, UserNotificationModelForApi } from '@server/types/models/user/index.js'
  5. import { ModelIndexesOptions, Op, WhereOptions } from 'sequelize'
  6. import { AllowNull, BelongsTo, Column, CreatedAt, Default, ForeignKey, Is, Table, UpdatedAt } from 'sequelize-typescript'
  7. import { isBooleanValid } from '../../helpers/custom-validators/misc.js'
  8. import { isUserNotificationTypeValid } from '../../helpers/custom-validators/user-notifications.js'
  9. import { AbuseModel } from '../abuse/abuse.js'
  10. import { AccountModel } from '../account/account.js'
  11. import { ActorFollowModel } from '../actor/actor-follow.js'
  12. import { ApplicationModel } from '../application/application.js'
  13. import { PluginModel } from '../server/plugin.js'
  14. import { SequelizeModel, throwIfNotValid } from '../shared/index.js'
  15. import { VideoBlacklistModel } from '../video/video-blacklist.js'
  16. import { VideoCaptionModel } from '../video/video-caption.js'
  17. import { VideoCommentModel } from '../video/video-comment.js'
  18. import { VideoImportModel } from '../video/video-import.js'
  19. import { VideoModel } from '../video/video.js'
  20. import { UserNotificationListQueryBuilder } from './sql/user-notitication-list-query-builder.js'
  21. import { UserRegistrationModel } from './user-registration.js'
  22. import { UserModel } from './user.js'
  23. @Table({
  24. tableName: 'userNotification',
  25. indexes: [
  26. {
  27. fields: [ 'userId' ]
  28. },
  29. {
  30. fields: [ 'videoId' ],
  31. where: {
  32. videoId: {
  33. [Op.ne]: null
  34. }
  35. }
  36. },
  37. {
  38. fields: [ 'commentId' ],
  39. where: {
  40. commentId: {
  41. [Op.ne]: null
  42. }
  43. }
  44. },
  45. {
  46. fields: [ 'abuseId' ],
  47. where: {
  48. abuseId: {
  49. [Op.ne]: null
  50. }
  51. }
  52. },
  53. {
  54. fields: [ 'videoBlacklistId' ],
  55. where: {
  56. videoBlacklistId: {
  57. [Op.ne]: null
  58. }
  59. }
  60. },
  61. {
  62. fields: [ 'videoImportId' ],
  63. where: {
  64. videoImportId: {
  65. [Op.ne]: null
  66. }
  67. }
  68. },
  69. {
  70. fields: [ 'accountId' ],
  71. where: {
  72. accountId: {
  73. [Op.ne]: null
  74. }
  75. }
  76. },
  77. {
  78. fields: [ 'actorFollowId' ],
  79. where: {
  80. actorFollowId: {
  81. [Op.ne]: null
  82. }
  83. }
  84. },
  85. {
  86. fields: [ 'pluginId' ],
  87. where: {
  88. pluginId: {
  89. [Op.ne]: null
  90. }
  91. }
  92. },
  93. {
  94. fields: [ 'applicationId' ],
  95. where: {
  96. applicationId: {
  97. [Op.ne]: null
  98. }
  99. }
  100. },
  101. {
  102. fields: [ 'userRegistrationId' ],
  103. where: {
  104. userRegistrationId: {
  105. [Op.ne]: null
  106. }
  107. }
  108. }
  109. ] as (ModelIndexesOptions & { where?: WhereOptions })[]
  110. })
  111. export class UserNotificationModel extends SequelizeModel<UserNotificationModel> {
  112. @AllowNull(false)
  113. @Default(null)
  114. @Is('UserNotificationType', value => throwIfNotValid(value, isUserNotificationTypeValid, 'type'))
  115. @Column
  116. type: UserNotificationType_Type
  117. @AllowNull(false)
  118. @Default(false)
  119. @Is('UserNotificationRead', value => throwIfNotValid(value, isBooleanValid, 'read'))
  120. @Column
  121. read: boolean
  122. @CreatedAt
  123. createdAt: Date
  124. @UpdatedAt
  125. updatedAt: Date
  126. @ForeignKey(() => UserModel)
  127. @Column
  128. userId: number
  129. @BelongsTo(() => UserModel, {
  130. foreignKey: {
  131. allowNull: false
  132. },
  133. onDelete: 'cascade'
  134. })
  135. User: Awaited<UserModel>
  136. @ForeignKey(() => VideoModel)
  137. @Column
  138. videoId: number
  139. @BelongsTo(() => VideoModel, {
  140. foreignKey: {
  141. allowNull: true
  142. },
  143. onDelete: 'cascade'
  144. })
  145. Video: Awaited<VideoModel>
  146. @ForeignKey(() => VideoCommentModel)
  147. @Column
  148. commentId: number
  149. @BelongsTo(() => VideoCommentModel, {
  150. foreignKey: {
  151. allowNull: true
  152. },
  153. onDelete: 'cascade'
  154. })
  155. VideoComment: Awaited<VideoCommentModel>
  156. @ForeignKey(() => AbuseModel)
  157. @Column
  158. abuseId: number
  159. @BelongsTo(() => AbuseModel, {
  160. foreignKey: {
  161. allowNull: true
  162. },
  163. onDelete: 'cascade'
  164. })
  165. Abuse: Awaited<AbuseModel>
  166. @ForeignKey(() => VideoBlacklistModel)
  167. @Column
  168. videoBlacklistId: number
  169. @BelongsTo(() => VideoBlacklistModel, {
  170. foreignKey: {
  171. allowNull: true
  172. },
  173. onDelete: 'cascade'
  174. })
  175. VideoBlacklist: Awaited<VideoBlacklistModel>
  176. @ForeignKey(() => VideoImportModel)
  177. @Column
  178. videoImportId: number
  179. @BelongsTo(() => VideoImportModel, {
  180. foreignKey: {
  181. allowNull: true
  182. },
  183. onDelete: 'cascade'
  184. })
  185. VideoImport: Awaited<VideoImportModel>
  186. @ForeignKey(() => AccountModel)
  187. @Column
  188. accountId: number
  189. @BelongsTo(() => AccountModel, {
  190. foreignKey: {
  191. allowNull: true
  192. },
  193. onDelete: 'cascade'
  194. })
  195. Account: Awaited<AccountModel>
  196. @ForeignKey(() => ActorFollowModel)
  197. @Column
  198. actorFollowId: number
  199. @BelongsTo(() => ActorFollowModel, {
  200. foreignKey: {
  201. allowNull: true
  202. },
  203. onDelete: 'cascade'
  204. })
  205. ActorFollow: Awaited<ActorFollowModel>
  206. @ForeignKey(() => PluginModel)
  207. @Column
  208. pluginId: number
  209. @BelongsTo(() => PluginModel, {
  210. foreignKey: {
  211. allowNull: true
  212. },
  213. onDelete: 'cascade'
  214. })
  215. Plugin: Awaited<PluginModel>
  216. @ForeignKey(() => ApplicationModel)
  217. @Column
  218. applicationId: number
  219. @BelongsTo(() => ApplicationModel, {
  220. foreignKey: {
  221. allowNull: true
  222. },
  223. onDelete: 'cascade'
  224. })
  225. Application: Awaited<ApplicationModel>
  226. @ForeignKey(() => UserRegistrationModel)
  227. @Column
  228. userRegistrationId: number
  229. @BelongsTo(() => UserRegistrationModel, {
  230. foreignKey: {
  231. allowNull: true
  232. },
  233. onDelete: 'cascade'
  234. })
  235. UserRegistration: Awaited<UserRegistrationModel>
  236. @ForeignKey(() => VideoCaptionModel)
  237. @Column
  238. videoCaptionId: number
  239. @BelongsTo(() => VideoCaptionModel, {
  240. foreignKey: {
  241. allowNull: true
  242. },
  243. onDelete: 'cascade'
  244. })
  245. VideoCaption: Awaited<VideoCaptionModel>
  246. static listForApi (userId: number, start: number, count: number, sort: string, unread?: boolean) {
  247. const where = { userId }
  248. const query = {
  249. userId,
  250. unread,
  251. offset: start,
  252. limit: count,
  253. sort,
  254. where
  255. }
  256. if (unread !== undefined) query.where['read'] = !unread
  257. return Promise.all([
  258. UserNotificationModel.count({ where })
  259. .then(count => count || 0),
  260. count === 0
  261. ? [] as UserNotificationModelForApi[]
  262. : new UserNotificationListQueryBuilder(this.sequelize, query).listNotifications()
  263. ]).then(([ total, data ]) => ({ total, data }))
  264. }
  265. static markAsRead (userId: number, notificationIds: number[]) {
  266. const query = {
  267. where: {
  268. userId,
  269. id: {
  270. [Op.in]: notificationIds
  271. },
  272. read: false
  273. }
  274. }
  275. return UserNotificationModel.update({ read: true }, query)
  276. }
  277. static markAllAsRead (userId: number) {
  278. const query = { where: { userId, read: false } }
  279. return UserNotificationModel.update({ read: true }, query)
  280. }
  281. static removeNotificationsOf (options: { id: number, type: 'account' | 'server', forUserId?: number }) {
  282. const id = forceNumber(options.id)
  283. function buildAccountWhereQuery (base: string) {
  284. const whereSuffix = options.forUserId
  285. ? ` AND "userNotification"."userId" = ${options.forUserId}`
  286. : ''
  287. if (options.type === 'account') {
  288. return base +
  289. ` WHERE "account"."id" = ${id} ${whereSuffix}`
  290. }
  291. return base +
  292. ` WHERE "actor"."serverId" = ${id} ${whereSuffix}`
  293. }
  294. const queries = [
  295. buildAccountWhereQuery(
  296. `SELECT "userNotification"."id" FROM "userNotification" ` +
  297. `INNER JOIN "account" ON "userNotification"."accountId" = "account"."id" ` +
  298. `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
  299. ),
  300. // Remove notifications from muted accounts that followed ours
  301. buildAccountWhereQuery(
  302. `SELECT "userNotification"."id" FROM "userNotification" ` +
  303. `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
  304. `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
  305. `INNER JOIN account ON account."actorId" = actor.id `
  306. ),
  307. // Remove notifications from muted accounts that commented something
  308. buildAccountWhereQuery(
  309. `SELECT "userNotification"."id" FROM "userNotification" ` +
  310. `INNER JOIN "actorFollow" ON "actorFollow".id = "userNotification"."actorFollowId" ` +
  311. `INNER JOIN actor ON actor.id = "actorFollow"."actorId" ` +
  312. `INNER JOIN account ON account."actorId" = actor.id `
  313. ),
  314. buildAccountWhereQuery(
  315. `SELECT "userNotification"."id" FROM "userNotification" ` +
  316. `INNER JOIN "videoComment" ON "videoComment".id = "userNotification"."commentId" ` +
  317. `INNER JOIN account ON account.id = "videoComment"."accountId" ` +
  318. `INNER JOIN actor ON "actor"."id" = "account"."actorId" `
  319. )
  320. ]
  321. const query = `DELETE FROM "userNotification" WHERE id IN (${queries.join(' UNION ')})`
  322. return UserNotificationModel.sequelize.query(query)
  323. }
  324. toFormattedJSON (this: UserNotificationModelForApi): UserNotification {
  325. const video = this.Video
  326. ? {
  327. ...this.formatVideo(this.Video),
  328. channel: this.formatActor(this.Video.VideoChannel)
  329. }
  330. : undefined
  331. const videoImport = this.VideoImport
  332. ? {
  333. id: this.VideoImport.id,
  334. video: this.VideoImport.Video
  335. ? this.formatVideo(this.VideoImport.Video)
  336. : undefined,
  337. torrentName: this.VideoImport.torrentName,
  338. magnetUri: this.VideoImport.magnetUri,
  339. targetUrl: this.VideoImport.targetUrl
  340. }
  341. : undefined
  342. const comment = this.VideoComment
  343. ? {
  344. id: this.VideoComment.id,
  345. threadId: this.VideoComment.getThreadId(),
  346. account: this.formatActor(this.VideoComment.Account),
  347. video: this.formatVideo(this.VideoComment.Video),
  348. heldForReview: this.VideoComment.heldForReview
  349. }
  350. : undefined
  351. const abuse = this.Abuse ? this.formatAbuse(this.Abuse) : undefined
  352. const videoBlacklist = this.VideoBlacklist
  353. ? {
  354. id: this.VideoBlacklist.id,
  355. video: this.formatVideo(this.VideoBlacklist.Video)
  356. }
  357. : undefined
  358. const account = this.Account ? this.formatActor(this.Account) : undefined
  359. const actorFollowingType = {
  360. Application: 'instance' as 'instance',
  361. Group: 'channel' as 'channel',
  362. Person: 'account' as 'account'
  363. }
  364. const actorFollow = this.ActorFollow
  365. ? {
  366. id: this.ActorFollow.id,
  367. state: this.ActorFollow.state,
  368. follower: {
  369. id: this.ActorFollow.ActorFollower.Account.id,
  370. displayName: this.ActorFollow.ActorFollower.Account.getDisplayName(),
  371. name: this.ActorFollow.ActorFollower.preferredUsername,
  372. host: this.ActorFollow.ActorFollower.getHost(),
  373. ...this.formatAvatars(this.ActorFollow.ActorFollower.Avatars)
  374. },
  375. following: {
  376. type: actorFollowingType[this.ActorFollow.ActorFollowing.type],
  377. displayName: (this.ActorFollow.ActorFollowing.VideoChannel || this.ActorFollow.ActorFollowing.Account).getDisplayName(),
  378. name: this.ActorFollow.ActorFollowing.preferredUsername,
  379. host: this.ActorFollow.ActorFollowing.getHost()
  380. }
  381. }
  382. : undefined
  383. const plugin = this.Plugin
  384. ? {
  385. name: this.Plugin.name,
  386. type: this.Plugin.type,
  387. latestVersion: this.Plugin.latestVersion
  388. }
  389. : undefined
  390. const peertube = this.Application
  391. ? { latestVersion: this.Application.latestPeerTubeVersion }
  392. : undefined
  393. const registration = this.UserRegistration
  394. ? { id: this.UserRegistration.id, username: this.UserRegistration.username }
  395. : undefined
  396. const videoCaption = this.VideoCaption
  397. ? {
  398. id: this.VideoCaption.id,
  399. language: {
  400. id: this.VideoCaption.language,
  401. label: VideoCaptionModel.getLanguageLabel(this.VideoCaption.language)
  402. },
  403. video: this.formatVideo(this.VideoCaption.Video)
  404. }
  405. : undefined
  406. return {
  407. id: this.id,
  408. type: this.type,
  409. read: this.read,
  410. video,
  411. videoImport,
  412. comment,
  413. abuse,
  414. videoBlacklist,
  415. account,
  416. actorFollow,
  417. plugin,
  418. peertube,
  419. registration,
  420. videoCaption,
  421. createdAt: this.createdAt.toISOString(),
  422. updatedAt: this.updatedAt.toISOString()
  423. }
  424. }
  425. formatVideo (video: UserNotificationIncludes.VideoInclude) {
  426. return {
  427. id: video.id,
  428. uuid: video.uuid,
  429. shortUUID: uuidToShort(video.uuid),
  430. name: video.name
  431. }
  432. }
  433. formatAbuse (abuse: UserNotificationIncludes.AbuseInclude) {
  434. const commentAbuse = abuse.VideoCommentAbuse?.VideoComment
  435. ? {
  436. threadId: abuse.VideoCommentAbuse.VideoComment.getThreadId(),
  437. video: abuse.VideoCommentAbuse.VideoComment.Video
  438. ? {
  439. id: abuse.VideoCommentAbuse.VideoComment.Video.id,
  440. name: abuse.VideoCommentAbuse.VideoComment.Video.name,
  441. shortUUID: uuidToShort(abuse.VideoCommentAbuse.VideoComment.Video.uuid),
  442. uuid: abuse.VideoCommentAbuse.VideoComment.Video.uuid
  443. }
  444. : undefined
  445. }
  446. : undefined
  447. const videoAbuse = abuse.VideoAbuse?.Video
  448. ? this.formatVideo(abuse.VideoAbuse.Video)
  449. : undefined
  450. const accountAbuse = (!commentAbuse && !videoAbuse && abuse.FlaggedAccount)
  451. ? this.formatActor(abuse.FlaggedAccount)
  452. : undefined
  453. return {
  454. id: abuse.id,
  455. state: abuse.state,
  456. video: videoAbuse,
  457. comment: commentAbuse,
  458. account: accountAbuse
  459. }
  460. }
  461. formatActor (
  462. accountOrChannel: UserNotificationIncludes.AccountIncludeActor | UserNotificationIncludes.VideoChannelIncludeActor
  463. ) {
  464. return {
  465. id: accountOrChannel.id,
  466. displayName: accountOrChannel.getDisplayName(),
  467. name: accountOrChannel.Actor.preferredUsername,
  468. host: accountOrChannel.Actor.getHost(),
  469. ...this.formatAvatars(accountOrChannel.Actor.Avatars)
  470. }
  471. }
  472. formatAvatars (avatars: UserNotificationIncludes.ActorImageInclude[]) {
  473. if (!avatars || avatars.length === 0) return { avatar: undefined, avatars: [] }
  474. return {
  475. avatar: this.formatAvatar(maxBy(avatars, 'width')),
  476. avatars: avatars.map(a => this.formatAvatar(a))
  477. }
  478. }
  479. formatAvatar (a: UserNotificationIncludes.ActorImageInclude) {
  480. return {
  481. path: a.getStaticPath(),
  482. width: a.width
  483. }
  484. }
  485. formatVideoCaption (a: UserNotificationIncludes.ActorImageInclude) {
  486. return {
  487. path: a.getStaticPath(),
  488. width: a.width
  489. }
  490. }
  491. }