feat: clip favorite

Resolve #10337
This commit is contained in:
syuilo
2023-03-16 17:24:49 +09:00
parent 8ae9d2eaa8
commit b644567735
25 changed files with 403 additions and 15 deletions

View File

@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { ClipsRepository } from '@/models/index.js';
import type { ClipFavoritesRepository, ClipsRepository, User } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/entities/Blocking.js';
@ -14,6 +14,9 @@ export class ClipEntityService {
@Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository,
@Inject(DI.clipFavoritesRepository)
private clipFavoritesRepository: ClipFavoritesRepository,
private userEntityService: UserEntityService,
) {
}
@ -21,25 +24,31 @@ export class ClipEntityService {
@bindThis
public async pack(
src: Clip['id'] | Clip,
me?: { id: User['id'] } | null | undefined,
): Promise<Packed<'Clip'>> {
const meId = me ? me.id : null;
const clip = typeof src === 'object' ? src : await this.clipsRepository.findOneByOrFail({ id: src });
return await awaitAll({
id: clip.id,
createdAt: clip.createdAt.toISOString(),
lastClippedAt: clip.lastClippedAt ? clip.lastClippedAt.toISOString() : null,
userId: clip.userId,
user: this.userEntityService.pack(clip.user ?? clip.userId),
name: clip.name,
description: clip.description,
isPublic: clip.isPublic,
favoritedCount: await this.clipFavoritesRepository.countBy({ clipId: clip.id }),
isFavorited: meId ? await this.clipFavoritesRepository.findOneBy({ clipId: clip.id, userId: meId }).then(x => x != null) : undefined,
});
}
@bindThis
public packMany(
clips: Clip[],
me?: { id: User['id'] } | null | undefined,
) {
return Promise.all(clips.map(x => this.pack(x)));
return Promise.all(clips.map(x => this.pack(x, me)));
}
}

View File

@ -52,6 +52,7 @@ export const DI = {
moderationLogsRepository: Symbol('moderationLogsRepository'),
clipsRepository: Symbol('clipsRepository'),
clipNotesRepository: Symbol('clipNotesRepository'),
clipFavoritesRepository: Symbol('clipFavoritesRepository'),
antennasRepository: Symbol('antennasRepository'),
antennaNotesRepository: Symbol('antennaNotesRepository'),
promoNotesRepository: Symbol('promoNotesRepository'),

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js';
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@ -286,6 +286,12 @@ const $clipNotesRepository: Provider = {
inject: [DI.db],
};
const $clipFavoritesRepository: Provider = {
provide: DI.clipFavoritesRepository,
useFactory: (db: DataSource) => db.getRepository(ClipFavorite),
inject: [DI.db],
};
const $antennasRepository: Provider = {
provide: DI.antennasRepository,
useFactory: (db: DataSource) => db.getRepository(Antenna),
@ -445,6 +451,7 @@ const $roleAssignmentsRepository: Provider = {
$moderationLogsRepository,
$clipsRepository,
$clipNotesRepository,
$clipFavoritesRepository,
$antennasRepository,
$antennaNotesRepository,
$promoNotesRepository,
@ -512,6 +519,7 @@ const $roleAssignmentsRepository: Provider = {
$moderationLogsRepository,
$clipsRepository,
$clipNotesRepository,
$clipFavoritesRepository,
$antennasRepository,
$antennaNotesRepository,
$promoNotesRepository,

View File

@ -12,6 +12,12 @@ export class Clip {
})
public createdAt: Date;
@Index()
@Column('timestamp with time zone', {
nullable: true,
})
public lastClippedAt: Date | null;
@Index()
@Column({
...id(),

View File

@ -0,0 +1,33 @@
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from '../id.js';
import { User } from './User.js';
import { Clip } from './Clip.js';
@Entity()
@Index(['userId', 'clipId'], { unique: true })
export class ClipFavorite {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone')
public createdAt: Date;
@Index()
@Column(id())
public userId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: User | null;
@Column(id())
public clipId: Clip['id'];
@ManyToOne(type => Clip, {
onDelete: 'CASCADE',
})
@JoinColumn()
public clip: Clip | null;
}

View File

@ -13,6 +13,7 @@ import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js';
import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js';
import { Clip } from '@/models/entities/Clip.js';
import { ClipNote } from '@/models/entities/ClipNote.js';
import { ClipFavorite } from '@/models/entities/ClipFavorite.js';
import { DriveFile } from '@/models/entities/DriveFile.js';
import { DriveFolder } from '@/models/entities/DriveFolder.js';
import { Emoji } from '@/models/entities/Emoji.js';
@ -81,6 +82,7 @@ export {
ChannelNotePining,
Clip,
ClipNote,
ClipFavorite,
DriveFile,
DriveFolder,
Emoji,
@ -148,6 +150,7 @@ export type ChannelFollowingsRepository = Repository<ChannelFollowing>;
export type ChannelNotePiningsRepository = Repository<ChannelNotePining>;
export type ClipsRepository = Repository<Clip>;
export type ClipNotesRepository = Repository<ClipNote>;
export type ClipFavoritesRepository = Repository<ClipFavorite>;
export type DriveFilesRepository = Repository<DriveFile>;
export type DriveFoldersRepository = Repository<DriveFolder>;
export type EmojisRepository = Repository<Emoji>;

View File

@ -12,6 +12,11 @@ export const packedClipSchema = {
optional: false, nullable: false,
format: 'date-time',
},
lastClippedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
userId: {
type: 'string',
optional: false, nullable: false,
@ -34,5 +39,13 @@ export const packedClipSchema = {
type: 'boolean',
optional: false, nullable: false,
},
isFavorited: {
type: 'boolean',
optional: true, nullable: false,
},
favoritedCount: {
type: 'number',
optional: false, nullable: false,
},
},
} as const;

View File

@ -21,6 +21,7 @@ import { ChannelFollowing } from '@/models/entities/ChannelFollowing.js';
import { ChannelNotePining } from '@/models/entities/ChannelNotePining.js';
import { Clip } from '@/models/entities/Clip.js';
import { ClipNote } from '@/models/entities/ClipNote.js';
import { ClipFavorite } from '@/models/entities/ClipFavorite.js';
import { DriveFile } from '@/models/entities/DriveFile.js';
import { DriveFolder } from '@/models/entities/DriveFolder.js';
import { Emoji } from '@/models/entities/Emoji.js';
@ -165,6 +166,7 @@ export const entities = [
ModerationLog,
Clip,
ClipNote,
ClipFavorite,
Antenna,
AntennaNote,
PromoNote,

View File

@ -114,6 +114,9 @@ import * as ep___clips_list from './endpoints/clips/list.js';
import * as ep___clips_notes from './endpoints/clips/notes.js';
import * as ep___clips_show from './endpoints/clips/show.js';
import * as ep___clips_update from './endpoints/clips/update.js';
import * as ep___clips_favorite from './endpoints/clips/favorite.js';
import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js';
import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js';
import * as ep___drive from './endpoints/drive.js';
import * as ep___drive_files from './endpoints/drive/files.js';
import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js';
@ -438,6 +441,9 @@ const $clips_list: Provider = { provide: 'ep:clips/list', useClass: ep___clips_l
const $clips_notes: Provider = { provide: 'ep:clips/notes', useClass: ep___clips_notes.default };
const $clips_show: Provider = { provide: 'ep:clips/show', useClass: ep___clips_show.default };
const $clips_update: Provider = { provide: 'ep:clips/update', useClass: ep___clips_update.default };
const $clips_favorite: Provider = { provide: 'ep:clips/favorite', useClass: ep___clips_favorite.default };
const $clips_unfavorite: Provider = { provide: 'ep:clips/unfavorite', useClass: ep___clips_unfavorite.default };
const $clips_myFavorites: Provider = { provide: 'ep:clips/my-favorites', useClass: ep___clips_myFavorites.default };
const $drive: Provider = { provide: 'ep:drive', useClass: ep___drive.default };
const $drive_files: Provider = { provide: 'ep:drive/files', useClass: ep___drive_files.default };
const $drive_files_attachedNotes: Provider = { provide: 'ep:drive/files/attached-notes', useClass: ep___drive_files_attachedNotes.default };
@ -766,6 +772,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$clips_notes,
$clips_show,
$clips_update,
$clips_favorite,
$clips_unfavorite,
$clips_myFavorites,
$drive,
$drive_files,
$drive_files_attachedNotes,
@ -1088,6 +1097,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$clips_notes,
$clips_show,
$clips_update,
$clips_favorite,
$clips_unfavorite,
$clips_myFavorites,
$drive,
$drive_files,
$drive_files_attachedNotes,

View File

@ -114,6 +114,9 @@ import * as ep___clips_list from './endpoints/clips/list.js';
import * as ep___clips_notes from './endpoints/clips/notes.js';
import * as ep___clips_show from './endpoints/clips/show.js';
import * as ep___clips_update from './endpoints/clips/update.js';
import * as ep___clips_favorite from './endpoints/clips/favorite.js';
import * as ep___clips_unfavorite from './endpoints/clips/unfavorite.js';
import * as ep___clips_myFavorites from './endpoints/clips/my-favorites.js';
import * as ep___drive from './endpoints/drive.js';
import * as ep___drive_files from './endpoints/drive/files.js';
import * as ep___drive_files_attachedNotes from './endpoints/drive/files/attached-notes.js';
@ -436,6 +439,9 @@ const eps = [
['clips/notes', ep___clips_notes],
['clips/show', ep___clips_show],
['clips/update', ep___clips_update],
['clips/favorite', ep___clips_favorite],
['clips/unfavorite', ep___clips_unfavorite],
['clips/my-favorites', ep___clips_myFavorites],
['drive', ep___drive],
['drive/files', ep___drive_files],
['drive/files/attached-notes', ep___drive_files_attachedNotes],

View File

@ -106,6 +106,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
noteId: note.id,
clipId: clip.id,
});
await this.clipsRepository.update(clip.id, {
lastClippedAt: new Date(),
});
});
}
}

View File

@ -67,7 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
description: ps.description,
}).then(x => this.clipsRepository.findOneByOrFail(x.identifiers[0]));
return await this.clipEntityService.pack(clip);
return await this.clipEntityService.pack(clip, me);
});
}
}

View File

@ -0,0 +1,76 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ClipsRepository, ClipFavoritesRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['clip'],
requireCredential: true,
kind: 'write:clip-favorite',
errors: {
noSuchClip: {
message: 'No such clip.',
code: 'NO_SUCH_CLIP',
id: '4c2aaeae-80d8-4250-9606-26cb1fdb77a5',
},
alreadyFavorited: {
message: 'The clip has already been favorited.',
code: 'ALREADY_FAVORITED',
id: '92658936-c625-4273-8326-2d790129256e',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
clipId: { type: 'string', format: 'misskey:id' },
},
required: ['clipId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository,
@Inject(DI.clipFavoritesRepository)
private clipFavoritesRepository: ClipFavoritesRepository,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const clip = await this.clipsRepository.findOneBy({ id: ps.clipId });
if (clip == null) {
throw new ApiError(meta.errors.noSuchClip);
}
if ((clip.userId !== me.id) && !clip.isPublic) {
throw new ApiError(meta.errors.noSuchClip);
}
const exist = await this.clipFavoritesRepository.findOneBy({
clipId: clip.id,
userId: me.id,
});
if (exist != null) {
throw new ApiError(meta.errors.alreadyFavorited);
}
await this.clipFavoritesRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
clipId: clip.id,
userId: me.id,
});
});
}
}

View File

@ -42,7 +42,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
userId: me.id,
});
return await Promise.all(clips.map(x => this.clipEntityService.pack(x)));
return await this.clipEntityService.packMany(clips, me);
});
}
}

View File

@ -0,0 +1,52 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { ClipFavoritesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
export const meta = {
tags: ['account', 'clip'],
requireCredential: true,
kind: 'read:clip-favorite',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'Clip',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.clipFavoritesRepository)
private clipFavoritesRepository: ClipFavoritesRepository,
private clipEntityService: ClipEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.clipFavoritesRepository.createQueryBuilder('favorite')
.andWhere('favorite.userId = :meId', { meId: me.id })
.leftJoinAndSelect('favorite.clip', 'clip');
const favorites = await query
.getMany();
return this.clipEntityService.packMany(favorites.map(x => x.clip!), me);
});
}
}

View File

@ -58,7 +58,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchClip);
}
return await this.clipEntityService.pack(clip);
return await this.clipEntityService.pack(clip, me);
});
}
}

View File

@ -0,0 +1,65 @@
import { Inject, Injectable } from '@nestjs/common';
import type { ClipsRepository, ClipFavoritesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['clip'],
requireCredential: true,
kind: 'write:clip-favorite',
errors: {
noSuchClip: {
message: 'No such clip.',
code: 'NO_SUCH_CLIP',
id: '2603966e-b865-426c-94a7-af4a01241dc1',
},
notFavorited: {
message: 'You have not favorited the clip.',
code: 'NOT_FAVORITED',
id: '90c3a9e8-b321-4dae-bf57-2bf79bbcc187',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
clipId: { type: 'string', format: 'misskey:id' },
},
required: ['clipId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.clipsRepository)
private clipsRepository: ClipsRepository,
@Inject(DI.clipFavoritesRepository)
private clipFavoritesRepository: ClipFavoritesRepository,
) {
super(meta, paramDef, async (ps, me) => {
const clip = await this.clipsRepository.findOneBy({ id: ps.clipId });
if (clip == null) {
throw new ApiError(meta.errors.noSuchClip);
}
const exist = await this.clipFavoritesRepository.findOneBy({
clipId: clip.id,
userId: me.id,
});
if (exist == null) {
throw new ApiError(meta.errors.notFavorited);
}
await this.clipFavoritesRepository.delete(exist.id);
});
}
}

View File

@ -64,7 +64,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
isPublic: ps.isPublic,
});
return await this.clipEntityService.pack(clip.id);
return await this.clipEntityService.pack(clip.id, me);
});
}
}

View File

@ -4,8 +4,8 @@ import type { ClipNotesRepository, ClipsRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ClipEntityService } from '@/core/entities/ClipEntityService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['clips', 'notes'],
@ -67,7 +67,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
isPublic: true,
});
return await Promise.all(clips.map(x => this.clipEntityService.pack(x)));
return await this.clipEntityService.packMany(clips, me);
});
}
}

View File

@ -51,7 +51,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.take(ps.limit)
.getMany();
return await this.clipEntityService.packMany(clips);
return await this.clipEntityService.packMany(clips, me);
});
}
}