diff --git a/api/api/v1/notifications/:id/dismiss.ts b/api/api/v1/notifications/:id/dismiss.ts index 66ff9319..bf63b486 100644 --- a/api/api/v1/notifications/:id/dismiss.ts +++ b/api/api/v1/notifications/:id/dismiss.ts @@ -1,8 +1,7 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; -import { db } from "@versia/kit/db"; -import { Notifications, RolePermissions } from "@versia/kit/tables"; -import { eq } from "drizzle-orm"; +import { Notification } from "@versia/kit/db"; +import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; import { ErrorSchema } from "~/types/api"; @@ -59,12 +58,15 @@ export default apiRoute((app) => return context.json({ error: "Unauthorized" }, 401); } - await db - .update(Notifications) - .set({ - dismissed: true, - }) - .where(eq(Notifications.id, id)); + const notification = await Notification.fromId(id); + + if (!notification) { + return context.json({ error: "Notification not found" }, 404); + } + + await notification.update({ + dismissed: true, + }); return context.newResponse(null, 200); }), diff --git a/api/api/v1/notifications/:id/index.ts b/api/api/v1/notifications/:id/index.ts index bb7f771d..d4567de5 100644 --- a/api/api/v1/notifications/:id/index.ts +++ b/api/api/v1/notifications/:id/index.ts @@ -1,13 +1,8 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; -import { Note, User } from "@versia/kit/db"; +import { Note, Notification, User } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables"; -import type { SQL } from "drizzle-orm"; import { z } from "zod"; -import { - findManyNotifications, - notificationToApi, -} from "~/classes/functions/notification"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -101,21 +96,12 @@ export default apiRoute((app) => return context.json({ error: "Unauthorized" }, 401); } - const notification = ( - await findManyNotifications( - { - where: (notification, { eq }): SQL | undefined => - eq(notification.id, id), - limit: 1, - }, - user.id, - ) - )[0]; + const notification = await Notification.fromId(id, user.id); if (!notification) { return context.json({ error: "Notification not found" }, 404); } - return context.json(await notificationToApi(notification), 200); + return context.json(await notification.toApi(), 200); }), ); diff --git a/api/api/v1/notifications/clear/index.ts b/api/api/v1/notifications/clear/index.ts index 03b0be84..08d492e7 100644 --- a/api/api/v1/notifications/clear/index.ts +++ b/api/api/v1/notifications/clear/index.ts @@ -1,8 +1,6 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; -import { db } from "@versia/kit/db"; -import { Notifications, RolePermissions } from "@versia/kit/tables"; -import { eq } from "drizzle-orm"; +import { RolePermissions } from "@versia/kit/tables"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -47,12 +45,7 @@ export default apiRoute((app) => return context.json({ error: "Unauthorized" }, 401); } - await db - .update(Notifications) - .set({ - dismissed: true, - }) - .where(eq(Notifications.notifiedId, user.id)); + await user.clearAllNotifications(); return context.newResponse(null, 200); }), diff --git a/api/api/v1/notifications/destroy_multiple/index.ts b/api/api/v1/notifications/destroy_multiple/index.ts index e73c2005..07761614 100644 --- a/api/api/v1/notifications/destroy_multiple/index.ts +++ b/api/api/v1/notifications/destroy_multiple/index.ts @@ -1,8 +1,6 @@ import { apiRoute, applyConfig, auth } from "@/api"; import { createRoute } from "@hono/zod-openapi"; -import { db } from "@versia/kit/db"; -import { Notifications, RolePermissions } from "@versia/kit/tables"; -import { and, eq, inArray } from "drizzle-orm"; +import { RolePermissions } from "@versia/kit/tables"; import { z } from "zod"; import { ErrorSchema } from "~/types/api"; @@ -60,17 +58,7 @@ export default apiRoute((app) => const { "ids[]": ids } = context.req.valid("query"); - await db - .update(Notifications) - .set({ - dismissed: true, - }) - .where( - and( - inArray(Notifications.id, ids), - eq(Notifications.notifiedId, user.id), - ), - ); + await user.clearSomeNotifications(ids); return context.newResponse(null, 200); }), diff --git a/api/api/v1/notifications/index.ts b/api/api/v1/notifications/index.ts index b23635f0..f4df3540 100644 --- a/api/api/v1/notifications/index.ts +++ b/api/api/v1/notifications/index.ts @@ -1,15 +1,9 @@ import { apiRoute, applyConfig, auth, idValidator } from "@/api"; -import { fetchTimeline } from "@/timelines"; import { createRoute } from "@hono/zod-openapi"; -import { Note, User } from "@versia/kit/db"; -import { RolePermissions } from "@versia/kit/tables"; -import { type SQL, sql } from "drizzle-orm"; +import { Note, Timeline, User } from "@versia/kit/db"; +import { Notifications, RolePermissions } from "@versia/kit/tables"; +import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm"; import { z } from "zod"; -import { - findManyNotifications, - notificationToApi, -} from "~/classes/functions/notification"; -import type { NotificationWithRelations } from "~/classes/functions/notification"; import { ErrorSchema } from "~/types/api"; export const meta = applyConfig({ @@ -150,65 +144,48 @@ export default apiRoute((app) => types, } = context.req.valid("query"); - const { objects, link } = - await fetchTimeline( - findManyNotifications, - { - where: ( - // @ts-expect-error Yes I KNOW the types are wrong - notification, - // @ts-expect-error Yes I KNOW the types are wrong - { lt, gte, gt, and, eq, not, inArray }, - ): SQL | undefined => - and( - max_id ? lt(notification.id, max_id) : undefined, - since_id - ? gte(notification.id, since_id) - : undefined, - min_id ? gt(notification.id, min_id) : undefined, - eq(notification.notifiedId, user.id), - eq(notification.dismissed, false), - account_id - ? eq(notification.accountId, account_id) - : undefined, - not(eq(notification.accountId, user.id)), - types - ? inArray(notification.type, types) - : undefined, - exclude_types - ? not(inArray(notification.type, exclude_types)) - : undefined, - // Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId) - // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE) - // Filters table has a userId and a context which is an array - sql`NOT EXISTS ( - SELECT 1 - FROM "Filters" - WHERE "Filters"."userId" = ${user.id} - AND "Filters"."filter_action" = 'hide' - AND EXISTS ( - SELECT 1 - FROM "FilterKeywords", "Notifications" as "n_inner", "Notes" - WHERE "FilterKeywords"."filterId" = "Filters"."id" - AND "n_inner"."noteId" = "Notes"."id" - AND "Notes"."content" LIKE - '%' || "FilterKeywords"."keyword" || '%' - AND "n_inner"."id" = "Notifications"."id" - ) - AND "Filters"."context" @> ARRAY['notifications'] - )`, - ), - limit, - // @ts-expect-error Yes I KNOW the types are wrong - orderBy: (notification, { desc }): SQL | undefined => - desc(notification.id), - }, - context.req.raw, - user.id, - ); + const { objects, link } = await Timeline.getNotificationTimeline( + and( + max_id ? lt(Notifications.id, max_id) : undefined, + since_id ? gte(Notifications.id, since_id) : undefined, + min_id ? gt(Notifications.id, min_id) : undefined, + eq(Notifications.notifiedId, user.id), + eq(Notifications.dismissed, false), + account_id + ? eq(Notifications.accountId, account_id) + : undefined, + not(eq(Notifications.accountId, user.id)), + types ? inArray(Notifications.type, types) : undefined, + exclude_types + ? not(inArray(Notifications.type, exclude_types)) + : undefined, + // Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId) + // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE) + // Filters table has a userId and a context which is an array + sql`NOT EXISTS ( + SELECT 1 + FROM "Filters" + WHERE "Filters"."userId" = ${user.id} + AND "Filters"."filter_action" = 'hide' + AND EXISTS ( + SELECT 1 + FROM "FilterKeywords", "Notifications" as "n_inner", "Notes" + WHERE "FilterKeywords"."filterId" = "Filters"."id" + AND "n_inner"."noteId" = "Notes"."id" + AND "Notes"."content" LIKE + '%' || "FilterKeywords"."keyword" || '%' + AND "n_inner"."id" = "Notifications"."id" + ) + AND "Filters"."context" @> ARRAY['notifications'] + )`, + ), + limit, + context.req.url, + user?.id, + ); return context.json( - await Promise.all(objects.map((n) => notificationToApi(n))), + await Promise.all(objects.map((n) => n.toApi())), 200, { Link: link, diff --git a/api/api/v1/statuses/:id/reblog.ts b/api/api/v1/statuses/:id/reblog.ts index 1d6bca26..1643c26d 100644 --- a/api/api/v1/statuses/:id/reblog.ts +++ b/api/api/v1/statuses/:id/reblog.ts @@ -1,7 +1,7 @@ import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api"; import { createRoute } from "@hono/zod-openapi"; -import { Note, db } from "@versia/kit/db"; -import { Notes, Notifications, RolePermissions } from "@versia/kit/tables"; +import { Note, Notification } from "@versia/kit/db"; +import { Notes, RolePermissions } from "@versia/kit/tables"; import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { ErrorSchema } from "~/types/api"; @@ -141,7 +141,7 @@ export default apiRoute((app) => } if (foundStatus.author.isLocal() && user.isLocal()) { - await db.insert(Notifications).values({ + await Notification.insert({ accountId: user.id, notifiedId: foundStatus.author.id, type: "reblog", diff --git a/classes/database/like.ts b/classes/database/like.ts index 083c13cd..e3e0aed1 100644 --- a/classes/database/like.ts +++ b/classes/database/like.ts @@ -1,11 +1,12 @@ import { RolePermission } from "@versia/client/types"; import type { Delete, LikeExtension } from "@versia/federation/types"; import { db } from "@versia/kit/db"; -import { Likes } from "@versia/kit/tables"; +import { Likes, Notifications } from "@versia/kit/tables"; import { type InferInsertModel, type InferSelectModel, type SQL, + and, desc, eq, inArray, @@ -139,6 +140,19 @@ export class Like extends BaseInterface { return this.data.id; } + public async clearRelatedNotifications(): Promise { + await db + .delete(Notifications) + .where( + and( + eq(Notifications.accountId, this.id), + eq(Notifications.type, "favourite"), + eq(Notifications.notifiedId, this.data.liked.authorId), + eq(Notifications.noteId, this.data.liked.id), + ), + ); + } + public getUri(): URL { return new URL(`/objects/${this.data.id}`, config.http.base_url); } diff --git a/classes/database/note.ts b/classes/database/note.ts index a46471a2..103f509e 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -14,13 +14,12 @@ import type { Delete as VersiaDelete, Note as VersiaNote, } from "@versia/federation/types"; -import { db } from "@versia/kit/db"; +import { Notification, db } from "@versia/kit/db"; import { Attachments, EmojiToNote, NoteToMentions, Notes, - Notifications, Users, } from "@versia/kit/tables"; import { @@ -469,7 +468,7 @@ export class Note extends BaseInterface { // Send notifications for mentioned local users for (const mention of parsedMentions ?? []) { if (mention.isLocal()) { - await db.insert(Notifications).values({ + await Notification.insert({ accountId: data.author.id, notifiedId: mention.id, type: "mention", diff --git a/classes/database/notification.ts b/classes/database/notification.ts new file mode 100644 index 00000000..8f42bc6f --- /dev/null +++ b/classes/database/notification.ts @@ -0,0 +1,243 @@ +import type { Notification as APINotification } from "@versia/client/types"; +import { Note, User, db } from "@versia/kit/db"; +import { Notifications } from "@versia/kit/tables"; +import { + type InferInsertModel, + type InferSelectModel, + type SQL, + desc, + eq, + inArray, +} from "drizzle-orm"; +import { z } from "zod"; +import { MediaBackendType } from "~/packages/config-manager/config.type"; +import { config } from "~/packages/config-manager/index.ts"; +import type { StatusWithRelations } from "../functions/status.ts"; +import { + type UserWithRelations, + transformOutputToUserWithRelations, + userExtrasTemplate, + userRelations, +} from "../functions/user.ts"; +import { BaseInterface } from "./base.ts"; + +export type NotificationType = InferSelectModel & { + status: StatusWithRelations | null; + account: UserWithRelations; +}; + +export class Notification extends BaseInterface< + typeof Notifications, + NotificationType +> { + public static schema: z.ZodType = z.object({ + account: z.lazy(() => User.schema).nullable(), + created_at: z.string(), + id: z.string().uuid(), + status: z.lazy(() => Note.schema).optional(), + // TODO: Add reactions + type: z.enum([ + "mention", + "status", + "follow", + "follow_request", + "reblog", + "poll", + "favourite", + "update", + "admin.sign_up", + "admin.report", + "chat", + "pleroma:chat_mention", + "pleroma:emoji_reaction", + "pleroma:event_reminder", + "pleroma:participation_request", + "pleroma:participation_accepted", + "move", + "group_reblog", + "group_favourite", + "user_approved", + ]), + target: z.lazy(() => User.schema).optional(), + }); + + public async reload(): Promise { + const reloaded = await Notification.fromId(this.data.id); + + if (!reloaded) { + throw new Error("Failed to reload notification"); + } + + this.data = reloaded.data; + } + + public static async fromId( + id: string | null, + userId?: string, + ): Promise { + if (!id) { + return null; + } + + return await Notification.fromSql( + eq(Notifications.id, id), + undefined, + userId, + ); + } + + public static async fromIds( + ids: string[], + userId?: string, + ): Promise { + return await Notification.manyFromSql( + inArray(Notifications.id, ids), + undefined, + undefined, + undefined, + undefined, + userId, + ); + } + + public static async fromSql( + sql: SQL | undefined, + orderBy: SQL | undefined = desc(Notifications.id), + userId?: string, + ): Promise { + const found = await db.query.Notifications.findFirst({ + where: sql, + orderBy, + with: { + account: { + with: { + ...userRelations, + }, + extras: userExtrasTemplate("Notifications_account"), + }, + }, + }); + + if (!found) { + return null; + } + return new Notification({ + ...found, + account: transformOutputToUserWithRelations(found.account), + status: (await Note.fromId(found.noteId, userId))?.data ?? null, + }); + } + + public static async manyFromSql( + sql: SQL | undefined, + orderBy: SQL | undefined = desc(Notifications.id), + limit?: number, + offset?: number, + extra?: Parameters[0], + userId?: string, + ): Promise { + const found = await db.query.Notifications.findMany({ + where: sql, + orderBy, + limit, + offset, + with: { + ...extra?.with, + account: { + with: { + ...userRelations, + }, + extras: userExtrasTemplate("Notifications_account"), + }, + }, + extras: extra?.extras, + }); + + return ( + await Promise.all( + found.map(async (notif) => ({ + ...notif, + account: transformOutputToUserWithRelations(notif.account), + status: + (await Note.fromId(notif.noteId, userId))?.data ?? null, + })), + ) + ).map((s) => new Notification(s)); + } + + public async update( + newAttachment: Partial, + ): Promise { + await db + .update(Notifications) + .set(newAttachment) + .where(eq(Notifications.id, this.id)); + + const updated = await Notification.fromId(this.data.id); + + if (!updated) { + throw new Error("Failed to update notification"); + } + + this.data = updated.data; + return updated.data; + } + + public save(): Promise { + return this.update(this.data); + } + + public async delete(ids?: string[]): Promise { + if (Array.isArray(ids)) { + await db + .delete(Notifications) + .where(inArray(Notifications.id, ids)); + } else { + await db.delete(Notifications).where(eq(Notifications.id, this.id)); + } + } + + public static async insert( + data: InferInsertModel, + ): Promise { + const inserted = ( + await db.insert(Notifications).values(data).returning() + )[0]; + + const notification = await Notification.fromId(inserted.id); + + if (!notification) { + throw new Error("Failed to insert notification"); + } + + return notification; + } + + public get id(): string { + return this.data.id; + } + + public static getUrl(name: string): string { + if (config.media.backend === MediaBackendType.Local) { + return new URL(`/media/${name}`, config.http.base_url).toString(); + } + if (config.media.backend === MediaBackendType.S3) { + return new URL(`/${name}`, config.s3.public_url).toString(); + } + return ""; + } + + public async toApi(): Promise { + const account = new User(this.data.account); + + return { + account: account.toApi(), + created_at: new Date(this.data.createdAt).toISOString(), + id: this.data.id, + type: this.data.type, + status: this.data.status + ? await new Note(this.data.status).toApi(account) + : undefined, + }; + } +} diff --git a/classes/database/timeline.ts b/classes/database/timeline.ts index d4ea4190..fca146e2 100644 --- a/classes/database/timeline.ts +++ b/classes/database/timeline.ts @@ -1,15 +1,17 @@ -import { Notes, Users } from "@versia/kit/tables"; +import { Notes, Notifications, Users } from "@versia/kit/tables"; import { type SQL, gt } from "drizzle-orm"; import { config } from "~/packages/config-manager"; import { Note } from "./note.ts"; +import { Notification } from "./notification.ts"; import { User } from "./user.ts"; enum TimelineType { Note = "Note", User = "User", + Notification = "Notification", } -export class Timeline { +export class Timeline { public constructor(private type: TimelineType) {} public static getNoteTimeline( @@ -38,6 +40,17 @@ export class Timeline { ); } + public static getNotificationTimeline( + sql: SQL | undefined, + limit: number, + url: string, + userId?: string, + ): Promise<{ link: string; objects: Notification[] }> { + return new Timeline( + TimelineType.Notification, + ).fetchTimeline(sql, limit, url, userId); + } + private async fetchObjects( sql: SQL | undefined, limit: number, @@ -58,6 +71,15 @@ export class Timeline { undefined, limit, )) as Type[]; + case TimelineType.Notification: + return (await Notification.manyFromSql( + sql, + undefined, + limit, + undefined, + undefined, + userId, + )) as Type[]; } } @@ -92,6 +114,14 @@ export class Timeline { )), ); break; + case TimelineType.Notification: + linkHeader.push( + ...(await Timeline.fetchNotificationLinkHeader( + objects as Notification[], + urlWithoutQuery, + limit, + )), + ); } } @@ -154,6 +184,39 @@ export class Timeline { return linkHeader; } + private static async fetchNotificationLinkHeader( + notifications: Notification[], + urlWithoutQuery: string, + limit: number, + ): Promise { + const linkHeader: string[] = []; + + const objectBefore = await Notification.fromSql( + gt(Notifications.id, notifications[0].data.id), + ); + if (objectBefore) { + linkHeader.push( + `<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${notifications[0].data.id}>; rel="prev"`, + ); + } + + if (notifications.length >= (limit ?? 20)) { + const objectAfter = await Notification.fromSql( + gt( + Notifications.id, + notifications[notifications.length - 1].data.id, + ), + ); + if (objectAfter) { + linkHeader.push( + `<${urlWithoutQuery}?limit=${limit ?? 20}&max_id=${notifications[notifications.length - 1].data.id}>; rel="next"`, + ); + } + } + + return linkHeader; + } + private async fetchTimeline( sql: SQL | undefined, limit: number, @@ -174,117 +237,11 @@ export class Timeline { link, objects, }; + case TimelineType.Notification: + return { + link, + objects, + }; } } - - /* private async fetchTimeline( - sql: SQL | undefined, - limit: number, - url: string, - userId?: string, - ) { - const notes: Note[] = []; - const users: User[] = []; - - switch (this.type) { - case TimelineType.Note: - notes.push( - ...(await Note.manyFromSql( - sql, - undefined, - limit, - undefined, - userId, - )), - ); - break; - case TimelineType.User: - users.push(...(await User.manyFromSql(sql, undefined, limit))); - break; - } - - const linkHeader = []; - const urlWithoutQuery = new URL( - new URL(url).pathname, - config.http.base_url, - ).toString(); - - if (notes.length > 0) { - switch (this.type) { - case TimelineType.Note: { - const objectBefore = await Note.fromSql( - gt(Notes.id, notes[0].data.id), - ); - - if (objectBefore) { - linkHeader.push( - `<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${ - notes[0].data.id - }>; rel="prev"`, - ); - } - - if (notes.length >= (limit ?? 20)) { - const objectAfter = await Note.fromSql( - gt(Notes.id, notes[notes.length - 1].data.id), - ); - - if (objectAfter) { - linkHeader.push( - `<${urlWithoutQuery}?limit=${ - limit ?? 20 - }&max_id=${ - notes[notes.length - 1].data.id - }>; rel="next"`, - ); - } - } - break; - } - case TimelineType.User: { - const objectBefore = await User.fromSql( - gt(Users.id, users[0].id), - ); - - if (objectBefore) { - linkHeader.push( - `<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${ - users[0].id - }>; rel="prev"`, - ); - } - - if (users.length >= (limit ?? 20)) { - const objectAfter = await User.fromSql( - gt(Users.id, users[users.length - 1].id), - ); - - if (objectAfter) { - linkHeader.push( - `<${urlWithoutQuery}?limit=${ - limit ?? 20 - }&max_id=${ - users[users.length - 1].id - }>; rel="next"`, - ); - } - } - break; - } - } - } - - switch (this.type) { - case TimelineType.Note: - return { - link: linkHeader.join(", "), - objects: notes as T[], - }; - case TimelineType.User: - return { - link: linkHeader.join(", "), - objects: users as T[], - }; - } - } */ } diff --git a/classes/database/user.ts b/classes/database/user.ts index 42071724..d55c2c52 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -19,7 +19,7 @@ import type { Unfollow, User as VersiaUser, } from "@versia/federation/types"; -import { db } from "@versia/kit/db"; +import { Notification, db } from "@versia/kit/db"; import { EmojiToUser, Likes, @@ -264,7 +264,7 @@ export class User extends BaseInterface { return foundRelationship; } } else { - await db.insert(Notifications).values({ + await Notification.insert({ accountId: this.id, type: otherUser.data.isLocked ? "follow_request" : "follow", notifiedId: otherUser.id, @@ -290,13 +290,13 @@ export class User extends BaseInterface { } } else if (!this.data.isLocked) { if (relationship.data.following) { - await db.insert(Notifications).values({ + await Notification.insert({ accountId: followee.id, type: "unfollow", notifiedId: this.id, }); } else { - await db.insert(Notifications).values({ + await Notification.insert({ accountId: followee.id, type: "cancel-follow", notifiedId: this.id, @@ -485,7 +485,7 @@ export class User extends BaseInterface { if (this.isLocal() && note.author.isLocal()) { // Notify the user that their post has been favourited - await db.insert(Notifications).values({ + await Notification.insert({ accountId: this.id, type: "favourite", notifiedId: note.author.id, @@ -519,22 +519,36 @@ export class User extends BaseInterface { if (this.isLocal() && note.author.isLocal()) { // Remove any eventual notifications for this like - await db - .delete(Notifications) - .where( - and( - eq(Notifications.accountId, this.id), - eq(Notifications.type, "favourite"), - eq(Notifications.notifiedId, note.author.id), - eq(Notifications.noteId, note.id), - ), - ); + await likeToDelete.clearRelatedNotifications(); } else if (this.isLocal() && note.author.isRemote()) { // User is local, federate the delete this.federateToFollowers(likeToDelete.unlikeToVersia(this)); } } + public async clearAllNotifications(): Promise { + await db + .update(Notifications) + .set({ + dismissed: true, + }) + .where(eq(Notifications.notifiedId, this.id)); + } + + public async clearSomeNotifications(ids: string[]): Promise { + await db + .update(Notifications) + .set({ + dismissed: true, + }) + .where( + and( + inArray(Notifications.id, ids), + eq(Notifications.notifiedId, this.id), + ), + ); + } + public async updateFromRemote(): Promise { if (!this.isRemote()) { throw new Error( diff --git a/classes/functions/notification.ts b/classes/functions/notification.ts deleted file mode 100644 index d140432a..00000000 --- a/classes/functions/notification.ts +++ /dev/null @@ -1,62 +0,0 @@ -import type { Notification as ApiNotification } from "@versia/client/types"; -import { Note, User, db } from "@versia/kit/db"; -import type { Notifications } from "@versia/kit/tables"; -import type { InferSelectModel } from "drizzle-orm"; -import type { StatusWithRelations } from "./status.ts"; -import { - type UserWithRelations, - transformOutputToUserWithRelations, - userExtrasTemplate, - userRelations, -} from "./user.ts"; - -export type Notification = InferSelectModel; - -export type NotificationWithRelations = Notification & { - status: StatusWithRelations | null; - account: UserWithRelations; -}; - -export const findManyNotifications = async ( - query: Parameters[0], - userId?: string, -): Promise => { - const output = await db.query.Notifications.findMany({ - ...query, - with: { - ...query?.with, - account: { - with: { - ...userRelations, - }, - extras: userExtrasTemplate("Notifications_account"), - }, - }, - extras: { - ...query?.extras, - }, - }); - - return await Promise.all( - output.map(async (notif) => ({ - ...notif, - account: transformOutputToUserWithRelations(notif.account), - status: (await Note.fromId(notif.noteId, userId))?.data ?? null, - })), - ); -}; - -export const notificationToApi = async ( - notification: NotificationWithRelations, -): Promise => { - const account = new User(notification.account); - return { - account: account.toApi(), - created_at: new Date(notification.createdAt).toISOString(), - id: notification.id, - type: notification.type, - status: notification.status - ? await new Note(notification.status).toApi(account) - : undefined, - }; -}; diff --git a/classes/inbox/processor.test.ts b/classes/inbox/processor.test.ts index c0883fb4..11e7f954 100644 --- a/classes/inbox/processor.test.ts +++ b/classes/inbox/processor.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, jest, mock, test } from "bun:test"; import { SignatureValidator } from "@versia/federation"; import type { Entity, Note as VersiaNote } from "@versia/federation/types"; -import { Note, Relationship, User, db } from "@versia/kit/db"; +import { Note, Notification, Relationship, User } from "@versia/kit/db"; import type { Context } from "hono"; import { ValidationError } from "zod-validation-error"; import { config } from "~/packages/config-manager/index.ts"; @@ -34,6 +34,10 @@ mock.module("@versia/kit/db", () => ({ Like: { fromSql: jest.fn(), }, + Notification: { + fromSql: jest.fn(), + insert: jest.fn(), + }, })); mock.module("@versia/federation", () => ({ @@ -235,6 +239,7 @@ describe("InboxProcessor", () => { Relationship.fromOwnerAndSubject = jest .fn() .mockResolvedValue(mockRelationship); + Notification.insert = jest.fn(); mockContext.text = jest.fn().mockReturnValue({ status: 200 }); // biome-ignore lint/complexity/useLiteralKeys: Private variable @@ -249,7 +254,6 @@ describe("InboxProcessor", () => { notifying: true, languages: [], }); - expect(db.insert).toHaveBeenCalled(); }); test("returns 404 when author not found", async () => { diff --git a/classes/inbox/processor.ts b/classes/inbox/processor.ts index 82f25dfe..69895d6b 100644 --- a/classes/inbox/processor.ts +++ b/classes/inbox/processor.ts @@ -15,8 +15,15 @@ import type { Note as VersiaNote, User as VersiaUser, } from "@versia/federation/types"; -import { Instance, Like, Note, Relationship, User, db } from "@versia/kit/db"; -import { Likes, Notes, Notifications } from "@versia/kit/tables"; +import { + Instance, + Like, + Note, + Notification, + Relationship, + User, +} from "@versia/kit/db"; +import { Likes, Notes } from "@versia/kit/tables"; import type { SocketAddress } from "bun"; import { eq } from "drizzle-orm"; import type { Context, TypedResponse } from "hono"; @@ -312,7 +319,7 @@ export class InboxProcessor { languages: [], }); - await db.insert(Notifications).values({ + await Notification.insert({ accountId: author.id, type: followee.data.isLocked ? "follow_request" : "follow", notifiedId: followee.id, diff --git a/packages/plugin-kit/exports/db.ts b/packages/plugin-kit/exports/db.ts index 98129855..b1360c4d 100644 --- a/packages/plugin-kit/exports/db.ts +++ b/packages/plugin-kit/exports/db.ts @@ -11,3 +11,4 @@ export { db } from "~/drizzle/db.ts"; export { Relationship } from "~/classes/database/relationship.ts"; export { Like } from "~/classes/database/like.ts"; export { Token } from "~/classes/database/token.ts"; +export { Notification } from "~/classes/database/notification.ts"; diff --git a/utils/timelines.ts b/utils/timelines.ts deleted file mode 100644 index 06787e21..00000000 --- a/utils/timelines.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { db } from "@versia/kit/db"; -import type { SQL } from "drizzle-orm"; -import type { - Notification, - findManyNotifications, -} from "~/classes/functions/notification"; -import type { Status, findManyNotes } from "~/classes/functions/status"; -import type { UserType, findManyUsers } from "~/classes/functions/user"; -import { config } from "~/packages/config-manager/index.ts"; - -export async function fetchTimeline( - model: - | typeof findManyNotes - | typeof findManyUsers - | typeof findManyNotifications, - args: - | Parameters[0] - | Parameters[0] - | Parameters[0], - req: Request, - userId?: string, -): Promise<{ - link: string; - objects: T[]; -}> { - // BEFORE: Before in a top-to-bottom order, so the most recent posts - // AFTER: After in a top-to-bottom order, so the oldest posts - // @ts-expect-error This is a hack to get around the fact that Prisma doesn't have a common base type for all models - const objects = (await model(args, userId)) as T[]; - - // Constuct HTTP Link header (next and prev) only if there are more statuses - const linkHeader: string[] = []; - const urlWithoutQuery = new URL( - new URL(req.url).pathname, - config.http.base_url, - ).toString(); - - if (objects.length > 0) { - // Check if there are statuses before the first one - // @ts-expect-error This is a hack to get around the fact that Prisma doesn't have a common base type for all models - const objectsBefore = await model({ - ...args, - // @ts-expect-error this hack breaks typing :( - where: (object, { gt }): SQL | undefined => - gt(object.id, objects[0].id), - limit: 1, - }); - - if (objectsBefore.length > 0) { - // Add prev link - linkHeader.push( - `<${urlWithoutQuery}?limit=${args?.limit ?? 20}&min_id=${ - objects[0].id - }>; rel="prev"`, - ); - } - - if (objects.length >= Number(args?.limit ?? 20)) { - // Check if there are statuses after the last one - // @ts-expect-error hack again - const objectsAfter = await model({ - ...args, - // @ts-expect-error this hack breaks typing :( - where: (object, { lt }): SQL | undefined => - lt(object.id, objects.at(-1)?.id), - limit: 1, - }); - - if (objectsAfter.length > 0) { - // Add next link - linkHeader.push( - `<${urlWithoutQuery}?limit=${args?.limit ?? 20}&max_id=${ - objects.at(-1)?.id - }>; rel="next"`, - ); - } - } - } - - return { - link: linkHeader.join(", "), - objects, - }; -}