mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(database): ♻️ Move Notifications to their own ORM abstractions
This commit is contained in:
parent
14ace17ad4
commit
e732a3df03
|
|
@ -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({
|
||||
const notification = await Notification.fromId(id);
|
||||
|
||||
if (!notification) {
|
||||
return context.json({ error: "Notification not found" }, 404);
|
||||
}
|
||||
|
||||
await notification.update({
|
||||
dismissed: true,
|
||||
})
|
||||
.where(eq(Notifications.id, id));
|
||||
});
|
||||
|
||||
return context.newResponse(null, 200);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,33 +144,20 @@ 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 =>
|
||||
const { objects, link } = await Timeline.getNotificationTimeline(
|
||||
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),
|
||||
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(notification.accountId, account_id)
|
||||
: undefined,
|
||||
not(eq(notification.accountId, user.id)),
|
||||
types
|
||||
? inArray(notification.type, types)
|
||||
? eq(Notifications.accountId, account_id)
|
||||
: undefined,
|
||||
not(eq(Notifications.accountId, user.id)),
|
||||
types ? inArray(Notifications.type, types) : undefined,
|
||||
exclude_types
|
||||
? not(inArray(notification.type, 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)
|
||||
|
|
@ -199,16 +180,12 @@ export default apiRoute((app) =>
|
|||
)`,
|
||||
),
|
||||
limit,
|
||||
// @ts-expect-error Yes I KNOW the types are wrong
|
||||
orderBy: (notification, { desc }): SQL | undefined =>
|
||||
desc(notification.id),
|
||||
},
|
||||
context.req.raw,
|
||||
user.id,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* 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:
|
||||
case TimelineType.Notification:
|
||||
return {
|
||||
link: linkHeader.join(", "),
|
||||
objects: notes as T[],
|
||||
};
|
||||
case TimelineType.User:
|
||||
return {
|
||||
link: linkHeader.join(", "),
|
||||
objects: users as T[],
|
||||
link,
|
||||
objects,
|
||||
};
|
||||
}
|
||||
} */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue