mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(database): 🚚 Move Likes to our custom ORM
This commit is contained in:
parent
e52e230ce3
commit
5a26bdf2f8
|
|
@ -2,8 +2,6 @@ import { apiRoute, applyConfig, auth } from "@/api";
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Note } from "~/classes/database/note";
|
import { Note } from "~/classes/database/note";
|
||||||
import { createLike } from "~/classes/functions/like";
|
|
||||||
import { db } from "~/drizzle/db";
|
|
||||||
import { RolePermissions } from "~/drizzle/schema";
|
import { RolePermissions } from "~/drizzle/schema";
|
||||||
import { ErrorSchema } from "~/types/api";
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
|
|
@ -79,21 +77,10 @@ export default apiRoute((app) =>
|
||||||
return context.json({ error: "Record not found" }, 404);
|
return context.json({ error: "Record not found" }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingLike = await db.query.Likes.findFirst({
|
await user.like(note);
|
||||||
where: (like, { and, eq }) =>
|
|
||||||
and(eq(like.likedId, note.data.id), eq(like.likerId, user.id)),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!existingLike) {
|
await note.reload(user.id);
|
||||||
await createLike(user, note);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newNote = await Note.fromId(id, user.id);
|
return context.json(await note.toApi(user), 200);
|
||||||
|
|
||||||
if (!newNote) {
|
|
||||||
return context.json({ error: "Record not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(await newNote.toApi(user), 200);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { apiRoute, applyConfig, auth } from "@/api";
|
||||||
import { createRoute } from "@hono/zod-openapi";
|
import { createRoute } from "@hono/zod-openapi";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Note } from "~/classes/database/note";
|
import { Note } from "~/classes/database/note";
|
||||||
import { deleteLike } from "~/classes/functions/like";
|
|
||||||
import { RolePermissions } from "~/drizzle/schema";
|
import { RolePermissions } from "~/drizzle/schema";
|
||||||
import { ErrorSchema } from "~/types/api";
|
import { ErrorSchema } from "~/types/api";
|
||||||
|
|
||||||
|
|
@ -77,14 +76,10 @@ export default apiRoute((app) =>
|
||||||
return context.json({ error: "Record not found" }, 404);
|
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(await note.toApi(user), 200);
|
||||||
return context.json({ error: "Record not found" }, 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return context.json(await newNote.toApi(user), 200);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@ import {
|
||||||
} from "@versia/federation/schemas";
|
} from "@versia/federation/schemas";
|
||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { Like } from "~/classes/database/like";
|
||||||
import { Note } from "~/classes/database/note";
|
import { Note } from "~/classes/database/note";
|
||||||
import { User } from "~/classes/database/user";
|
import { User } from "~/classes/database/user";
|
||||||
import { type LikeType, likeToVersia } from "~/classes/functions/like";
|
import { Likes, Notes } from "~/drizzle/schema";
|
||||||
import { db } from "~/drizzle/db";
|
|
||||||
import { Notes } from "~/drizzle/schema";
|
|
||||||
import { config } from "~/packages/config-manager";
|
import { config } from "~/packages/config-manager";
|
||||||
import { ErrorSchema, type KnownEntity } from "~/types/api";
|
import { ErrorSchema, type KnownEntity } from "~/types/api";
|
||||||
|
|
||||||
|
|
@ -70,7 +69,7 @@ export default apiRoute((app) =>
|
||||||
app.openapi(route, async (context) => {
|
app.openapi(route, async (context) => {
|
||||||
const { id } = context.req.valid("param");
|
const { id } = context.req.valid("param");
|
||||||
|
|
||||||
let foundObject: Note | LikeType | null = null;
|
let foundObject: Note | Like | null = null;
|
||||||
let foundAuthor: User | null = null;
|
let foundAuthor: User | null = null;
|
||||||
let apiObject: KnownEntity | null = null;
|
let apiObject: KnownEntity | null = null;
|
||||||
|
|
||||||
|
|
@ -88,17 +87,15 @@ export default apiRoute((app) =>
|
||||||
return context.json({ error: "Object not found" }, 404);
|
return context.json({ error: "Object not found" }, 404);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
foundObject =
|
foundObject = await Like.fromSql(
|
||||||
(await db.query.Likes.findFirst({
|
|
||||||
where: (like, { eq, and }) =>
|
|
||||||
and(
|
and(
|
||||||
eq(like.id, id),
|
eq(Likes.id, id),
|
||||||
sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${like.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`,
|
sql`EXISTS (SELECT 1 FROM "Notes" WHERE "Notes"."id" = ${Likes.likedId} AND "Notes"."visibility" IN ('public', 'unlisted'))`,
|
||||||
),
|
),
|
||||||
})) ?? null;
|
);
|
||||||
apiObject = foundObject ? likeToVersia(foundObject) : null;
|
apiObject = foundObject ? foundObject.toVersia() : null;
|
||||||
foundAuthor = foundObject
|
foundAuthor = foundObject
|
||||||
? await User.fromId(foundObject.likerId)
|
? await User.fromId(foundObject.data.likerId)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
163
classes/database/like.ts
Normal file
163
classes/database/like.ts
Normal file
|
|
@ -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<typeof Likes> & {
|
||||||
|
liker: UserType;
|
||||||
|
liked: Status;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Like extends BaseInterface<typeof Likes, LikeType> {
|
||||||
|
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<void> {
|
||||||
|
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<Like | null> {
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Like.fromSql(eq(Likes.id, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async fromIds(ids: string[]): Promise<Like[]> {
|
||||||
|
return await Like.manyFromSql(inArray(Likes.id, ids));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async fromSql(
|
||||||
|
sql: SQL<unknown> | undefined,
|
||||||
|
orderBy: SQL<unknown> | 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<unknown> | undefined,
|
||||||
|
orderBy: SQL<unknown> | undefined = desc(Likes.id),
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
extra?: Parameters<typeof db.query.Likes.findMany>[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<LikeType>): Promise<LikeType> {
|
||||||
|
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<LikeType> {
|
||||||
|
return this.update(this.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(ids?: string[]): Promise<void> {
|
||||||
|
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<typeof Likes>,
|
||||||
|
): Promise<Like> {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -147,8 +147,11 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
|
||||||
return this.update(this.data);
|
return this.update(this.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async reload(): Promise<void> {
|
/**
|
||||||
const reloaded = await Note.fromId(this.data.id);
|
* @param userRequestingNoteId Used to calculate visibility of the note
|
||||||
|
*/
|
||||||
|
async reload(userRequestingNoteId?: string): Promise<void> {
|
||||||
|
const reloaded = await Note.fromId(this.data.id, userRequestingNoteId);
|
||||||
|
|
||||||
if (!reloaded) {
|
if (!reloaded) {
|
||||||
throw new Error("Failed to reload status");
|
throw new Error("Failed to reload status");
|
||||||
|
|
@ -475,7 +478,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await newNote.reload();
|
await newNote.reload(data.author.id);
|
||||||
|
|
||||||
return newNote;
|
return newNote;
|
||||||
}
|
}
|
||||||
|
|
@ -557,7 +560,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
|
||||||
// Set attachment parents
|
// Set attachment parents
|
||||||
await this.recalculateDatabaseAttachments(data.mediaAttachments ?? []);
|
await this.recalculateDatabaseAttachments(data.mediaAttachments ?? []);
|
||||||
|
|
||||||
await this.reload();
|
await this.reload(data.author.id);
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ import { searchManager } from "~/classes/search/search-manager";
|
||||||
import { db } from "~/drizzle/db";
|
import { db } from "~/drizzle/db";
|
||||||
import {
|
import {
|
||||||
EmojiToUser,
|
EmojiToUser,
|
||||||
|
Likes,
|
||||||
NoteToMentions,
|
NoteToMentions,
|
||||||
Notes,
|
Notes,
|
||||||
Notifications,
|
Notifications,
|
||||||
|
|
@ -56,6 +57,7 @@ import type { KnownEntity } from "~/types/api.ts";
|
||||||
import { BaseInterface } from "./base.ts";
|
import { BaseInterface } from "./base.ts";
|
||||||
import { Emoji } from "./emoji.ts";
|
import { Emoji } from "./emoji.ts";
|
||||||
import { Instance } from "./instance.ts";
|
import { Instance } from "./instance.ts";
|
||||||
|
import { Like } from "./like.ts";
|
||||||
import type { Note } from "./note.ts";
|
import type { Note } from "./note.ts";
|
||||||
import { Relationship } from "./relationship.ts";
|
import { Relationship } from "./relationship.ts";
|
||||||
import { Role } from "./role.ts";
|
import { Role } from "./role.ts";
|
||||||
|
|
@ -440,6 +442,79 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
.filter((x) => x !== null);
|
.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<Like> {
|
||||||
|
// 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<void> {
|
||||||
|
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<User> {
|
async updateFromRemote(): Promise<User> {
|
||||||
if (!this.isRemote()) {
|
if (!this.isRemote()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
|
||||||
|
|
@ -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<typeof Likes>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Loading…
Reference in a new issue