diff --git a/biome.json b/biome.json index 94fc81b1..36d9da97 100644 --- a/biome.json +++ b/biome.json @@ -2,37 +2,19 @@ "$schema": "https://biomejs.dev/schemas/1.6.4/schema.json", "organizeImports": { "enabled": true, - "ignore": [ - "node_modules/**/*", - "dist/**/*", - "packages/frontend/.output", - "packages/frontend/.nuxt", - "glitch" - ] + "ignore": ["node_modules", "dist", "glitch", "glitch-dev"] }, "linter": { "enabled": true, "rules": { "recommended": true }, - "ignore": [ - "node_modules/**/*", - "dist/**/*", - "packages/frontend/.output", - "packages/frontend/.nuxt", - "glitch" - ] + "ignore": ["node_modules", "dist", "glitch", "glitch-dev"] }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 4, - "ignore": [ - "node_modules/**/*", - "dist/**/*", - "packages/frontend/.output", - "packages/frontend/.nuxt", - "glitch" - ] + "ignore": ["node_modules", "dist", "glitch", "glitch-dev"] } } diff --git a/bun.lockb b/bun.lockb index 0280bdd6..56010d07 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli.ts b/cli.ts index fc9bc5d4..ca28c45c 100644 --- a/cli.ts +++ b/cli.ts @@ -8,28 +8,20 @@ import { CliBuilder, CliCommand } from "cli-parser"; import { CliParameterType } from "cli-parser/cli-builder.type"; import Table from "cli-table"; import { config } from "config-manager"; -import { - type SQL, - eq, - inArray, - isNotNull, - isNull, - like, - sql, -} from "drizzle-orm"; +import { type SQL, and, eq, inArray, like, or, sql } from "drizzle-orm"; import extract from "extract-zip"; import { MediaBackend } from "media-manager"; import { lookup } from "mime-types"; import { getUrl } from "~database/entities/Attachment"; -import { findFirstStatuses, findManyStatuses } from "~database/entities/Status"; import { type User, createNewLocalUser, findFirstUser, findManyUsers, } from "~database/entities/User"; -import { db, client } from "~drizzle/db"; +import { client, db } from "~drizzle/db"; import { emoji, openIdAccount, status, user } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; await client.connect(); const args = process.argv; @@ -803,9 +795,7 @@ const cliBuilder = new CliBuilder([ return 1; } - const note = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const note = await Note.fromId(id); if (!note) { console.log(`${chalk.red("✗")} Note not found`); @@ -815,7 +805,7 @@ const cliBuilder = new CliBuilder([ if (!args.noconfirm) { process.stdout.write( `Are you sure you want to delete note ${chalk.blue( - note.id, + note.getStatus().id, )}?\n${chalk.red( chalk.bold( "This is a destructive action and cannot be undone!", @@ -832,10 +822,12 @@ const cliBuilder = new CliBuilder([ } } - await db.delete(status).where(eq(status.id, note.id)); + await note.delete(); console.log( - `${chalk.green("✓")} Deleted note ${chalk.blue(note.id)}`, + `${chalk.green("✓")} Deleted note ${chalk.blue( + note.getStatus().id, + )}`, ); return 0; @@ -968,8 +960,8 @@ const cliBuilder = new CliBuilder([ instanceQuery = sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${status.authorId} AND "User"."instanceId" IS NOT NULL)`; } - const notes = await findManyStatuses({ - where: (status, { or, and }) => + const notes = ( + await Note.manyFromSql( and( or( ...fields.map((field) => @@ -979,8 +971,10 @@ const cliBuilder = new CliBuilder([ ), instanceQuery, ), - limit: Number(limit), - }); + undefined, + Number(limit), + ) + ).map((n) => n.getStatus()); if (redact) { for (const note of notes) { diff --git a/database/entities/Federation.ts b/database/entities/Federation.ts index f2227e28..8f18f672 100644 --- a/database/entities/Federation.ts +++ b/database/entities/Federation.ts @@ -2,6 +2,8 @@ import { config } from "config-manager"; import type * as Lysand from "lysand-types"; import { type User, getUserUri } from "./User"; +export const localObjectURI = (id: string) => `/objects/${id}`; + export const objectToInboxRequest = async ( object: Lysand.Entity, author: User, diff --git a/database/entities/Notification.ts b/database/entities/Notification.ts index dcc8e20e..a976049a 100644 --- a/database/entities/Notification.ts +++ b/database/entities/Notification.ts @@ -1,12 +1,9 @@ import type { InferSelectModel } from "drizzle-orm"; import { db } from "~drizzle/db"; import type { notification } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; import type { Notification as APINotification } from "~types/mastodon/notification"; -import { - type StatusWithRelations, - findFirstStatuses, - statusToAPI, -} from "./Status"; +import type { StatusWithRelations } from "./Status"; import { type UserWithRelations, transformOutputToUserWithRelations, @@ -45,12 +42,7 @@ export const findManyNotifications = async ( output.map(async (notif) => ({ ...notif, account: transformOutputToUserWithRelations(notif.account), - status: notif.statusId - ? await findFirstStatuses({ - where: (status, { eq }) => - eq(status.id, notif.statusId ?? ""), - }) - : null, + status: (await Note.fromId(notif.statusId))?.getStatus() ?? null, })), ); }; @@ -64,7 +56,9 @@ export const notificationToAPI = async ( id: notification.id, type: notification.type, status: notification.status - ? await statusToAPI(notification.status, notification.account) + ? await Note.fromStatus(notification.status).toAPI( + notification.account, + ) : undefined, }; }; diff --git a/database/entities/Queue.ts b/database/entities/Queue.ts index 108c4ee8..c44718ea 100644 --- a/database/entities/Queue.ts +++ b/database/entities/Queue.ts @@ -1,6 +1,5 @@ import { config } from "config-manager"; // import { Worker } from "bullmq"; -import { type StatusWithRelations, statusToLysand } from "./Status"; /* export const federationWorker = new Worker( "federation", diff --git a/database/entities/Status.ts b/database/entities/Status.ts index 0877a4fe..106b1ffb 100644 --- a/database/entities/Status.ts +++ b/database/entities/Status.ts @@ -27,38 +27,31 @@ import { import { parse } from "marked"; import { db } from "~drizzle/db"; import { - type application, attachment, emojiToStatus, instance, - type like, notification, status, statusToMentions, user, } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; import { LogLevel } from "~packages/log-manager"; -import type { Note } from "~types/lysand/Object"; -import type { Attachment as APIAttachment } from "~types/mastodon/attachment"; import type { Status as APIStatus } from "~types/mastodon/status"; -import { type Application, applicationToAPI } from "./Application"; -import { - attachmentFromLysand, - attachmentToAPI, - attachmentToLysand, -} from "./Attachment"; +import type { Application } from "./Application"; +import { attachmentFromLysand, attachmentToLysand } from "./Attachment"; import { type EmojiWithInstance, - emojiToAPI, emojiToLysand, fetchEmoji, parseEmojis, } from "./Emoji"; import { objectToInboxRequest } from "./Federation"; +import type { Like } from "./Like"; import { type User, + type UserWithInstance, type UserWithRelations, - type UserWithRelationsAndRelationships, findManyUsers, getUserUri, resolveUser, @@ -66,21 +59,20 @@ import { transformOutputToUserWithRelations, userExtrasTemplate, userRelations, - userToAPI, } from "./User"; export type Status = InferSelectModel; export type StatusWithRelations = Status & { author: UserWithRelations; - mentions: UserWithRelations[]; + mentions: UserWithInstance[]; attachments: InferSelectModel[]; reblog: StatusWithoutRecursiveRelations | null; emojis: EmojiWithInstance[]; - likes: InferSelectModel[]; - inReplyTo: StatusWithoutRecursiveRelations | null; - quoting: StatusWithoutRecursiveRelations | null; - application: InferSelectModel | null; + likes: Like[]; + inReplyTo: Status | null; + quoting: Status | null; + application: Application | null; reblogCount: number; likeCount: number; replyCount: number; @@ -88,15 +80,10 @@ export type StatusWithRelations = Status & { export type StatusWithoutRecursiveRelations = Omit< StatusWithRelations, - | "inReplyTo" - | "quoting" - | "reblog" - | "reblogCount" - | "likeCount" - | "replyCount" + "inReplyTo" | "quoting" | "reblog" >; -export const statusExtras = { +export const noteExtras = { reblogCount: sql`(SELECT COUNT(*) FROM "Status" "status" WHERE "status"."reblogId" = "status".id)`.as( "reblog_count", @@ -111,49 +98,12 @@ export const statusExtras = { ), }; -export const statusExtrasTemplate = (name: string) => ({ - // @ts-ignore - reblogCount: sql([ - `(SELECT COUNT(*) FROM "Status" "status" WHERE "status"."reblogId" = ${name}.id)`, - ]).as("reblog_count"), - // @ts-ignore - likeCount: sql([ - `(SELECT COUNT(*) FROM "Like" "like" WHERE "like"."likedId" = ${name}.id)`, - ]).as("like_count"), - // @ts-ignore - replyCount: sql([ - `(SELECT COUNT(*) FROM "Status" "status" WHERE "status"."inReplyToPostId" = ${name}.id)`, - ]).as("reply_count"), -}); - /** - * Returns whether this status is viewable by a user. - * @param user The user to check. - * @returns Whether this status is viewable by the user. + * Wrapper against the Status object to make it easier to work with + * @param query + * @returns */ -export const isViewableByUser = async ( - status: StatusWithRelations, - user: UserWithRelations | null, -) => { - if (status.authorId === user?.id) return true; - if (status.visibility === "public") return true; - if (status.visibility === "unlisted") return true; - if (status.visibility === "private") { - return user - ? await db.query.relationship.findFirst({ - where: (relationship, { and, eq }) => - and( - eq(relationship.ownerId, user?.id), - eq(relationship.subjectId, status.authorId), - eq(relationship.following, true), - ), - }) - : false; - } - return user && status.mentions.includes(user); -}; - -export const findManyStatuses = async ( +export const findManyNotes = async ( query: Parameters[0], ): Promise => { const output = await db.query.status.findMany({ @@ -182,8 +132,9 @@ export const findManyStatuses = async ( mentions: { with: { user: { - with: userRelations, - extras: userExtrasTemplate("status_mentions_user"), + with: { + instance: true, + }, }, }, }, @@ -218,74 +169,15 @@ export const findManyStatuses = async ( extras: userExtrasTemplate("status_reblog_author"), }, }, - }, - inReplyTo: { - with: { - attachments: true, - emojis: { - with: { - emoji: { - with: { - instance: true, - }, - }, - }, - }, - likes: true, - application: true, - mentions: { - with: { - user: { - with: userRelations, - extras: userExtrasTemplate( - "status_inReplyTo_mentions_user", - ), - }, - }, - }, - author: { - with: { - ...userRelations, - }, - extras: userExtrasTemplate("status_inReplyTo_author"), - }, - }, - }, - quoting: { - with: { - attachments: true, - emojis: { - with: { - emoji: { - with: { - instance: true, - }, - }, - }, - }, - likes: true, - application: true, - mentions: { - with: { - user: { - with: userRelations, - extras: userExtrasTemplate( - "status_quoting_mentions_user", - ), - }, - }, - }, - author: { - with: { - ...userRelations, - }, - extras: userExtrasTemplate("status_quoting_author"), - }, + extras: { + ...noteExtras, }, }, + inReplyTo: true, + quoting: true, }, extras: { - ...statusExtras, + ...noteExtras, ...query?.extras, }, }); @@ -293,65 +185,38 @@ export const findManyStatuses = async ( return output.map((post) => ({ ...post, author: transformOutputToUserWithRelations(post.author), - mentions: post.mentions.map( - (mention) => - mention.user && - transformOutputToUserWithRelations(mention.user), - ), - reblog: post.reblog && { - ...post.reblog, - author: transformOutputToUserWithRelations(post.reblog.author), - mentions: post.reblog.mentions.map( - (mention) => - mention.user && - transformOutputToUserWithRelations(mention.user), - ), - emojis: post.reblog.emojis.map( - (emoji) => - (emoji as unknown as Record) - .emoji as EmojiWithInstance, - ), - }, - inReplyTo: post.inReplyTo && { - ...post.inReplyTo, - author: transformOutputToUserWithRelations(post.inReplyTo.author), - mentions: post.inReplyTo.mentions.map( - (mention) => - mention.user && - transformOutputToUserWithRelations(mention.user), - ), - emojis: post.inReplyTo.emojis.map( - (emoji) => - (emoji as unknown as Record) - .emoji as EmojiWithInstance, - ), - }, - quoting: post.quoting && { - ...post.quoting, - author: transformOutputToUserWithRelations(post.quoting.author), - mentions: post.quoting.mentions.map( - (mention) => - mention.user && - transformOutputToUserWithRelations(mention.user), - ), - emojis: post.quoting.emojis.map( - (emoji) => - (emoji as unknown as Record) - .emoji as EmojiWithInstance, - ), - }, + mentions: post.mentions.map((mention) => ({ + ...mention.user, + endpoints: mention.user.endpoints as User["endpoints"], + })), emojis: (post.emojis ?? []).map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), + reblog: post.reblog && { + ...post.reblog, + author: transformOutputToUserWithRelations(post.reblog.author), + mentions: post.reblog.mentions.map((mention) => ({ + ...mention.user, + endpoints: mention.user.endpoints as User["endpoints"], + })), + emojis: (post.reblog.emojis ?? []).map( + (emoji) => + (emoji as unknown as Record) + .emoji as EmojiWithInstance, + ), + reblogCount: Number(post.reblog.reblogCount), + likeCount: Number(post.reblog.likeCount), + replyCount: Number(post.reblog.replyCount), + }, reblogCount: Number(post.reblogCount), likeCount: Number(post.likeCount), replyCount: Number(post.replyCount), })); }; -export const findFirstStatuses = async ( +export const findFirstNote = async ( query: Parameters[0], ): Promise => { const output = await db.query.status.findFirst({ @@ -380,8 +245,9 @@ export const findFirstStatuses = async ( mentions: { with: { user: { - with: userRelations, - extras: userExtrasTemplate("status_mentions_user"), + with: { + instance: true, + }, }, }, }, @@ -416,74 +282,15 @@ export const findFirstStatuses = async ( extras: userExtrasTemplate("status_reblog_author"), }, }, - }, - inReplyTo: { - with: { - attachments: true, - emojis: { - with: { - emoji: { - with: { - instance: true, - }, - }, - }, - }, - likes: true, - application: true, - mentions: { - with: { - user: { - with: userRelations, - extras: userExtrasTemplate( - "status_inReplyTo_mentions_user", - ), - }, - }, - }, - author: { - with: { - ...userRelations, - }, - extras: userExtrasTemplate("status_inReplyTo_author"), - }, - }, - }, - quoting: { - with: { - attachments: true, - emojis: { - with: { - emoji: { - with: { - instance: true, - }, - }, - }, - }, - likes: true, - application: true, - mentions: { - with: { - user: { - with: userRelations, - extras: userExtrasTemplate( - "status_quoting_mentions_user", - ), - }, - }, - }, - author: { - with: { - ...userRelations, - }, - extras: userExtrasTemplate("status_quoting_author"), - }, + extras: { + ...noteExtras, }, }, + inReplyTo: true, + quoting: true, }, extras: { - ...statusExtras, + ...noteExtras, ...query?.extras, }, }); @@ -493,74 +300,48 @@ export const findFirstStatuses = async ( return { ...output, author: transformOutputToUserWithRelations(output.author), - mentions: output.mentions.map((mention) => - transformOutputToUserWithRelations(mention.user), - ), - reblog: output.reblog && { - ...output.reblog, - author: transformOutputToUserWithRelations(output.reblog.author), - mentions: output.reblog.mentions.map( - (mention) => - mention.user && - transformOutputToUserWithRelations(mention.user), - ), - emojis: output.reblog.emojis.map( - (emoji) => - (emoji as unknown as Record) - .emoji as EmojiWithInstance, - ), - }, - inReplyTo: output.inReplyTo && { - ...output.inReplyTo, - author: transformOutputToUserWithRelations(output.inReplyTo.author), - mentions: output.inReplyTo.mentions.map( - (mention) => - mention.user && - transformOutputToUserWithRelations(mention.user), - ), - emojis: output.inReplyTo.emojis.map( - (emoji) => - (emoji as unknown as Record) - .emoji as EmojiWithInstance, - ), - }, - quoting: output.quoting && { - ...output.quoting, - author: transformOutputToUserWithRelations(output.quoting.author), - mentions: output.quoting.mentions.map( - (mention) => - mention.user && - transformOutputToUserWithRelations(mention.user), - ), - emojis: output.quoting.emojis.map( - (emoji) => - (emoji as unknown as Record) - .emoji as EmojiWithInstance, - ), - }, + mentions: output.mentions.map((mention) => ({ + ...mention.user, + endpoints: mention.user.endpoints as User["endpoints"], + })), emojis: (output.emojis ?? []).map( (emoji) => (emoji as unknown as Record) .emoji as EmojiWithInstance, ), + reblog: output.reblog && { + ...output.reblog, + author: transformOutputToUserWithRelations(output.reblog.author), + mentions: output.reblog.mentions.map((mention) => ({ + ...mention.user, + endpoints: mention.user.endpoints as User["endpoints"], + })), + emojis: (output.reblog.emojis ?? []).map( + (emoji) => + (emoji as unknown as Record) + .emoji as EmojiWithInstance, + ), + reblogCount: Number(output.reblog.reblogCount), + likeCount: Number(output.reblog.likeCount), + replyCount: Number(output.reblog.replyCount), + }, reblogCount: Number(output.reblogCount), likeCount: Number(output.likeCount), replyCount: Number(output.replyCount), }; }; -export const resolveStatus = async ( +export const resolveNote = async ( uri?: string, providedNote?: Lysand.Note, -): Promise => { +): Promise => { if (!uri && !providedNote) { throw new Error("No URI or note provided"); } - const foundStatus = await findFirstStatuses({ - where: (status, { eq }) => - eq(status.uri, uri ?? providedNote?.uri ?? ""), - }); + const foundStatus = await Note.fromSql( + eq(status.uri, uri ?? providedNote?.uri ?? ""), + ); if (foundStatus) return foundStatus; @@ -632,7 +413,7 @@ export const resolveStatus = async ( } } - const createdStatus = await createNewStatus( + const createdNote = await Note.fromData( author, note.content ?? { "text/plain": { @@ -652,86 +433,19 @@ export const resolveStatus = async ( ) as Promise[], ), attachments.map((a) => a.id), - note.replies_to ? await resolveStatus(note.replies_to) : undefined, - note.quotes ? await resolveStatus(note.quotes) : undefined, + note.replies_to + ? (await resolveNote(note.replies_to)).getStatus().id + : undefined, + note.quotes + ? (await resolveNote(note.quotes)).getStatus().id + : undefined, ); - if (!createdStatus) { + if (!createdNote) { throw new Error("Failed to create status"); } - return createdStatus; -}; - -/** - * Return all the ancestors of this post, - */ -export const getAncestors = async ( - status: StatusWithRelations, - fetcher: UserWithRelationsAndRelationships | null, -) => { - const ancestors: StatusWithRelations[] = []; - - let currentStatus = status; - - while (currentStatus.inReplyToPostId) { - const parent = await findFirstStatuses({ - where: (status, { eq }) => - eq(status.id, currentStatus.inReplyToPostId ?? ""), - }); - - if (!parent) break; - - ancestors.push(parent); - - currentStatus = parent; - } - - // Filter for posts that are viewable by the user - const viewableAncestors = ancestors.filter((ancestor) => - isViewableByUser(ancestor, fetcher), - ); - return viewableAncestors; -}; - -/** - * Return all the descendants of this post (recursive) - * Temporary implementation, will be replaced with a recursive SQL query when Prisma adds support for it - */ -export const getDescendants = async ( - status: StatusWithRelations, - fetcher: UserWithRelationsAndRelationships | null, - depth = 0, -) => { - const descendants: StatusWithRelations[] = []; - - const currentStatus = status; - - // Fetch all children of children of children recursively calling getDescendants - - const children = await findManyStatuses({ - where: (status, { eq }) => eq(status.inReplyToPostId, currentStatus.id), - }); - - for (const child of children) { - descendants.push(child); - - if (depth < 20) { - const childDescendants = await getDescendants( - child, - fetcher, - depth + 1, - ); - descendants.push(...childDescendants); - } - } - - // Filter for posts that are viewable by the user - - const viewableDescendants = descendants.filter((descendant) => - isViewableByUser(descendant, fetcher), - ); - return viewableDescendants; + return createdNote; }; export const createMentionRegExp = () => @@ -907,122 +621,12 @@ export const contentToHtml = async ( return htmlContent; }; -/** - * Creates a new status and saves it to the database. - * @returns A promise that resolves with the new status. - */ -export const createNewStatus = async ( - author: User, - content: Lysand.ContentFormat, - visibility: APIStatus["visibility"], - is_sensitive: boolean, - spoiler_text: string, - emojis: EmojiWithInstance[], - uri?: string, - mentions?: UserWithRelations[], - /** List of IDs of database Attachment objects */ - media_attachments?: string[], - inReplyTo?: StatusWithRelations, - quoting?: StatusWithRelations, - application?: Application, -): Promise => { - const htmlContent = await contentToHtml(content, mentions); - - // Parse emojis and fuse with existing emojis - let foundEmojis = emojis; - - if (author.instanceId === null) { - const parsedEmojis = await parseEmojis(htmlContent); - // Fuse and deduplicate - foundEmojis = [...emojis, ...parsedEmojis].filter( - (emoji, index, self) => - index === self.findIndex((t) => t.id === emoji.id), - ); - } - - const newStatus = ( - await db - .insert(status) - .values({ - authorId: author.id, - content: htmlContent, - contentSource: - content["text/plain"]?.content || - content["text/markdown"]?.content || - Object.entries(content)[0][1].content || - "", - contentType: "text/html", - visibility, - sensitive: is_sensitive, - spoilerText: spoiler_text, - uri: uri || null, - inReplyToPostId: inReplyTo?.id, - quotingPostId: quoting?.id, - applicationId: application?.id ?? null, - updatedAt: new Date().toISOString(), - }) - .returning() - )[0]; - - // Connect emojis - for (const emoji of foundEmojis) { - await db - .insert(emojiToStatus) - .values({ - emojiId: emoji.id, - statusId: newStatus.id, - }) - .execute(); - } - - // Connect mentions - for (const mention of mentions ?? []) { - await db - .insert(statusToMentions) - .values({ - statusId: newStatus.id, - userId: mention.id, - }) - .execute(); - } - - // Set attachment parents - if (media_attachments && media_attachments.length > 0) { - await db - .update(attachment) - .set({ - statusId: newStatus.id, - }) - .where(inArray(attachment.id, media_attachments)); - } - - // Send notifications for mentioned local users - for (const mention of mentions ?? []) { - if (mention.instanceId === null) { - await db.insert(notification).values({ - accountId: author.id, - notifiedId: mention.id, - type: "mention", - statusId: newStatus.id, - }); - } - } - - return ( - (await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, newStatus.id), - })) || null - ); -}; - -export const federateStatus = async (status: StatusWithRelations) => { - const toFederateTo = await getUsersToFederateTo(status); - - for (const user of toFederateTo) { +export const federateNote = async (note: Note) => { + for (const user of await note.getUsersToFederateTo()) { // TODO: Add queue system const request = await objectToInboxRequest( - statusToLysand(status), - status.author, + note.toLysand(), + note.getAuthor(), user, ); @@ -1038,57 +642,14 @@ export const federateStatus = async (status: StatusWithRelations) => { dualLogger.log( LogLevel.ERROR, "Federation.Status", - `Failed to federate status ${status.id} to ${user.uri}`, + `Failed to federate status ${note.getStatus().id} to ${ + user.uri + }`, ); } } }; -export const getUsersToFederateTo = async ( - status: StatusWithRelations, -): Promise => { - // Mentioned users - const mentionedUsers = - status.mentions.length > 0 - ? await findManyUsers({ - where: (user, { or, and, isNotNull, eq, inArray }) => - and( - isNotNull(user.instanceId), - inArray( - user.id, - status.mentions.map((mention) => mention.id), - ), - ), - with: { - ...userRelations, - }, - }) - : []; - - const usersThatCanSeePost = await findManyUsers({ - where: (user, { isNotNull }) => isNotNull(user.instanceId), - with: { - ...userRelations, - relationships: { - where: (relationship, { eq, and }) => - and( - eq(relationship.subjectId, user.id), - eq(relationship.following, true), - ), - }, - }, - }); - - const fusedUsers = [...mentionedUsers, ...usersThatCanSeePost]; - - const deduplicatedUsersById = fusedUsers.filter( - (user, index, self) => - index === self.findIndex((t) => t.id === user.id), - ); - - return deduplicatedUsersById; -}; - export const editStatus = async ( statusToEdit: StatusWithRelations, data: { @@ -1102,7 +663,7 @@ export const editStatus = async ( mentions?: User[]; media_attachments?: string[]; }, -): Promise => { +): Promise => { const mentions = await parseTextMentions(data.content); // Parse emojis @@ -1122,20 +683,20 @@ export const editStatus = async ( }, }); - const updated = ( - await db - .update(status) - .set({ - content: htmlContent, - contentSource: data.content, - contentType: data.content_type, - visibility: data.visibility, - sensitive: data.sensitive, - spoilerText: data.spoiler_text, - }) - .where(eq(status.id, statusToEdit.id)) - .returning() - )[0]; + const note = await Note.fromId(statusToEdit.id); + + if (!note) { + return null; + } + + const updated = await note.update({ + content: htmlContent, + contentSource: data.content, + contentType: data.content_type, + visibility: data.visibility, + sensitive: data.sensitive, + spoilerText: data.spoiler_text, + }); // Connect emojis for (const emoji of data.emojis) { @@ -1179,11 +740,7 @@ export const editStatus = async ( }) .where(inArray(attachment.id, data.media_attachments ?? [])); - return ( - (await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, updated.id), - })) || null - ); + return await Note.fromId(updated.id); }; export const isFavouritedBy = async (status: Status, user: User) => { @@ -1193,128 +750,6 @@ export const isFavouritedBy = async (status: Status, user: User) => { })); }; -/** - * Converts this status to an API status. - * @returns A promise that resolves with the API status. - */ -export const statusToAPI = async ( - statusToConvert: StatusWithRelations, - userFetching?: UserWithRelations, -): Promise => { - const wasPinnedByUser = userFetching - ? !!(await db.query.userPinnedNotes.findFirst({ - where: (relation, { and, eq }) => - and( - eq(relation.statusId, statusToConvert.id), - eq(relation.userId, userFetching?.id), - ), - })) - : false; - - const wasRebloggedByUser = userFetching - ? !!(await db.query.status.findFirst({ - where: (status, { eq, and }) => - and( - eq(status.authorId, userFetching?.id), - eq(status.reblogId, statusToConvert.id), - ), - })) - : false; - - const wasMutedByUser = userFetching - ? !!(await db.query.relationship.findFirst({ - where: (relationship, { and, eq }) => - and( - eq(relationship.ownerId, userFetching.id), - eq(relationship.subjectId, statusToConvert.authorId), - eq(relationship.muting, true), - ), - })) - : false; - - // Convert mentions of local users from @username@host to @username - const mentionedLocalUsers = statusToConvert.mentions.filter( - (mention) => mention.instanceId === null, - ); - - let replacedContent = statusToConvert.content; - - for (const mention of mentionedLocalUsers) { - replacedContent = replacedContent.replace( - createRegExp( - exactly( - `@${mention.username}@${ - new URL(config.http.base_url).host - }`, - ), - [global], - ), - `@${mention.username}`, - ); - } - - return { - id: statusToConvert.id, - in_reply_to_id: statusToConvert.inReplyToPostId || null, - in_reply_to_account_id: statusToConvert.inReplyTo?.authorId || null, - account: userToAPI(statusToConvert.author), - created_at: new Date(statusToConvert.createdAt).toISOString(), - application: statusToConvert.application - ? applicationToAPI(statusToConvert.application) - : null, - card: null, - content: replacedContent, - emojis: statusToConvert.emojis.map((emoji) => emojiToAPI(emoji)), - favourited: !!(statusToConvert.likes ?? []).find( - (like) => like.likerId === userFetching?.id, - ), - favourites_count: (statusToConvert.likes ?? []).length, - media_attachments: (statusToConvert.attachments ?? []).map( - (a) => attachmentToAPI(a) as APIAttachment, - ), - mentions: statusToConvert.mentions.map((mention) => userToAPI(mention)), - language: null, - muted: wasMutedByUser, - pinned: wasPinnedByUser, - // TODO: Add polls - poll: null, - reblog: statusToConvert.reblog - ? await statusToAPI( - statusToConvert.reblog as unknown as StatusWithRelations, - userFetching, - ) - : null, - reblogged: wasRebloggedByUser, - reblogs_count: statusToConvert.reblogCount, - replies_count: statusToConvert.replyCount, - sensitive: statusToConvert.sensitive, - spoiler_text: statusToConvert.spoilerText, - tags: [], - uri: - statusToConvert.uri || - new URL( - `/@${statusToConvert.author.username}/${statusToConvert.id}`, - config.http.base_url, - ).toString(), - visibility: statusToConvert.visibility as APIStatus["visibility"], - url: - statusToConvert.uri || - new URL( - `/@${statusToConvert.author.username}/${statusToConvert.id}`, - config.http.base_url, - ).toString(), - bookmarked: false, - quote: !!statusToConvert.quotingPostId /* statusToConvert.quoting - ? await statusToAPI( - statusToConvert.quoting as unknown as StatusWithRelations, - userFetching, - ) - : null, */, - // @ts-expect-error Pleroma extension - quote_id: statusToConvert.quotingPostId || undefined, - }; -}; - export const getStatusUri = (status?: Status | null) => { if (!status) return undefined; diff --git a/database/entities/User.ts b/database/entities/User.ts index 5674532f..d05bc9a5 100644 --- a/database/entities/User.ts +++ b/database/entities/User.ts @@ -17,6 +17,7 @@ import { } from "~drizzle/schema"; import { LogLevel } from "~packages/log-manager"; import type { Account as APIAccount } from "~types/mastodon/account"; +import type { Mention as APIMention } from "~types/mastodon/mention"; import type { Source as APISource } from "~types/mastodon/source"; import type { Application } from "./Application"; import { @@ -30,16 +31,10 @@ import { addInstanceIfNotExists } from "./Instance"; import { createNewRelationship } from "./Relationship"; import type { Token } from "./Token"; -export type User = InferSelectModel & { - endpoints?: Partial<{ - dislikes: string; - featured: string; - likes: string; - followers: string; - following: string; - inbox: string; - outbox: string; - }>; +export type User = InferSelectModel; + +export type UserWithInstance = User & { + instance: InferSelectModel | null; }; export type UserWithRelations = User & { @@ -109,21 +104,6 @@ export const userExtrasTemplate = (name: string) => ({ ]).as("status_count"), }); -/* const a = await db.query.user.findFirst({ - with: { - instance: true, - emojis: { - with: { - emoji: { - with: { - instance: true, - }, - }, - }, - }, - }, -}); */ - export interface AuthData { user: UserWithRelations | null; token: string; @@ -774,6 +754,16 @@ export const generateUserKeys = async () => { }; }; +export const userToMention = (user: UserWithInstance): APIMention => ({ + url: getUserUri(user), + username: user.username, + acct: + user.instance === null + ? user.username + : `${user.username}@${user.instance.baseUrl}`, + id: user.id, +}); + export const userToAPI = ( userToConvert: UserWithRelations, isOwnAccount = false, diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 5e240bf7..94eadac8 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -289,7 +289,15 @@ export const user = pgTable( email: text("email"), note: text("note").default("").notNull(), isAdmin: boolean("is_admin").default(false).notNull(), - endpoints: jsonb("endpoints"), + endpoints: jsonb("endpoints").$type | null>(), source: jsonb("source").notNull(), avatar: text("avatar").notNull(), header: text("header").notNull(), diff --git a/drizzle/types.ts b/drizzle/types.ts new file mode 100644 index 00000000..c6f5db2c --- /dev/null +++ b/drizzle/types.ts @@ -0,0 +1,39 @@ +import type { + BuildQueryResult, + DBQueryConfig, + ExtractTablesWithRelations, +} from "drizzle-orm"; + +import type * as schema from "./schema"; + +type Schema = typeof schema; +type TablesWithRelations = ExtractTablesWithRelations; + +export type IncludeRelation = + DBQueryConfig< + "one" | "many", + boolean, + TablesWithRelations, + TablesWithRelations[TableName] + >["with"]; + +export type IncludeColumns = + DBQueryConfig< + "one" | "many", + boolean, + TablesWithRelations, + TablesWithRelations[TableName] + >["columns"]; + +export type InferQueryModel< + TableName extends keyof TablesWithRelations, + Columns extends IncludeColumns | undefined = undefined, + With extends IncludeRelation | undefined = undefined, +> = BuildQueryResult< + TablesWithRelations, + TablesWithRelations[TableName], + { + columns: Columns; + with: With; + } +>; diff --git a/packages/database-interface/main.ts b/packages/database-interface/main.ts new file mode 100644 index 00000000..0aaaa2c5 --- /dev/null +++ b/packages/database-interface/main.ts @@ -0,0 +1,4 @@ +import { Note } from "./note"; +import { Timeline } from "./timeline"; + +export { Note, Timeline }; diff --git a/packages/database-interface/note.ts b/packages/database-interface/note.ts new file mode 100644 index 00000000..ac110e23 --- /dev/null +++ b/packages/database-interface/note.ts @@ -0,0 +1,618 @@ +import { + type InferInsertModel, + type SQL, + and, + desc, + eq, + inArray, +} from "drizzle-orm"; +import { htmlToText } from "html-to-text"; +import type * as Lysand from "lysand-types"; +import { createRegExp, exactly, global } from "magic-regexp"; +import { + type Application, + applicationToAPI, +} from "~database/entities/Application"; +import { + attachmentToAPI, + attachmentToLysand, +} from "~database/entities/Attachment"; +import { + type EmojiWithInstance, + emojiToAPI, + emojiToLysand, + parseEmojis, +} from "~database/entities/Emoji"; +import { localObjectURI } from "~database/entities/Federation"; +import { + type Status, + type StatusWithRelations, + contentToHtml, + findFirstNote, + findManyNotes, + getStatusUri, +} from "~database/entities/Status"; +import { + type User, + type UserWithRelations, + type UserWithRelationsAndRelationships, + findManyUsers, + getUserUri, + userToAPI, + userToMention, +} from "~database/entities/User"; +import { db } from "~drizzle/db"; +import { + attachment, + emojiToStatus, + notification, + status, + statusToMentions, + user, + userRelations, +} from "~drizzle/schema"; +import { config } from "~packages/config-manager"; +import type { Attachment as APIAttachment } from "~types/mastodon/attachment"; +import type { Status as APIStatus } from "~types/mastodon/status"; + +/** + * Gives helpers to fetch notes from database in a nice format + */ +export class Note { + private constructor(private status: StatusWithRelations) {} + + static async fromId(id: string | null): Promise { + if (!id) return null; + + return await Note.fromSql(eq(status.id, id)); + } + + static async fromIds(ids: string[]): Promise { + return await Note.manyFromSql(inArray(status.id, ids)); + } + + static async fromSql( + sql: SQL | undefined, + orderBy: SQL | undefined = desc(status.id), + ) { + const found = await findFirstNote({ + where: sql, + orderBy, + }); + + if (!found) return null; + return new Note(found); + } + + static async manyFromSql( + sql: SQL | undefined, + orderBy: SQL | undefined = desc(status.id), + limit?: number, + offset?: number, + ) { + const found = await findManyNotes({ + where: sql, + orderBy, + limit, + offset, + }); + + return found.map((s) => new Note(s)); + } + + async getUsersToFederateTo() { + // Mentioned users + const mentionedUsers = + this.getStatus().mentions.length > 0 + ? await findManyUsers({ + where: (user, { and, isNotNull, inArray }) => + and( + isNotNull(user.instanceId), + inArray( + user.id, + this.getStatus().mentions.map( + (mention) => mention.id, + ), + ), + ), + }) + : []; + + const usersThatCanSeePost = await findManyUsers({ + where: (user, { isNotNull }) => isNotNull(user.instanceId), + with: { + ...userRelations, + relationships: { + where: (relationship, { eq, and }) => + and( + eq(relationship.subjectId, user.id), + eq(relationship.following, true), + ), + }, + }, + }); + + const fusedUsers = [...mentionedUsers, ...usersThatCanSeePost]; + + const deduplicatedUsersById = fusedUsers.filter( + (user, index, self) => + index === self.findIndex((t) => t.id === user.id), + ); + + return deduplicatedUsersById; + } + + static fromStatus(status: StatusWithRelations) { + return new Note(status); + } + + static fromStatuses(statuses: StatusWithRelations[]) { + return statuses.map((s) => new Note(s)); + } + + isNull() { + return this.status === null; + } + + getStatus() { + return this.status; + } + + getAuthor() { + return this.status.author; + } + + async getReplyChildren() { + return await Note.manyFromSql( + eq(status.inReplyToPostId, this.status.id), + ); + } + + async unpin(unpinner: User) { + return await db + .delete(statusToMentions) + .where( + and( + eq(statusToMentions.statusId, this.status.id), + eq(statusToMentions.userId, unpinner.id), + ), + ); + } + + static async insert(values: InferInsertModel) { + return (await db.insert(status).values(values).returning())[0]; + } + + static async fromData( + author: User, + content: Lysand.ContentFormat, + visibility: APIStatus["visibility"], + is_sensitive: boolean, + spoiler_text: string, + emojis: EmojiWithInstance[], + uri?: string, + mentions?: UserWithRelations[], + /** List of IDs of database Attachment objects */ + media_attachments?: string[], + replyId?: string, + quoteId?: string, + application?: Application, + ): Promise { + const htmlContent = await contentToHtml(content, mentions); + + // Parse emojis and fuse with existing emojis + let foundEmojis = emojis; + + if (author.instanceId === null) { + const parsedEmojis = await parseEmojis(htmlContent); + // Fuse and deduplicate + foundEmojis = [...emojis, ...parsedEmojis].filter( + (emoji, index, self) => + index === self.findIndex((t) => t.id === emoji.id), + ); + } + + const newNote = await Note.insert({ + authorId: author.id, + content: htmlContent, + contentSource: + content["text/plain"]?.content || + content["text/markdown"]?.content || + Object.entries(content)[0][1].content || + "", + contentType: "text/html", + visibility, + sensitive: is_sensitive, + spoilerText: spoiler_text, + uri: uri || null, + inReplyToPostId: replyId ?? null, + quotingPostId: quoteId ?? null, + applicationId: application?.id ?? null, + }); + + // Connect emojis + for (const emoji of foundEmojis) { + await db + .insert(emojiToStatus) + .values({ + emojiId: emoji.id, + statusId: newNote.id, + }) + .execute(); + } + + // Connect mentions + for (const mention of mentions ?? []) { + await db + .insert(statusToMentions) + .values({ + statusId: newNote.id, + userId: mention.id, + }) + .execute(); + } + + // Set attachment parents + if (media_attachments && media_attachments.length > 0) { + await db + .update(attachment) + .set({ + statusId: newNote.id, + }) + .where(inArray(attachment.id, media_attachments)); + } + + // Send notifications for mentioned local users + for (const mention of mentions ?? []) { + if (mention.instanceId === null) { + await db.insert(notification).values({ + accountId: author.id, + notifiedId: mention.id, + type: "mention", + statusId: newNote.id, + }); + } + } + + return await Note.fromId(newNote.id); + } + + async updateFromData( + content?: Lysand.ContentFormat, + visibility?: APIStatus["visibility"], + is_sensitive?: boolean, + spoiler_text?: string, + emojis: EmojiWithInstance[] = [], + mentions: UserWithRelations[] = [], + /** List of IDs of database Attachment objects */ + media_attachments: string[] = [], + ) { + const htmlContent = content + ? await contentToHtml(content, mentions) + : undefined; + + // Parse emojis and fuse with existing emojis + let foundEmojis = emojis; + + if (this.getAuthor().instanceId === null && htmlContent) { + const parsedEmojis = await parseEmojis(htmlContent); + // Fuse and deduplicate + foundEmojis = [...emojis, ...parsedEmojis].filter( + (emoji, index, self) => + index === self.findIndex((t) => t.id === emoji.id), + ); + } + + const newNote = await this.update({ + content: htmlContent, + contentSource: content + ? content["text/plain"]?.content || + content["text/markdown"]?.content || + Object.entries(content)[0][1].content || + "" + : undefined, + contentType: "text/html", + visibility, + sensitive: is_sensitive, + spoilerText: spoiler_text, + }); + + // Connect emojis + await db + .delete(emojiToStatus) + .where(eq(emojiToStatus.statusId, this.status.id)); + + for (const emoji of foundEmojis) { + await db + .insert(emojiToStatus) + .values({ + emojiId: emoji.id, + statusId: this.status.id, + }) + .execute(); + } + + // Connect mentions + await db + .delete(statusToMentions) + .where(eq(statusToMentions.statusId, this.status.id)); + + for (const mention of mentions ?? []) { + await db + .insert(statusToMentions) + .values({ + statusId: this.status.id, + userId: mention.id, + }) + .execute(); + } + + // Set attachment parents + if (media_attachments && media_attachments.length > 0) { + await db + .update(attachment) + .set({ + statusId: this.status.id, + }) + .where(inArray(attachment.id, media_attachments)); + } + + return await Note.fromId(newNote.id); + } + + async delete() { + return ( + await db + .delete(status) + .where(eq(status.id, this.status.id)) + .returning() + )[0]; + } + + async update(newStatus: Partial) { + return ( + await db + .update(status) + .set(newStatus) + .where(eq(status.id, this.status.id)) + .returning() + )[0]; + } + + static async deleteMany(ids: string[]) { + return await db + .delete(status) + .where(inArray(status.id, ids)) + .returning(); + } + + /** + * Returns whether this status is viewable by a user. + * @param user The user to check. + * @returns Whether this status is viewable by the user. + */ + async isViewableByUser(user: UserWithRelations | null) { + if (this.getAuthor().id === user?.id) return true; + if (this.getStatus().visibility === "public") return true; + if (this.getStatus().visibility === "unlisted") return true; + if (this.getStatus().visibility === "private") { + return user + ? await db.query.relationship.findFirst({ + where: (relationship, { and, eq }) => + and( + eq(relationship.ownerId, user?.id), + eq(relationship.subjectId, status.authorId), + eq(relationship.following, true), + ), + }) + : false; + } + return ( + user && + this.getStatus().mentions.find((mention) => mention.id === user.id) + ); + } + + async toAPI(userFetching?: UserWithRelations | null): Promise { + const data = this.getStatus(); + const wasPinnedByUser = userFetching + ? !!(await db.query.userPinnedNotes.findFirst({ + where: (relation, { and, eq }) => + and( + eq(relation.statusId, data.id), + eq(relation.userId, userFetching?.id), + ), + })) + : false; + + const wasRebloggedByUser = userFetching + ? !!(await Note.fromSql( + and( + eq(status.authorId, userFetching?.id), + eq(status.reblogId, data.id), + ), + )) + : false; + + const wasMutedByUser = userFetching + ? !!(await db.query.relationship.findFirst({ + where: (relationship, { and, eq }) => + and( + eq(relationship.ownerId, userFetching.id), + eq(relationship.subjectId, data.authorId), + eq(relationship.muting, true), + ), + })) + : false; + + // Convert mentions of local users from @username@host to @username + const mentionedLocalUsers = data.mentions.filter( + (mention) => mention.instanceId === null, + ); + + let replacedContent = data.content; + + for (const mention of mentionedLocalUsers) { + replacedContent = replacedContent.replace( + createRegExp( + exactly( + `@${mention.username}@${ + new URL(config.http.base_url).host + }`, + ), + [global], + ), + `@${mention.username}`, + ); + } + + return { + id: data.id, + in_reply_to_id: data.inReplyToPostId || null, + in_reply_to_account_id: data.inReplyTo?.authorId || null, + account: userToAPI(data.author), + created_at: new Date(data.createdAt).toISOString(), + application: data.application + ? applicationToAPI(data.application) + : null, + card: null, + content: replacedContent, + emojis: data.emojis.map((emoji) => emojiToAPI(emoji)), + favourited: !!(data.likes ?? []).find( + (like) => like.likerId === userFetching?.id, + ), + favourites_count: (data.likes ?? []).length, + media_attachments: (data.attachments ?? []).map( + (a) => attachmentToAPI(a) as APIAttachment, + ), + mentions: data.mentions.map((mention) => userToMention(mention)), + language: null, + muted: wasMutedByUser, + pinned: wasPinnedByUser, + // TODO: Add polls + poll: null, + reblog: data.reblog + ? await Note.fromStatus( + data.reblog as StatusWithRelations, + ).toAPI(userFetching) + : null, + reblogged: wasRebloggedByUser, + reblogs_count: data.reblogCount, + replies_count: data.replyCount, + sensitive: data.sensitive, + spoiler_text: data.spoilerText, + tags: [], + uri: + data.uri || + new URL( + `/@${data.author.username}/${data.id}`, + config.http.base_url, + ).toString(), + visibility: data.visibility as APIStatus["visibility"], + url: data.uri || this.getMastoURI(), + bookmarked: false, + quote: !!data.quotingPostId, + // @ts-expect-error Pleroma extension + quote_id: data.quotingPostId || undefined, + }; + } + + getURI() { + return localObjectURI(this.getStatus().id); + } + + getMastoURI() { + return `/@${this.getAuthor().username}/${this.getStatus().id}`; + } + + toLysand(): Lysand.Note { + const status = this.getStatus(); + return { + type: "Note", + created_at: new Date(status.createdAt).toISOString(), + id: status.id, + author: getUserUri(status.author), + uri: this.getURI(), + content: { + "text/html": { + content: status.content, + }, + "text/plain": { + content: htmlToText(status.content), + }, + }, + attachments: (status.attachments ?? []).map((attachment) => + attachmentToLysand(attachment), + ), + is_sensitive: status.sensitive, + mentions: status.mentions.map((mention) => mention.uri || ""), + quotes: getStatusUri(status.quoting) ?? undefined, + replies_to: getStatusUri(status.inReplyTo) ?? undefined, + subject: status.spoilerText, + visibility: status.visibility as Lysand.Visibility, + extensions: { + "org.lysand:custom_emojis": { + emojis: status.emojis.map((emoji) => emojiToLysand(emoji)), + }, + // TODO: Add polls and reactions + }, + }; + } + + /** + * Return all the ancestors of this post, + */ + async getAncestors(fetcher: UserWithRelationsAndRelationships | null) { + const ancestors: Note[] = []; + + let currentStatus: Note = this; + + while (currentStatus.getStatus().inReplyToPostId) { + const parent = await Note.fromId( + currentStatus.getStatus().inReplyToPostId, + ); + + if (!parent) { + break; + } + + ancestors.push(parent); + currentStatus = parent; + } + + // Filter for posts that are viewable by the user + const viewableAncestors = ancestors.filter((ancestor) => + ancestor.isViewableByUser(fetcher), + ); + return viewableAncestors; + } + + /** + * Return all the descendants of this post (recursive) + * Temporary implementation, will be replaced with a recursive SQL query when I get to it + */ + async getDescendants( + fetcher: UserWithRelationsAndRelationships | null, + depth = 0, + ) { + const descendants: Note[] = []; + for (const child of await this.getReplyChildren()) { + descendants.push(child); + + if (depth < 20) { + const childDescendants = await child.getDescendants( + fetcher, + depth + 1, + ); + descendants.push(...childDescendants); + } + } + + // Filter for posts that are viewable by the user + + const viewableDescendants = descendants.filter((descendant) => + descendant.isViewableByUser(fetcher), + ); + return viewableDescendants; + } +} diff --git a/packages/database-interface/package.json b/packages/database-interface/package.json new file mode 100644 index 00000000..f1d398eb --- /dev/null +++ b/packages/database-interface/package.json @@ -0,0 +1,6 @@ +{ + "name": "database-interface", + "version": "0.0.0", + "main": "index.ts", + "dependencies": {} +} diff --git a/packages/database-interface/timeline.ts b/packages/database-interface/timeline.ts new file mode 100644 index 00000000..b1b6d1c2 --- /dev/null +++ b/packages/database-interface/timeline.ts @@ -0,0 +1,85 @@ +import { type SQL, gt } from "drizzle-orm"; +import { status } from "~drizzle/schema"; +import { config } from "~packages/config-manager"; +import { Note } from "./note"; + +enum TimelineType { + NOTE = "Note", +} + +export class Timeline { + constructor(private type: TimelineType) {} + + static async getNoteTimeline( + sql: SQL | undefined, + limit: number, + url: string, + ) { + return new Timeline(TimelineType.NOTE).fetchTimeline(sql, limit, url); + } + + private async fetchTimeline( + sql: SQL | undefined, + limit: number, + url: string, + ) { + const objects: Note[] = []; + + switch (this.type) { + case TimelineType.NOTE: + objects.push( + ...(await Note.manyFromSql(sql, undefined, limit)), + ); + break; + } + + const linkHeader = []; + const urlWithoutQuery = new URL( + new URL(url).pathname, + config.http.base_url, + ).toString(); + + if (objects.length > 0) { + switch (this.type) { + case TimelineType.NOTE: { + const objectBefore = await Note.fromSql( + gt(status.id, objects[0].getStatus().id), + ); + + if (objectBefore) { + linkHeader.push( + `<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${ + objects[0].getStatus().id + }>; rel="prev"`, + ); + } + + if (objects.length >= (limit ?? 20)) { + const objectAfter = await Note.fromSql( + gt( + status.id, + objects[objects.length - 1].getStatus().id, + ), + ); + + if (objectAfter) { + linkHeader.push( + `<${urlWithoutQuery}?limit=${ + limit ?? 20 + }&max_id=${ + objects[objects.length - 1].getStatus().id + }>; rel="next"`, + ); + } + } + break; + } + } + } + + return { + link: linkHeader.join(", "), + objects, + }; + } +} diff --git a/server/api/api/v1/accounts/[id]/statuses.ts b/server/api/api/v1/accounts/[id]/statuses.ts index 8091d39f..fbab9f89 100644 --- a/server/api/api/v1/accounts/[id]/statuses.ts +++ b/server/api/api/v1/accounts/[id]/statuses.ts @@ -1,13 +1,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; +import { and, eq, gt, gte, isNull, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type StatusWithRelations, - findManyStatuses, - statusToAPI, -} from "~database/entities/Status"; import { findFirstUser } from "~database/entities/User"; +import { status } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -62,32 +59,23 @@ export default apiRoute( if (!user) return errorResponse("User not found", 404); if (pinned) { - const { objects, link } = await fetchTimeline( - findManyStatuses, - { - // @ts-ignore - where: (status, { and, lt, gt, gte, eq, sql }) => - and( - max_id ? lt(status.id, max_id) : undefined, - since_id ? gte(status.id, since_id) : undefined, - min_id ? gt(status.id, min_id) : undefined, - eq(status.authorId, id), - sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."statusId" = ${status.id} AND "UserToPinnedNotes"."userId" = ${user.id})`, - only_media - ? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})` - : undefined, - ), - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (status, { desc }) => desc(status.id), - limit, - }, - req, + const { objects, link } = await Timeline.getNoteTimeline( + and( + max_id ? lt(status.id, max_id) : undefined, + since_id ? gte(status.id, since_id) : undefined, + min_id ? gt(status.id, min_id) : undefined, + eq(status.authorId, id), + sql`EXISTS (SELECT 1 FROM "UserToPinnedNotes" WHERE "UserToPinnedNotes"."statusId" = ${status.id} AND "UserToPinnedNotes"."userId" = ${user.id})`, + only_media + ? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})` + : undefined, + ), + limit, + req.url, ); return jsonResponse( - await Promise.all( - objects.map((status) => statusToAPI(status, user)), - ), + await Promise.all(objects.map((note) => note.toAPI(user))), 200, { Link: link, @@ -95,32 +83,23 @@ export default apiRoute( ); } - const { objects, link } = await fetchTimeline( - findManyStatuses, - { - // @ts-ignore - where: (status, { and, lt, gt, gte, eq, sql }) => - and( - max_id ? lt(status.id, max_id) : undefined, - since_id ? gte(status.id, since_id) : undefined, - min_id ? gt(status.id, min_id) : undefined, - eq(status.authorId, id), - only_media - ? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})` - : undefined, - exclude_reblogs ? eq(status.reblogId, null) : undefined, - ), - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (status, { desc }) => desc(status.id), - limit, - }, - req, + const { objects, link } = await Timeline.getNoteTimeline( + and( + max_id ? lt(status.id, max_id) : undefined, + since_id ? gte(status.id, since_id) : undefined, + min_id ? gt(status.id, min_id) : undefined, + eq(status.authorId, id), + only_media + ? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})` + : undefined, + exclude_reblogs ? isNull(status.reblogId) : undefined, + ), + limit, + req.url, ); return jsonResponse( - await Promise.all( - objects.map((status) => statusToAPI(status, user)), - ), + await Promise.all(objects.map((note) => note.toAPI(user))), 200, { Link: link, diff --git a/server/api/api/v1/favourites/index.ts b/server/api/api/v1/favourites/index.ts index ae8a9769..ac8f6074 100644 --- a/server/api/api/v1/favourites/index.ts +++ b/server/api/api/v1/favourites/index.ts @@ -1,12 +1,9 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type StatusWithRelations, - findManyStatuses, - statusToAPI, -} from "~database/entities/Status"; +import { status } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -35,28 +32,19 @@ export default apiRoute( if (!user) return errorResponse("Unauthorized", 401); - const { objects, link } = await fetchTimeline( - findManyStatuses, - { - // @ts-ignore - where: (status, { and, lt, gt, gte, eq, sql }) => - and( - max_id ? lt(status.id, max_id) : undefined, - since_id ? gte(status.id, since_id) : undefined, - min_id ? gt(status.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${user.id})`, - ), - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (status, { desc }) => desc(status.id), - limit, - }, - req, + const { objects, link } = await Timeline.getNoteTimeline( + and( + max_id ? lt(status.id, max_id) : undefined, + since_id ? gte(status.id, since_id) : undefined, + min_id ? gt(status.id, min_id) : undefined, + sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${user.id})`, + ), + limit, + req.url, ); return jsonResponse( - await Promise.all( - objects.map(async (status) => statusToAPI(status, user)), - ), + await Promise.all(objects.map(async (note) => note.toAPI(user))), 200, { Link: link, diff --git a/server/api/api/v1/statuses/[id]/context.ts b/server/api/api/v1/statuses/[id]/context.ts index 0cbf01de..80108838 100644 --- a/server/api/api/v1/statuses/[id]/context.ts +++ b/server/api/api/v1/statuses/[id]/context.ts @@ -1,13 +1,8 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import type { Relationship } from "~database/entities/Relationship"; -import { - findFirstStatuses, - getAncestors, - getDescendants, - statusToAPI, -} from "~database/entities/Status"; import { db } from "~drizzle/db"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -34,9 +29,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { const { user } = extraData.auth; - const foundStatus = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const foundStatus = await Note.fromId(id); if (!foundStatus) return errorResponse("Record not found", 404); @@ -55,8 +48,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { : null; // Get all ancestors - const ancestors = await getAncestors( - foundStatus, + const ancestors = await foundStatus.getAncestors( user ? { ...user, @@ -65,8 +57,8 @@ export default apiRoute(async (req, matchedRoute, extraData) => { } : null, ); - const descendants = await getDescendants( - foundStatus, + + const descendants = await foundStatus.getDescendants( user ? { ...user, @@ -78,10 +70,10 @@ export default apiRoute(async (req, matchedRoute, extraData) => { return jsonResponse({ ancestors: await Promise.all( - ancestors.map((status) => statusToAPI(status, user || undefined)), + ancestors.map((status) => status.toAPI(user)), ), descendants: await Promise.all( - descendants.map((status) => statusToAPI(status, user || undefined)), + descendants.map((status) => status.toAPI(user)), ), }); }); diff --git a/server/api/api/v1/statuses/[id]/favourite.ts b/server/api/api/v1/statuses/[id]/favourite.ts index ad12a624..775fa746 100644 --- a/server/api/api/v1/statuses/[id]/favourite.ts +++ b/server/api/api/v1/statuses/[id]/favourite.ts @@ -1,12 +1,8 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { createLike } from "~database/entities/Like"; -import { - findFirstStatuses, - isViewableByUser, - statusToAPI, -} from "~database/entities/Status"; import { db } from "~drizzle/db"; +import { Note } from "~packages/database-interface/note"; import type { Status as APIStatus } from "~types/mastodon/status"; export const meta = applyConfig({ @@ -34,26 +30,27 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!user) return errorResponse("Unauthorized", 401); - const status = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const status = await Note.fromId(id); // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) + if (!status?.isViewableByUser(user)) return errorResponse("Record not found", 404); const existingLike = await db.query.like.findFirst({ where: (like, { and, eq }) => - and(eq(like.likedId, status.id), eq(like.likerId, user.id)), + and( + eq(like.likedId, status.getStatus().id), + eq(like.likerId, user.id), + ), }); if (!existingLike) { - await createLike(user, status); + await createLike(user, status.getStatus()); } return jsonResponse({ - ...(await statusToAPI(status, user)), + ...(await status.toAPI(user)), favourited: true, - favourites_count: status.likeCount + 1, + favourites_count: status.getStatus().likeCount + 1, } as APIStatus); }); diff --git a/server/api/api/v1/statuses/[id]/favourited_by.ts b/server/api/api/v1/statuses/[id]/favourited_by.ts index daa6b2df..fc902285 100644 --- a/server/api/api/v1/statuses/[id]/favourited_by.ts +++ b/server/api/api/v1/statuses/[id]/favourited_by.ts @@ -2,12 +2,12 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; import { z } from "zod"; -import { findFirstStatuses, isViewableByUser } from "~database/entities/Status"; import { type UserWithRelations, findManyUsers, userToAPI, } from "~database/entities/User"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -40,12 +40,10 @@ export default apiRoute( const { user } = extraData.auth; - const status = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const status = await Note.fromId(id); // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) + if (!status?.isViewableByUser(user)) return errorResponse("Record not found", 404); const { max_id, min_id, since_id, limit } = extraData.parsedRequest; @@ -59,7 +57,9 @@ export default apiRoute( max_id ? lt(liker.id, max_id) : undefined, since_id ? gte(liker.id, since_id) : undefined, min_id ? gt(liker.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${status.id} AND "Like"."likerId" = ${liker.id})`, + sql`EXISTS (SELECT 1 FROM "Like" WHERE "Like"."likedId" = ${ + status.getStatus().id + } AND "Like"."likerId" = ${liker.id})`, ), // @ts-expect-error Yes I KNOW the types are wrong orderBy: (liker, { desc }) => desc(liker.id), diff --git a/server/api/api/v1/statuses/[id]/index.ts b/server/api/api/v1/statuses/[id]/index.ts index b3ec5a01..b3b5449a 100644 --- a/server/api/api/v1/statuses/[id]/index.ts +++ b/server/api/api/v1/statuses/[id]/index.ts @@ -1,19 +1,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { sanitizeHtml } from "@sanitization"; import { config } from "config-manager"; -import { eq } from "drizzle-orm"; import ISO6391 from "iso-639-1"; -import { parse } from "marked"; import { z } from "zod"; -import { - editStatus, - findFirstStatuses, - isViewableByUser, - statusToAPI, -} from "~database/entities/Status"; import { db } from "~drizzle/db"; -import { status } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["GET", "DELETE", "PUT"], @@ -31,7 +22,7 @@ export const meta = applyConfig({ export const schema = z.object({ status: z.string().max(config.validation.max_note_size).optional(), // TODO: Add regex to validate - content_type: z.string().optional(), + content_type: z.string().optional().default("text/plain"), media_ids: z .array(z.string().regex(idValidator)) .max(config.validation.max_media_attachments) @@ -65,49 +56,37 @@ export default apiRoute( const { user } = extraData.auth; - const foundStatus = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const foundStatus = await Note.fromId(id); const config = await extraData.configManager.getConfig(); // Check if user is authorized to view this status (if it's private) - if (!foundStatus || !isViewableByUser(foundStatus, user)) + if (!foundStatus?.isViewableByUser(user)) return errorResponse("Record not found", 404); if (req.method === "GET") { - return jsonResponse(await statusToAPI(foundStatus)); + return jsonResponse(await foundStatus.toAPI(user)); } if (req.method === "DELETE") { - if (foundStatus.authorId !== user?.id) { + if (foundStatus.getAuthor().id !== user?.id) { return errorResponse("Unauthorized", 401); } // TODO: Implement delete and redraft functionality // Delete status and all associated objects - await db.delete(status).where(eq(status.id, id)); + await foundStatus.delete(); - return jsonResponse( - { - ...(await statusToAPI(foundStatus, user)), - // TODO: Add - // text: Add source text - // poll: Add source poll - // media_attachments - }, - 200, - ); + return jsonResponse(await foundStatus.toAPI(user), 200); } if (req.method === "PUT") { - if (foundStatus.authorId !== user?.id) { + if (foundStatus.getAuthor().id !== user?.id) { return errorResponse("Unauthorized", 401); } const { status: statusText, content_type, - "poll[expires_in]": expires_in, "poll[options]": options, media_ids, spoiler_text, @@ -131,22 +110,6 @@ export default apiRoute( ); } - let sanitizedStatus: string; - - if (content_type === "text/markdown") { - sanitizedStatus = await sanitizeHtml( - await parse(statusText ?? ""), - ); - } else if (content_type === "text/x.misskeymarkdown") { - // Parse as MFM - // TODO: Parse as MFM - sanitizedStatus = await sanitizeHtml( - await parse(statusText ?? ""), - ); - } else { - sanitizedStatus = await sanitizeHtml(statusText ?? ""); - } - // Check if status body doesnt match filters if ( config.filters.note_content.some((filter) => @@ -168,20 +131,27 @@ export default apiRoute( } } - // Update status - const newStatus = await editStatus(foundStatus, { - content: sanitizedStatus, - content_type, - media_attachments: media_ids, - spoiler_text: spoiler_text ?? "", - sensitive: sensitive ?? false, - }); + const newNote = await foundStatus.updateFromData( + statusText + ? { + [content_type]: { + content: statusText, + }, + } + : undefined, + undefined, + sensitive, + spoiler_text, + undefined, + undefined, + media_ids, + ); - if (!newStatus) { + if (!newNote) { return errorResponse("Failed to update status", 500); } - return jsonResponse(await statusToAPI(newStatus, user)); + return jsonResponse(await newNote.toAPI(user)); } return jsonResponse({}); diff --git a/server/api/api/v1/statuses/[id]/pin.ts b/server/api/api/v1/statuses/[id]/pin.ts index 545609f4..583dcaab 100644 --- a/server/api/api/v1/statuses/[id]/pin.ts +++ b/server/api/api/v1/statuses/[id]/pin.ts @@ -1,8 +1,8 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { findFirstStatuses, statusToAPI } from "~database/entities/Status"; import { db } from "~drizzle/db"; import { statusToMentions } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -29,15 +29,13 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!user) return errorResponse("Unauthorized", 401); - const foundStatus = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const foundStatus = await Note.fromId(id); // Check if status exists if (!foundStatus) return errorResponse("Record not found", 404); // Check if status is user's - if (foundStatus.authorId !== user.id) + if (foundStatus.getAuthor().id !== user.id) return errorResponse("Unauthorized", 401); // Check if post is already pinned @@ -45,7 +43,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => { await db.query.userPinnedNotes.findFirst({ where: (userPinnedNote, { and, eq }) => and( - eq(userPinnedNote.statusId, foundStatus.id), + eq(userPinnedNote.statusId, foundStatus.getStatus().id), eq(userPinnedNote.userId, user.id), ), }) @@ -54,9 +52,9 @@ export default apiRoute(async (req, matchedRoute, extraData) => { } await db.insert(statusToMentions).values({ - statusId: foundStatus.id, + statusId: foundStatus.getStatus().id, userId: user.id, }); - return jsonResponse(statusToAPI(foundStatus, user)); + return jsonResponse(await foundStatus.toAPI(user)); }); diff --git a/server/api/api/v1/statuses/[id]/reblog.ts b/server/api/api/v1/statuses/[id]/reblog.ts index 079f99f9..77ed97e2 100644 --- a/server/api/api/v1/statuses/[id]/reblog.ts +++ b/server/api/api/v1/statuses/[id]/reblog.ts @@ -1,13 +1,10 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; +import { and, eq } from "drizzle-orm"; import { z } from "zod"; -import { - findFirstStatuses, - isViewableByUser, - statusToAPI, -} from "~database/entities/Status"; import { db } from "~drizzle/db"; import { notification, status } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -41,12 +38,10 @@ export default apiRoute( const { visibility } = extraData.parsedRequest; - const foundStatus = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const foundStatus = await Note.fromId(id); // Check if user is authorized to view this status (if it's private) - if (!foundStatus || !isViewableByUser(foundStatus, user)) + if (!foundStatus?.isViewableByUser(user)) return errorResponse("Record not found", 404); const existingReblog = await db.query.status.findFirst({ @@ -61,42 +56,35 @@ export default apiRoute( return errorResponse("Already reblogged", 422); } - const newReblog = ( - await db - .insert(status) - .values({ - authorId: user.id, - reblogId: foundStatus.id, - visibility, - sensitive: false, - updatedAt: new Date().toISOString(), - applicationId: application?.id ?? null, - }) - .returning() - )[0]; + const newReblog = await Note.insert({ + authorId: user.id, + reblogId: foundStatus.getStatus().id, + visibility, + sensitive: false, + updatedAt: new Date().toISOString(), + applicationId: application?.id ?? null, + }); if (!newReblog) { return errorResponse("Failed to reblog", 500); } - const finalNewReblog = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, newReblog.id), - }); + const finalNewReblog = await Note.fromId(newReblog.id); if (!finalNewReblog) { return errorResponse("Failed to reblog", 500); } // Create notification for reblog if reblogged user is on the same instance - if (foundStatus.author.instanceId === user.instanceId) { + if (foundStatus.getAuthor().instanceId === user.instanceId) { await db.insert(notification).values({ accountId: user.id, - notifiedId: foundStatus.authorId, + notifiedId: foundStatus.getAuthor().id, type: "reblog", - statusId: foundStatus.reblogId, + statusId: foundStatus.getStatus().reblogId, }); } - return jsonResponse(await statusToAPI(finalNewReblog, user)); + return jsonResponse(await finalNewReblog.toAPI(user)); }, ); diff --git a/server/api/api/v1/statuses/[id]/reblogged_by.ts b/server/api/api/v1/statuses/[id]/reblogged_by.ts index d5e19b35..ed1df31d 100644 --- a/server/api/api/v1/statuses/[id]/reblogged_by.ts +++ b/server/api/api/v1/statuses/[id]/reblogged_by.ts @@ -2,12 +2,12 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { fetchTimeline } from "@timelines"; import { z } from "zod"; -import { findFirstStatuses, isViewableByUser } from "~database/entities/Status"; import { type UserWithRelations, findManyUsers, userToAPI, } from "~database/entities/User"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -40,12 +40,10 @@ export default apiRoute( const { user } = extraData.auth; - const status = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const status = await Note.fromId(id); // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) + if (!status?.isViewableByUser(user)) return errorResponse("Record not found", 404); const { max_id, min_id, since_id, limit } = extraData.parsedRequest; @@ -59,7 +57,9 @@ export default apiRoute( max_id ? lt(reblogger.id, max_id) : undefined, since_id ? gte(reblogger.id, since_id) : undefined, min_id ? gt(reblogger.id, min_id) : undefined, - sql`EXISTS (SELECT 1 FROM "Status" WHERE "Status"."reblogId" = ${status.id} AND "Status"."authorId" = ${reblogger.id})`, + sql`EXISTS (SELECT 1 FROM "Status" WHERE "Status"."reblogId" = ${ + status.getStatus().id + } AND "Status"."authorId" = ${reblogger.id})`, ), // @ts-expect-error Yes I KNOW the types are wrong orderBy: (liker, { desc }) => desc(liker.id), diff --git a/server/api/api/v1/statuses/[id]/source.ts b/server/api/api/v1/statuses/[id]/source.ts index 95e10c22..5adedf34 100644 --- a/server/api/api/v1/statuses/[id]/source.ts +++ b/server/api/api/v1/statuses/[id]/source.ts @@ -1,6 +1,6 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse } from "@response"; -import { findFirstStatuses, isViewableByUser } from "~database/entities/Status"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -27,12 +27,10 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!user) return errorResponse("Unauthorized", 401); - const status = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const status = await Note.fromId(id); // Check if user is authorized to view this status (if it's private) - if (!status || !isViewableByUser(status, user)) + if (!status?.isViewableByUser(user)) return errorResponse("Record not found", 404); return errorResponse("Not implemented yet"); diff --git a/server/api/api/v1/statuses/[id]/unfavourite.ts b/server/api/api/v1/statuses/[id]/unfavourite.ts index da708727..73aedfba 100644 --- a/server/api/api/v1/statuses/[id]/unfavourite.ts +++ b/server/api/api/v1/statuses/[id]/unfavourite.ts @@ -1,11 +1,7 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; import { deleteLike } from "~database/entities/Like"; -import { - findFirstStatuses, - isViewableByUser, - statusToAPI, -} from "~database/entities/Status"; +import { Note } from "~packages/database-interface/note"; import type { Status as APIStatus } from "~types/mastodon/status"; export const meta = applyConfig({ @@ -33,19 +29,17 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!user) return errorResponse("Unauthorized", 401); - const foundStatus = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const foundStatus = await Note.fromId(id); // Check if user is authorized to view this status (if it's private) - if (!foundStatus || !isViewableByUser(foundStatus, user)) + if (!foundStatus?.isViewableByUser(user)) return errorResponse("Record not found", 404); - await deleteLike(user, foundStatus); + await deleteLike(user, foundStatus.getStatus()); return jsonResponse({ - ...(await statusToAPI(foundStatus, user)), + ...(await foundStatus.toAPI(user)), favourited: false, - favourites_count: foundStatus.likeCount - 1, + favourites_count: foundStatus.getStatus().likeCount - 1, } as APIStatus); }); diff --git a/server/api/api/v1/statuses/[id]/unpin.ts b/server/api/api/v1/statuses/[id]/unpin.ts index 8ffb88f4..8ae38837 100644 --- a/server/api/api/v1/statuses/[id]/unpin.ts +++ b/server/api/api/v1/statuses/[id]/unpin.ts @@ -1,9 +1,6 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { and, eq } from "drizzle-orm"; -import { findFirstStatuses, statusToAPI } from "~database/entities/Status"; -import { db } from "~drizzle/db"; -import { statusToMentions } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -30,26 +27,18 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!user) return errorResponse("Unauthorized", 401); - const status = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const status = await Note.fromId(id); // Check if status exists if (!status) return errorResponse("Record not found", 404); // Check if status is user's - if (status.authorId !== user.id) return errorResponse("Unauthorized", 401); + if (status.getAuthor().id !== user.id) + return errorResponse("Unauthorized", 401); - await db - .delete(statusToMentions) - .where( - and( - eq(statusToMentions.statusId, status.id), - eq(statusToMentions.userId, user.id), - ), - ); + await status.unpin(user); if (!status) return errorResponse("Record not found", 404); - return jsonResponse(statusToAPI(status, user)); + return jsonResponse(await status.toAPI(user)); }); diff --git a/server/api/api/v1/statuses/[id]/unreblog.ts b/server/api/api/v1/statuses/[id]/unreblog.ts index 36c6cf63..f38a39ba 100644 --- a/server/api/api/v1/statuses/[id]/unreblog.ts +++ b/server/api/api/v1/statuses/[id]/unreblog.ts @@ -1,13 +1,8 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { eq } from "drizzle-orm"; -import { - findFirstStatuses, - isViewableByUser, - statusToAPI, -} from "~database/entities/Status"; -import { db } from "~drizzle/db"; +import { and, eq } from "drizzle-orm"; import { status } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; import type { Status as APIStatus } from "~types/mastodon/status"; export const meta = applyConfig({ @@ -35,28 +30,28 @@ export default apiRoute(async (req, matchedRoute, extraData) => { if (!user) return errorResponse("Unauthorized", 401); - const foundStatus = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, id), - }); + const foundStatus = await Note.fromId(id); // Check if user is authorized to view this status (if it's private) - if (!foundStatus || !isViewableByUser(foundStatus, user)) + if (!foundStatus?.isViewableByUser(user)) return errorResponse("Record not found", 404); - const existingReblog = await findFirstStatuses({ - where: (status, { eq }) => - eq(status.authorId, user.id) && eq(status.reblogId, foundStatus.id), - }); + const existingReblog = await Note.fromSql( + and( + eq(status.authorId, user.id), + eq(status.reblogId, foundStatus.getStatus().id), + ), + ); if (!existingReblog) { return errorResponse("Not already reblogged", 422); } - await db.delete(status).where(eq(status.id, existingReblog.id)); + await existingReblog.delete(); return jsonResponse({ - ...(await statusToAPI(foundStatus, user)), + ...(await foundStatus.toAPI(user)), reblogged: false, - reblogs_count: foundStatus.reblogCount - 1, + reblogs_count: foundStatus.getStatus().reblogCount - 1, } as APIStatus); }); diff --git a/server/api/api/v1/statuses/index.ts b/server/api/api/v1/statuses/index.ts index e2d7da29..d24f5138 100644 --- a/server/api/api/v1/statuses/index.ts +++ b/server/api/api/v1/statuses/index.ts @@ -6,14 +6,9 @@ import ISO6391 from "iso-639-1"; import { parse } from "marked"; import { z } from "zod"; import type { StatusWithRelations } from "~database/entities/Status"; -import { - createNewStatus, - federateStatus, - findFirstStatuses, - parseTextMentions, - statusToAPI, -} from "~database/entities/Status"; +import { federateNote, parseTextMentions } from "~database/entities/Status"; import { db } from "~drizzle/db"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["POST"], @@ -125,28 +120,10 @@ export default apiRoute( } // Get reply account and status if exists - let replyStatus: StatusWithRelations | null = null; - let quote: StatusWithRelations | null = null; - - if (in_reply_to_id) { - replyStatus = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, in_reply_to_id), - }).catch(() => null); - - if (!replyStatus) { - return errorResponse("Reply status not found", 404); - } - } - - if (quote_id) { - quote = await findFirstStatuses({ - where: (status, { eq }) => eq(status.id, quote_id), - }).catch(() => null); - - if (!quote) { - return errorResponse("Quote status not found", 404); - } - } + const replyStatus: StatusWithRelations | null = + (await Note.fromId(in_reply_to_id ?? null))?.getStatus() ?? null; + const quote: StatusWithRelations | null = + (await Note.fromId(quote_id ?? null))?.getStatus() ?? null; // Check if status body doesnt match filters if ( @@ -171,7 +148,7 @@ export default apiRoute( const mentions = await parseTextMentions(sanitizedStatus); - const newStatus = await createNewStatus( + const newNote = await Note.fromData( user, { [content_type]: { @@ -185,19 +162,19 @@ export default apiRoute( undefined, mentions, media_ids, - replyStatus ?? undefined, - quote ?? undefined, + in_reply_to_id ?? undefined, + quote_id ?? undefined, application ?? undefined, ); - if (!newStatus) { + if (!newNote) { return errorResponse("Failed to create status", 500); } if (federate) { - await federateStatus(newStatus); + await federateNote(newNote); } - return jsonResponse(await statusToAPI(newStatus, user)); + return jsonResponse(await newNote.toAPI(user)); }, ); diff --git a/server/api/api/v1/timelines/home.ts b/server/api/api/v1/timelines/home.ts index 1c706b14..d3fd0941 100644 --- a/server/api/api/v1/timelines/home.ts +++ b/server/api/api/v1/timelines/home.ts @@ -1,12 +1,9 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; +import { and, eq, gt, gte, lt, or, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type StatusWithRelations, - findManyStatuses, - statusToAPI, -} from "~database/entities/Status"; +import { status } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -38,38 +35,29 @@ export default apiRoute( if (!user) return errorResponse("Unauthorized", 401); - const { objects, link } = await fetchTimeline( - findManyStatuses, - { - // @ts-expect-error Yes I KNOW the types are wrong - where: (status, { lt, gte, gt, and, or, eq, inArray, sql }) => - and( - and( - max_id ? lt(status.id, max_id) : undefined, - since_id ? gte(status.id, since_id) : undefined, - min_id ? gt(status.id, min_id) : undefined, - ), - or( - eq(status.authorId, user.id), - // All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id - // WHERE format (... = ...) - sql`EXISTS (SELECT 1 FROM "StatusToMentions" WHERE "StatusToMentions"."statusId" = ${status.id} AND "StatusToMentions"."userId" = ${user.id})`, - // All statuses from users that the user is following - // WHERE format (... = ...) - sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`, - ), - ), - limit, - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (status, { desc }) => desc(status.id), - }, - req, + const { objects, link } = await Timeline.getNoteTimeline( + and( + and( + max_id ? lt(status.id, max_id) : undefined, + since_id ? gte(status.id, since_id) : undefined, + min_id ? gt(status.id, min_id) : undefined, + ), + or( + eq(status.authorId, user.id), + // All statuses where the user is mentioned, using table _StatusToUser which has a: status.id and b: user.id + // WHERE format (... = ...) + sql`EXISTS (SELECT 1 FROM "StatusToMentions" WHERE "StatusToMentions"."statusId" = ${status.id} AND "StatusToMentions"."userId" = ${user.id})`, + // All statuses from users that the user is following + // WHERE format (... = ...) + sql`EXISTS (SELECT 1 FROM "Relationship" WHERE "Relationship"."subjectId" = ${status.authorId} AND "Relationship"."ownerId" = ${user.id} AND "Relationship"."following" = true)`, + ), + ), + limit, + req.url, ); return jsonResponse( - await Promise.all( - objects.map(async (status) => statusToAPI(status, user)), - ), + await Promise.all(objects.map(async (note) => note.toAPI(user))), 200, { Link: link, diff --git a/server/api/api/v1/timelines/public.ts b/server/api/api/v1/timelines/public.ts index fbc8c9af..c5b188df 100644 --- a/server/api/api/v1/timelines/public.ts +++ b/server/api/api/v1/timelines/public.ts @@ -1,13 +1,9 @@ import { apiRoute, applyConfig, idValidator } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { fetchTimeline } from "@timelines"; -import { sql } from "drizzle-orm"; +import { and, gt, gte, lt, sql } from "drizzle-orm"; import { z } from "zod"; -import { - type StatusWithRelations, - findManyStatuses, - statusToAPI, -} from "~database/entities/Status"; +import { status } from "~drizzle/schema"; +import { Timeline } from "~packages/database-interface/timeline"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -41,39 +37,28 @@ export default apiRoute( return errorResponse("Cannot use both local and remote", 400); } - const { objects, link } = await fetchTimeline( - findManyStatuses, - { - // @ts-expect-error Yes I KNOW the types are wrong - where: (status, { lt, gte, gt, and, isNull, isNotNull }) => - and( - max_id ? lt(status.id, max_id) : undefined, - since_id ? gte(status.id, since_id) : undefined, - min_id ? gt(status.id, min_id) : undefined, - // use authorId to grab user, then use user.instanceId to filter local/remote statuses - remote - ? sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${status.authorId} AND "User"."instanceId" IS NOT NULL)` - : undefined, - local - ? sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${status.authorId} AND "User"."instanceId" IS NULL)` - : undefined, - only_media - ? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})` - : undefined, - ), - limit, - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (status, { desc }) => desc(status.id), - }, - req, + const { objects, link } = await Timeline.getNoteTimeline( + and( + max_id ? lt(status.id, max_id) : undefined, + since_id ? gte(status.id, since_id) : undefined, + min_id ? gt(status.id, min_id) : undefined, + // use authorId to grab user, then use user.instanceId to filter local/remote statuses + remote + ? sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${status.authorId} AND "User"."instanceId" IS NOT NULL)` + : undefined, + local + ? sql`EXISTS (SELECT 1 FROM "User" WHERE "User"."id" = ${status.authorId} AND "User"."instanceId" IS NULL)` + : undefined, + only_media + ? sql`EXISTS (SELECT 1 FROM "Attachment" WHERE "Attachment"."statusId" = ${status.id})` + : undefined, + ), + limit, + req.url, ); return jsonResponse( - await Promise.all( - objects.map(async (status) => - statusToAPI(status, user || undefined), - ), - ), + await Promise.all(objects.map(async (note) => note.toAPI(user))), 200, { Link: link, diff --git a/server/api/api/v2/search/index.ts b/server/api/api/v2/search/index.ts index ff4ae758..f129f5a7 100644 --- a/server/api/api/v2/search/index.ts +++ b/server/api/api/v2/search/index.ts @@ -2,9 +2,8 @@ import { apiRoute, applyConfig } from "@api"; import { dualLogger } from "@loggers"; import { MeiliIndexType, meilisearch } from "@meilisearch"; import { errorResponse, jsonResponse } from "@response"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import { z } from "zod"; -import { findManyStatuses, statusToAPI } from "~database/entities/Status"; import { findFirstUser, findManyUsers, @@ -12,7 +11,8 @@ import { userToAPI, } from "~database/entities/User"; import { db } from "~drizzle/db"; -import { instance, user } from "~drizzle/schema"; +import { instance, status, user } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; import { LogLevel } from "~packages/log-manager"; export const meta = applyConfig({ @@ -178,29 +178,27 @@ export default apiRoute( orderBy: (user, { desc }) => desc(user.createdAt), }); - const statuses = await findManyStatuses({ - where: (status, { and, eq, inArray }) => - and( - inArray( - status.id, - statusResults.map((hit) => hit.id), - ), - account_id ? eq(status.authorId, account_id) : undefined, - self - ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ - self?.id - } AND Relationships.following = ${ - following ? true : false - } AND Relationships.ownerId = ${status.authorId})` - : undefined, + const statuses = await Note.manyFromSql( + and( + inArray( + status.id, + statusResults.map((hit) => hit.id), ), - orderBy: (status, { desc }) => desc(status.createdAt), - }); + account_id ? eq(status.authorId, account_id) : undefined, + self + ? sql`EXISTS (SELECT 1 FROM Relationships WHERE Relationships.subjectId = ${ + self?.id + } AND Relationships.following = ${ + following ? true : false + } AND Relationships.ownerId = ${status.authorId})` + : undefined, + ), + ); return jsonResponse({ accounts: accounts.map((account) => userToAPI(account)), statuses: await Promise.all( - statuses.map((status) => statusToAPI(status)), + statuses.map((status) => status.toAPI(self)), ), hashtags: [], }); diff --git a/server/api/objects/[uuid]/index.ts b/server/api/objects/[uuid]/index.ts index e687d4e8..0e42b27b 100644 --- a/server/api/objects/[uuid]/index.ts +++ b/server/api/objects/[uuid]/index.ts @@ -1,14 +1,11 @@ import { apiRoute, applyConfig } from "@api"; import { errorResponse, jsonResponse } from "@response"; -import { sql } from "drizzle-orm"; -import { likeToLysand, type Like } from "~database/entities/Like"; -import { - findFirstStatuses, - statusToLysand, - type StatusWithRelations, -} from "~database/entities/Status"; -import { db } from "~drizzle/db"; +import { and, eq, inArray, sql } from "drizzle-orm"; import type * as Lysand from "lysand-types"; +import { type Like, likeToLysand } from "~database/entities/Like"; +import { db } from "~drizzle/db"; +import { status } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -25,18 +22,16 @@ export const meta = applyConfig({ export default apiRoute(async (req, matchedRoute) => { const uuid = matchedRoute.params.uuid; - let foundObject: StatusWithRelations | Like | null = null; + let foundObject: Note | Like | null = null; let apiObject: Lysand.Entity | null = null; - foundObject = - (await findFirstStatuses({ - where: (status, { eq, and, inArray }) => - and( - eq(status.id, uuid), - inArray(status.visibility, ["public", "unlisted"]), - ), - })) ?? null; - apiObject = foundObject ? statusToLysand(foundObject) : null; + foundObject = await Note.fromSql( + and( + eq(status.id, uuid), + inArray(status.visibility, ["public", "unlisted"]), + ), + ); + apiObject = foundObject ? foundObject.toLysand() : null; if (!foundObject) { foundObject = diff --git a/server/api/users/[uuid]/inbox/index.ts b/server/api/users/[uuid]/inbox/index.ts index 832d078f..9705deaa 100644 --- a/server/api/users/[uuid]/inbox/index.ts +++ b/server/api/users/[uuid]/inbox/index.ts @@ -3,7 +3,7 @@ import { dualLogger } from "@loggers"; import { errorResponse, response } from "@response"; import { eq } from "drizzle-orm"; import type * as Lysand from "lysand-types"; -import { resolveStatus } from "~database/entities/Status"; +import { resolveNote } from "~database/entities/Status"; import { findFirstUser, getRelationshipToOtherUser, @@ -126,16 +126,14 @@ export default apiRoute(async (req, matchedRoute, extraData) => { return errorResponse("Author not found", 400); } - const newStatus = await resolveStatus(undefined, note).catch( - (e) => { - dualLogger.logError( - LogLevel.ERROR, - "Inbox.NoteResolve", - e as Error, - ); - return null; - }, - ); + const newStatus = await resolveNote(undefined, note).catch((e) => { + dualLogger.logError( + LogLevel.ERROR, + "Inbox.NoteResolve", + e as Error, + ); + return null; + }); if (!newStatus) { return errorResponse("Failed to add status", 500); diff --git a/server/api/users/[uuid]/outbox/index.ts b/server/api/users/[uuid]/outbox/index.ts index 5d2c97c7..97a1cd5d 100644 --- a/server/api/users/[uuid]/outbox/index.ts +++ b/server/api/users/[uuid]/outbox/index.ts @@ -1,9 +1,9 @@ import { apiRoute, applyConfig } from "@api"; import { jsonResponse } from "@response"; import { and, count, eq, inArray } from "drizzle-orm"; -import { findManyStatuses, statusToLysand } from "~database/entities/Status"; import { db } from "~drizzle/db"; import { status } from "~drizzle/schema"; +import { Note } from "~packages/database-interface/note"; export const meta = applyConfig({ allowedMethods: ["GET"], @@ -23,18 +23,17 @@ export default apiRoute(async (req, matchedRoute, extraData) => { const config = await extraData.configManager.getConfig(); const host = new URL(config.http.base_url).hostname; - const statuses = await findManyStatuses({ - where: (status, { eq, and, inArray }) => - and( - eq(status.authorId, uuid), - inArray(status.visibility, ["public", "unlisted"]), - ), - offset: 20 * (pageNumber - 1), - limit: 20, - orderBy: (status, { desc }) => desc(status.createdAt), - }); + const notes = await Note.manyFromSql( + and( + eq(status.authorId, uuid), + inArray(status.visibility, ["public", "unlisted"]), + ), + undefined, + 20, + 20 * (pageNumber - 1), + ); - const totalStatuses = await db + const totalNotes = await db .select({ count: count(), }) @@ -49,11 +48,11 @@ export default apiRoute(async (req, matchedRoute, extraData) => { return jsonResponse({ first: `${host}/users/${uuid}/outbox?page=1`, last: `${host}/users/${uuid}/outbox?page=1`, - total_items: totalStatuses, + total_items: totalNotes, // Server actor author: new URL("/users/actor", config.http.base_url).toString(), next: - statuses.length === 20 + notes.length === 20 ? new URL( `/users/${uuid}/outbox?page=${pageNumber + 1}`, config.http.base_url, @@ -66,6 +65,6 @@ export default apiRoute(async (req, matchedRoute, extraData) => { config.http.base_url, ).toString() : undefined, - items: statuses.map((s) => statusToLysand(s)), + items: notes.map((note) => note.toLysand()), }); }); diff --git a/tests/utils.ts b/tests/utils.ts index d46b3612..824c3a56 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; -import { inArray, like } from "drizzle-orm"; -import { type Status, findManyStatuses } from "~database/entities/Status"; +import { asc, inArray, like } from "drizzle-orm"; +import type { Status } from "~database/entities/Status"; import { type User, type UserWithRelations, @@ -9,6 +9,7 @@ import { import { db } from "~drizzle/db"; import { status, token, user } from "~drizzle/schema"; import { server } from "~index"; +import { Note } from "~packages/database-interface/note"; /** * This allows us to send a test request to the server even when it isnt running * CURRENTLY NOT WORKING, NEEDS TO BE FIXED @@ -86,20 +87,15 @@ export const getTestStatuses = async ( const statuses: Status[] = []; for (let i = 0; i < count; i++) { - const newStatus = ( - await db - .insert(status) - .values({ - content: `${i} ${randomBytes(32).toString("hex")}`, - authorId: user.id, - sensitive: false, - updatedAt: new Date().toISOString(), - visibility: "public", - applicationId: null, - ...partial, - }) - .returning() - )[0]; + const newStatus = await Note.insert({ + content: `${i} ${randomBytes(32).toString("hex")}`, + authorId: user.id, + sensitive: false, + updatedAt: new Date().toISOString(), + visibility: "public", + applicationId: null, + ...partial, + }); if (!newStatus) { throw new Error("Failed to create test status"); @@ -108,14 +104,13 @@ export const getTestStatuses = async ( statuses.push(newStatus); } - const statusesWithRelations = await findManyStatuses({ - where: (status, { inArray }) => + return ( + await Note.manyFromSql( inArray( status.id, statuses.map((s) => s.id), ), - orderBy: (status, { asc }) => asc(status.id), - }); - - return statusesWithRelations; + asc(status.id), + ) + ).map((n) => n.getStatus()); }; diff --git a/utils/timelines.ts b/utils/timelines.ts index 2e44690d..c291ee0c 100644 --- a/utils/timelines.ts +++ b/utils/timelines.ts @@ -3,17 +3,17 @@ import type { Notification, findManyNotifications, } from "~database/entities/Notification"; -import type { Status, findManyStatuses } from "~database/entities/Status"; +import type { Status, findManyNotes } from "~database/entities/Status"; import type { User, findManyUsers } from "~database/entities/User"; import type { db } from "~drizzle/db"; export async function fetchTimeline( model: - | typeof findManyStatuses + | typeof findManyNotes | typeof findManyUsers | typeof findManyNotifications, args: - | Parameters[0] + | Parameters[0] | Parameters[0] | Parameters[0], req: Request,