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