refactor(database): ♻️ Move Notifications to their own ORM abstractions

This commit is contained in:
Jesse Wierzbinski 2024-11-04 10:43:30 +01:00
parent 14ace17ad4
commit e732a3df03
No known key found for this signature in database
16 changed files with 440 additions and 401 deletions

View file

@ -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);
}),

View file

@ -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);
}),
);

View file

@ -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);
}),

View file

@ -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);
}),

View file

@ -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<NotificationWithRelations>(
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,

View file

@ -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",

View file

@ -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<typeof Likes, LikeType> {
return this.data.id;
}
public async clearRelatedNotifications(): Promise<void> {
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);
}

View file

@ -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<typeof Notes, StatusWithRelations> {
// 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",

View file

@ -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<typeof Notifications> & {
status: StatusWithRelations | null;
account: UserWithRelations;
};
export class Notification extends BaseInterface<
typeof Notifications,
NotificationType
> {
public static schema: z.ZodType<APINotification> = 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<void> {
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<Notification | null> {
if (!id) {
return null;
}
return await Notification.fromSql(
eq(Notifications.id, id),
undefined,
userId,
);
}
public static async fromIds(
ids: string[],
userId?: string,
): Promise<Notification[]> {
return await Notification.manyFromSql(
inArray(Notifications.id, ids),
undefined,
undefined,
undefined,
undefined,
userId,
);
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Notifications.id),
userId?: string,
): Promise<Notification | null> {
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<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Notifications.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Notifications.findMany>[0],
userId?: string,
): Promise<Notification[]> {
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<NotificationType>,
): Promise<NotificationType> {
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<NotificationType> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
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<typeof Notifications>,
): Promise<Notification> {
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<APINotification> {
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,
};
}
}

View file

@ -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<Type extends Note | User> {
export class Timeline<Type extends Note | User | Notification> {
public constructor(private type: TimelineType) {}
public static getNoteTimeline(
@ -38,6 +40,17 @@ export class Timeline<Type extends Note | User> {
);
}
public static getNotificationTimeline(
sql: SQL<unknown> | undefined,
limit: number,
url: string,
userId?: string,
): Promise<{ link: string; objects: Notification[] }> {
return new Timeline<Notification>(
TimelineType.Notification,
).fetchTimeline(sql, limit, url, userId);
}
private async fetchObjects(
sql: SQL<unknown> | undefined,
limit: number,
@ -58,6 +71,15 @@ export class Timeline<Type extends Note | User> {
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<Type extends Note | User> {
)),
);
break;
case TimelineType.Notification:
linkHeader.push(
...(await Timeline.fetchNotificationLinkHeader(
objects as Notification[],
urlWithoutQuery,
limit,
)),
);
}
}
@ -154,6 +184,39 @@ export class Timeline<Type extends Note | User> {
return linkHeader;
}
private static async fetchNotificationLinkHeader(
notifications: Notification[],
urlWithoutQuery: string,
limit: number,
): Promise<string[]> {
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<unknown> | undefined,
limit: number,
@ -174,117 +237,11 @@ export class Timeline<Type extends Note | User> {
link,
objects,
};
case TimelineType.Notification:
return {
link,
objects,
};
}
}
/* private async fetchTimeline<T>(
sql: SQL<unknown> | 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[],
};
}
} */
}

View file

@ -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<typeof Users, UserWithRelations> {
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<typeof Users, UserWithRelations> {
}
} 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<typeof Users, UserWithRelations> {
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<typeof Users, UserWithRelations> {
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<void> {
await db
.update(Notifications)
.set({
dismissed: true,
})
.where(eq(Notifications.notifiedId, this.id));
}
public async clearSomeNotifications(ids: string[]): Promise<void> {
await db
.update(Notifications)
.set({
dismissed: true,
})
.where(
and(
inArray(Notifications.id, ids),
eq(Notifications.notifiedId, this.id),
),
);
}
public async updateFromRemote(): Promise<User> {
if (!this.isRemote()) {
throw new Error(

View file

@ -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<typeof Notifications>;
export type NotificationWithRelations = Notification & {
status: StatusWithRelations | null;
account: UserWithRelations;
};
export const findManyNotifications = async (
query: Parameters<typeof db.query.Notifications.findMany>[0],
userId?: string,
): Promise<NotificationWithRelations[]> => {
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<ApiNotification> => {
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,
};
};

View file

@ -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 () => {

View file

@ -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,

View file

@ -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";

View file

@ -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<T extends UserType | Status | Notification>(
model:
| typeof findManyNotes
| typeof findManyUsers
| typeof findManyNotifications,
args:
| Parameters<typeof findManyNotes>[0]
| Parameters<typeof findManyUsers>[0]
| Parameters<typeof db.query.Notifications.findMany>[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,
};
}