diff --git a/api/api/v1/statuses/:id/favourite.ts b/api/api/v1/statuses/:id/favourite.ts index bfe3ca1f..9e0c2be2 100644 --- a/api/api/v1/statuses/:id/favourite.ts +++ b/api/api/v1/statuses/:id/favourite.ts @@ -2,8 +2,6 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { z } from "zod"; import { Note } from "~/classes/database/note"; -import { createLike } from "~/classes/functions/like"; -import { db } from "~/drizzle/db"; import { RolePermissions } from "~/drizzle/schema"; import { ErrorSchema } from "~/types/api"; @@ -79,21 +77,10 @@ export default apiRoute((app) => return context.json({ error: "Record not found" }, 404); } - const existingLike = await db.query.Likes.findFirst({ - where: (like, { and, eq }) => - and(eq(like.likedId, note.data.id), eq(like.likerId, user.id)), - }); + await user.like(note); - if (!existingLike) { - await createLike(user, note); - } + await note.reload(user.id); - const newNote = await Note.fromId(id, user.id); - - if (!newNote) { - return context.json({ error: "Record not found" }, 404); - } - - return context.json(await newNote.toApi(user), 200); + return context.json(await note.toApi(user), 200); }), ); diff --git a/api/api/v1/statuses/:id/unfavourite.ts b/api/api/v1/statuses/:id/unfavourite.ts index 313f821d..f1bcd1a7 100644 --- a/api/api/v1/statuses/:id/unfavourite.ts +++ b/api/api/v1/statuses/:id/unfavourite.ts @@ -2,7 +2,6 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; import { z } from "zod"; import { Note } from "~/classes/database/note"; -import { deleteLike } from "~/classes/functions/like"; import { RolePermissions } from "~/drizzle/schema"; import { ErrorSchema } from "~/types/api"; @@ -77,14 +76,10 @@ export default apiRoute((app) => return context.json({ error: "Record not found" }, 404); } - await deleteLike(user, note); + await user.unlike(note); - const newNote = await Note.fromId(id, user.id); + await note.reload(user.id); - if (!newNote) { - return context.json({ error: "Record not found" }, 404); - } - - return context.json(await newNote.toApi(user), 200); + return context.json(await note.toApi(user), 200); }), ); diff --git a/api/objects/:id/index.ts b/api/objects/:id/index.ts index a7fd49ab..a852a758 100644 --- a/api/objects/:id/index.ts +++ b/api/objects/:id/index.ts @@ -6,11 +6,10 @@ import { } from "@versia/federation/schemas"; import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; +import { Like } from "~/classes/database/like"; import { Note } from "~/classes/database/note"; import { User } from "~/classes/database/user"; -import { type LikeType, likeToVersia } from "~/classes/functions/like"; -import { db } from "~/drizzle/db"; -import { Notes } from "~/drizzle/schema"; +import { Likes, Notes } from "~/drizzle/schema"; import { config } from "~/packages/config-manager"; import { ErrorSchema, type KnownEntity } from "~/types/api"; @@ -70,7 +69,7 @@ export default apiRoute((app) => app.openapi(route, async (context) => { const { id } = context.req.valid("param"); - let foundObject: Note | LikeType | null = null; + let foundObject: Note | Like | null = null; let foundAuthor: User | null = null; let apiObject: KnownEntity | null = null; @@ -88,17 +87,15 @@ export default apiRoute((app) => return context.json({ error: "Object not found" }, 404); } } else { - foundObject = - (await db.query.Likes.findFirst({ - where: (like, { eq, and }) => - and( - eq(like.id, id), - sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${like.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`, - ), - })) ?? null; - apiObject = foundObject ? likeToVersia(foundObject) : null; + foundObject = await Like.fromSql( + and( + eq(Likes.id, id), + sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${Likes.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`, + ), + ); + apiObject = foundObject ? foundObject.toVersia() : null; foundAuthor = foundObject - ? await User.fromId(foundObject.likerId) + ? await User.fromId(foundObject.data.likerId) : null; } diff --git a/classes/database/like.ts b/classes/database/like.ts new file mode 100644 index 00000000..631c070a --- /dev/null +++ b/classes/database/like.ts @@ -0,0 +1,163 @@ +import { RolePermission } from "@versia/client/types"; +import type { LikeExtension } from "@versia/federation"; +import { + type InferInsertModel, + type InferSelectModel, + type SQL, + desc, + eq, + inArray, +} from "drizzle-orm"; +import { z } from "zod"; +import { db } from "~/drizzle/db"; +import { Likes } from "~/drizzle/schema"; +import { config } from "~/packages/config-manager/index.ts"; +import type { Status } from "../functions/status.ts"; +import type { UserType } from "../functions/user.ts"; +import { BaseInterface } from "./base.ts"; +import { Note } from "./note.ts"; +import { User } from "./user.ts"; + +export type LikeType = InferSelectModel & { + liker: UserType; + liked: Status; +}; + +export class Like extends BaseInterface { + static schema = z.object({ + id: z.string(), + name: z.string(), + permissions: z.array(z.nativeEnum(RolePermission)), + priority: z.number(), + description: z.string().nullable(), + visible: z.boolean(), + icon: z.string().nullable(), + }); + + async reload(): Promise { + const reloaded = await Like.fromId(this.data.id); + + if (!reloaded) { + throw new Error("Failed to reload like"); + } + + this.data = reloaded.data; + } + + public static async fromId(id: string | null): Promise { + if (!id) { + return null; + } + + return await Like.fromSql(eq(Likes.id, id)); + } + + public static async fromIds(ids: string[]): Promise { + return await Like.manyFromSql(inArray(Likes.id, ids)); + } + + public static async fromSql( + sql: SQL | undefined, + orderBy: SQL | undefined = desc(Likes.id), + ) { + const found = await db.query.Likes.findFirst({ + where: sql, + orderBy, + with: { + liked: true, + liker: true, + }, + }); + + if (!found) { + return null; + } + return new Like(found); + } + + public static async manyFromSql( + sql: SQL | undefined, + orderBy: SQL | undefined = desc(Likes.id), + limit?: number, + offset?: number, + extra?: Parameters[0], + ) { + const found = await db.query.Likes.findMany({ + where: sql, + orderBy, + limit, + offset, + with: { + liked: true, + liker: true, + ...extra?.with, + }, + }); + + return found.map((s) => new Like(s)); + } + + async update(newRole: Partial): Promise { + await db.update(Likes).set(newRole).where(eq(Likes.id, this.id)); + + const updated = await Like.fromId(this.data.id); + + if (!updated) { + throw new Error("Failed to update like"); + } + + return updated.data; + } + + save(): Promise { + return this.update(this.data); + } + + async delete(ids?: string[]): Promise { + if (Array.isArray(ids)) { + await db.delete(Likes).where(inArray(Likes.id, ids)); + } else { + await db.delete(Likes).where(eq(Likes.id, this.id)); + } + } + + public static async insert( + data: InferInsertModel, + ): Promise { + const inserted = (await db.insert(Likes).values(data).returning())[0]; + + const role = await Like.fromId(inserted.id); + + if (!role) { + throw new Error("Failed to insert like"); + } + + return role; + } + + get id() { + return this.data.id; + } + + public getUri(): URL { + return new URL(`/objects/${this.data.id}`, config.http.base_url); + } + + public toVersia(): LikeExtension { + return { + id: this.data.id, + author: User.getUri( + this.data.liker.id, + this.data.liker.uri, + config.http.base_url, + ), + type: "pub.versia:likes/Like", + created_at: new Date(this.data.createdAt).toISOString(), + liked: Note.getUri( + this.data.liked.id, + this.data.liked.uri, + ) as string, + uri: this.getUri().toString(), + }; + } +} diff --git a/classes/database/note.ts b/classes/database/note.ts index d400168f..4ff62963 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -147,8 +147,11 @@ export class Note extends BaseInterface { return this.update(this.data); } - async reload(): Promise { - const reloaded = await Note.fromId(this.data.id); + /** + * @param userRequestingNoteId Used to calculate visibility of the note + */ + async reload(userRequestingNoteId?: string): Promise { + const reloaded = await Note.fromId(this.data.id, userRequestingNoteId); if (!reloaded) { throw new Error("Failed to reload status"); @@ -475,7 +478,7 @@ export class Note extends BaseInterface { } } - await newNote.reload(); + await newNote.reload(data.author.id); return newNote; } @@ -557,7 +560,7 @@ export class Note extends BaseInterface { // Set attachment parents await this.recalculateDatabaseAttachments(data.mediaAttachments ?? []); - await this.reload(); + await this.reload(data.author.id); return this; } diff --git a/classes/database/user.ts b/classes/database/user.ts index b6bbd971..70a9f91c 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -44,6 +44,7 @@ import { searchManager } from "~/classes/search/search-manager"; import { db } from "~/drizzle/db"; import { EmojiToUser, + Likes, NoteToMentions, Notes, Notifications, @@ -56,6 +57,7 @@ import type { KnownEntity } from "~/types/api.ts"; import { BaseInterface } from "./base.ts"; import { Emoji } from "./emoji.ts"; import { Instance } from "./instance.ts"; +import { Like } from "./like.ts"; import type { Note } from "./note.ts"; import { Relationship } from "./relationship.ts"; import { Role } from "./role.ts"; @@ -440,6 +442,79 @@ export class User extends BaseInterface { .filter((x) => x !== null); } + /** + * Like a note. + * + * If the note is already liked, it will return the existing like. Also creates a notification for the author of the note. + * @param note The note to like + * @returns The like object created or the existing like + */ + public async like(note: Note): Promise { + // Check if the user has already liked the note + const existingLike = await Like.fromSql( + and(eq(Likes.likerId, this.id), eq(Likes.likedId, note.id)), + ); + + if (existingLike) { + return existingLike; + } + + const newLike = await Like.insert({ + likerId: this.id, + likedId: note.id, + }); + + if (note.author.data.instanceId === this.data.instanceId) { + // Notify the user that their post has been favourited + await db.insert(Notifications).values({ + accountId: this.id, + type: "favourite", + notifiedId: note.author.id, + noteId: note.id, + }); + } else { + // TODO: Add database jobs for federating this + } + + return newLike; + } + + /** + * Unlike a note. + * + * If the note is not liked, it will return without doing anything. Also removes any notifications for this like. + * @param note The note to unlike + * @returns + */ + public async unlike(note: Note): Promise { + const likeToDelete = await Like.fromSql( + and(eq(Likes.likerId, this.id), eq(Likes.likedId, note.id)), + ); + + if (!likeToDelete) { + return; + } + + await likeToDelete.delete(); + + // Remove any eventual notifications for this like + await db + .delete(Notifications) + .where( + and( + eq(Notifications.accountId, this.id), + eq(Notifications.type, "favourite"), + eq(Notifications.notifiedId, note.author.id), + eq(Notifications.noteId, note.id), + ), + ); + + if (this.isLocal() && note.author.isRemote()) { + // User is local, federate the delete + // TODO: Federate this + } + } + async updateFromRemote(): Promise { if (!this.isRemote()) { throw new Error( diff --git a/classes/functions/like.ts b/classes/functions/like.ts deleted file mode 100644 index e793a13b..00000000 --- a/classes/functions/like.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { LikeExtension } from "@versia/federation/types"; -import { type InferSelectModel, and, eq } from "drizzle-orm"; -import type { Note } from "~/classes/database/note"; -import type { User } from "~/classes/database/user"; -import { db } from "~/drizzle/db"; -import { Likes, Notifications } from "~/drizzle/schema"; -import { config } from "~/packages/config-manager/index.ts"; - -export type LikeType = InferSelectModel; - -/** - * Represents a Like entity in the database. - */ -export const likeToVersia = (like: LikeType): LikeExtension => { - return { - id: like.id, - // biome-ignore lint/suspicious/noExplicitAny: to be rewritten - author: (like as any).liker?.uri, - type: "pub.versia:likes/Like", - created_at: new Date(like.createdAt).toISOString(), - // biome-ignore lint/suspicious/noExplicitAny: to be rewritten - liked: (like as any).liked?.uri, - uri: new URL(`/objects/${like.id}`, config.http.base_url).toString(), - }; -}; - -/** - * Create a like - * @param user User liking the status - * @param note Status being liked - */ -export const createLike = async (user: User, note: Note) => { - await db.insert(Likes).values({ - likedId: note.id, - likerId: user.id, - }); - - if (note.author.data.instanceId === user.data.instanceId) { - // Notify the user that their post has been favourited - await db.insert(Notifications).values({ - accountId: user.id, - type: "favourite", - notifiedId: note.author.id, - noteId: note.id, - }); - } else { - // TODO: Add database jobs for federating this - } -}; - -/** - * Delete a like - * @param user User deleting their like - * @param note Status being unliked - */ -export const deleteLike = async (user: User, note: Note) => { - await db - .delete(Likes) - .where(and(eq(Likes.likedId, note.id), eq(Likes.likerId, user.id))); - - // Notify the user that their post has been favourited - await db - .delete(Notifications) - .where( - and( - eq(Notifications.accountId, user.id), - eq(Notifications.type, "favourite"), - eq(Notifications.notifiedId, note.author.id), - eq(Notifications.noteId, note.id), - ), - ); - - if (user.isLocal() && note.author.isRemote()) { - // User is local, federate the delete - // TODO: Federate this - } -};