mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 22:09:16 +01:00
refactor(database): ♻️ Move Notifications to their own ORM abstractions
This commit is contained in:
parent
14ace17ad4
commit
e732a3df03
16 changed files with 440 additions and 401 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
243
classes/database/notification.ts
Normal file
243
classes/database/notification.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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[],
|
||||
};
|
||||
}
|
||||
} */
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue