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 { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { db } from "@versia/kit/db"; import { Notification } from "@versia/kit/db";
import { Notifications, RolePermissions } from "@versia/kit/tables"; import { RolePermissions } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
@ -59,12 +58,15 @@ export default apiRoute((app) =>
return context.json({ error: "Unauthorized" }, 401); return context.json({ error: "Unauthorized" }, 401);
} }
await db const notification = await Notification.fromId(id);
.update(Notifications)
.set({ if (!notification) {
dismissed: true, return context.json({ error: "Notification not found" }, 404);
}) }
.where(eq(Notifications.id, id));
await notification.update({
dismissed: true,
});
return context.newResponse(null, 200); return context.newResponse(null, 200);
}), }),

View file

@ -1,13 +1,8 @@
import { apiRoute, applyConfig, auth } from "@/api"; import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi"; 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 { RolePermissions } from "@versia/kit/tables";
import type { SQL } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import {
findManyNotifications,
notificationToApi,
} from "~/classes/functions/notification";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
@ -101,21 +96,12 @@ export default apiRoute((app) =>
return context.json({ error: "Unauthorized" }, 401); return context.json({ error: "Unauthorized" }, 401);
} }
const notification = ( const notification = await Notification.fromId(id, user.id);
await findManyNotifications(
{
where: (notification, { eq }): SQL | undefined =>
eq(notification.id, id),
limit: 1,
},
user.id,
)
)[0];
if (!notification) { if (!notification) {
return context.json({ error: "Notification not found" }, 404); 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 { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { db } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables";
import { Notifications, RolePermissions } from "@versia/kit/tables";
import { eq } from "drizzle-orm";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
@ -47,12 +45,7 @@ export default apiRoute((app) =>
return context.json({ error: "Unauthorized" }, 401); return context.json({ error: "Unauthorized" }, 401);
} }
await db await user.clearAllNotifications();
.update(Notifications)
.set({
dismissed: true,
})
.where(eq(Notifications.notifiedId, user.id));
return context.newResponse(null, 200); return context.newResponse(null, 200);
}), }),

View file

@ -1,8 +1,6 @@
import { apiRoute, applyConfig, auth } from "@/api"; import { apiRoute, applyConfig, auth } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { db } from "@versia/kit/db"; import { RolePermissions } from "@versia/kit/tables";
import { Notifications, RolePermissions } from "@versia/kit/tables";
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
@ -60,17 +58,7 @@ export default apiRoute((app) =>
const { "ids[]": ids } = context.req.valid("query"); const { "ids[]": ids } = context.req.valid("query");
await db await user.clearSomeNotifications(ids);
.update(Notifications)
.set({
dismissed: true,
})
.where(
and(
inArray(Notifications.id, ids),
eq(Notifications.notifiedId, user.id),
),
);
return context.newResponse(null, 200); return context.newResponse(null, 200);
}), }),

View file

@ -1,15 +1,9 @@
import { apiRoute, applyConfig, auth, idValidator } from "@/api"; import { apiRoute, applyConfig, auth, idValidator } from "@/api";
import { fetchTimeline } from "@/timelines";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { Note, User } from "@versia/kit/db"; import { Note, Timeline, User } from "@versia/kit/db";
import { RolePermissions } from "@versia/kit/tables"; import { Notifications, RolePermissions } from "@versia/kit/tables";
import { type SQL, sql } from "drizzle-orm"; import { and, eq, gt, gte, inArray, lt, not, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import {
findManyNotifications,
notificationToApi,
} from "~/classes/functions/notification";
import type { NotificationWithRelations } from "~/classes/functions/notification";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
export const meta = applyConfig({ export const meta = applyConfig({
@ -150,65 +144,48 @@ export default apiRoute((app) =>
types, types,
} = context.req.valid("query"); } = context.req.valid("query");
const { objects, link } = const { objects, link } = await Timeline.getNotificationTimeline(
await fetchTimeline<NotificationWithRelations>( and(
findManyNotifications, max_id ? lt(Notifications.id, max_id) : undefined,
{ since_id ? gte(Notifications.id, since_id) : undefined,
where: ( min_id ? gt(Notifications.id, min_id) : undefined,
// @ts-expect-error Yes I KNOW the types are wrong eq(Notifications.notifiedId, user.id),
notification, eq(Notifications.dismissed, false),
// @ts-expect-error Yes I KNOW the types are wrong account_id
{ lt, gte, gt, and, eq, not, inArray }, ? eq(Notifications.accountId, account_id)
): SQL | undefined => : undefined,
and( not(eq(Notifications.accountId, user.id)),
max_id ? lt(notification.id, max_id) : undefined, types ? inArray(Notifications.type, types) : undefined,
since_id exclude_types
? gte(notification.id, since_id) ? not(inArray(Notifications.type, exclude_types))
: undefined, : undefined,
min_id ? gt(notification.id, min_id) : undefined, // Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId)
eq(notification.notifiedId, user.id), // Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE)
eq(notification.dismissed, false), // Filters table has a userId and a context which is an array
account_id sql`NOT EXISTS (
? eq(notification.accountId, account_id) SELECT 1
: undefined, FROM "Filters"
not(eq(notification.accountId, user.id)), WHERE "Filters"."userId" = ${user.id}
types AND "Filters"."filter_action" = 'hide'
? inArray(notification.type, types) AND EXISTS (
: undefined, SELECT 1
exclude_types FROM "FilterKeywords", "Notifications" as "n_inner", "Notes"
? not(inArray(notification.type, exclude_types)) WHERE "FilterKeywords"."filterId" = "Filters"."id"
: undefined, AND "n_inner"."noteId" = "Notes"."id"
// Don't show notes that have filtered words in them (via Notification.note.content via Notification.noteId) AND "Notes"."content" LIKE
// Filters in `Filters` table have keyword in `FilterKeywords` table (use LIKE) '%' || "FilterKeywords"."keyword" || '%'
// Filters table has a userId and a context which is an array AND "n_inner"."id" = "Notifications"."id"
sql`NOT EXISTS ( )
SELECT 1 AND "Filters"."context" @> ARRAY['notifications']
FROM "Filters" )`,
WHERE "Filters"."userId" = ${user.id} ),
AND "Filters"."filter_action" = 'hide' limit,
AND EXISTS ( context.req.url,
SELECT 1 user?.id,
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,
);
return context.json( return context.json(
await Promise.all(objects.map((n) => notificationToApi(n))), await Promise.all(objects.map((n) => n.toApi())),
200, 200,
{ {
Link: link, Link: link,

View file

@ -1,7 +1,7 @@
import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api"; import { apiRoute, applyConfig, auth, jsonOrForm } from "@/api";
import { createRoute } from "@hono/zod-openapi"; import { createRoute } from "@hono/zod-openapi";
import { Note, db } from "@versia/kit/db"; import { Note, Notification } from "@versia/kit/db";
import { Notes, Notifications, RolePermissions } from "@versia/kit/tables"; import { Notes, RolePermissions } from "@versia/kit/tables";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { ErrorSchema } from "~/types/api"; import { ErrorSchema } from "~/types/api";
@ -141,7 +141,7 @@ export default apiRoute((app) =>
} }
if (foundStatus.author.isLocal() && user.isLocal()) { if (foundStatus.author.isLocal() && user.isLocal()) {
await db.insert(Notifications).values({ await Notification.insert({
accountId: user.id, accountId: user.id,
notifiedId: foundStatus.author.id, notifiedId: foundStatus.author.id,
type: "reblog", type: "reblog",

View file

@ -1,11 +1,12 @@
import { RolePermission } from "@versia/client/types"; import { RolePermission } from "@versia/client/types";
import type { Delete, LikeExtension } from "@versia/federation/types"; import type { Delete, LikeExtension } from "@versia/federation/types";
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { Likes } from "@versia/kit/tables"; import { Likes, Notifications } from "@versia/kit/tables";
import { import {
type InferInsertModel, type InferInsertModel,
type InferSelectModel, type InferSelectModel,
type SQL, type SQL,
and,
desc, desc,
eq, eq,
inArray, inArray,
@ -139,6 +140,19 @@ export class Like extends BaseInterface<typeof Likes, LikeType> {
return this.data.id; 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 { public getUri(): URL {
return new URL(`/objects/${this.data.id}`, config.http.base_url); return new URL(`/objects/${this.data.id}`, config.http.base_url);
} }

View file

@ -14,13 +14,12 @@ import type {
Delete as VersiaDelete, Delete as VersiaDelete,
Note as VersiaNote, Note as VersiaNote,
} from "@versia/federation/types"; } from "@versia/federation/types";
import { db } from "@versia/kit/db"; import { Notification, db } from "@versia/kit/db";
import { import {
Attachments, Attachments,
EmojiToNote, EmojiToNote,
NoteToMentions, NoteToMentions,
Notes, Notes,
Notifications,
Users, Users,
} from "@versia/kit/tables"; } from "@versia/kit/tables";
import { import {
@ -469,7 +468,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
// Send notifications for mentioned local users // Send notifications for mentioned local users
for (const mention of parsedMentions ?? []) { for (const mention of parsedMentions ?? []) {
if (mention.isLocal()) { if (mention.isLocal()) {
await db.insert(Notifications).values({ await Notification.insert({
accountId: data.author.id, accountId: data.author.id,
notifiedId: mention.id, notifiedId: mention.id,
type: "mention", 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 { type SQL, gt } from "drizzle-orm";
import { config } from "~/packages/config-manager"; import { config } from "~/packages/config-manager";
import { Note } from "./note.ts"; import { Note } from "./note.ts";
import { Notification } from "./notification.ts";
import { User } from "./user.ts"; import { User } from "./user.ts";
enum TimelineType { enum TimelineType {
Note = "Note", Note = "Note",
User = "User", 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 constructor(private type: TimelineType) {}
public static getNoteTimeline( 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( private async fetchObjects(
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
limit: number, limit: number,
@ -58,6 +71,15 @@ export class Timeline<Type extends Note | User> {
undefined, undefined,
limit, limit,
)) as Type[]; )) 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; 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; 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( private async fetchTimeline(
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
limit: number, limit: number,
@ -174,117 +237,11 @@ export class Timeline<Type extends Note | User> {
link, link,
objects, 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, Unfollow,
User as VersiaUser, User as VersiaUser,
} from "@versia/federation/types"; } from "@versia/federation/types";
import { db } from "@versia/kit/db"; import { Notification, db } from "@versia/kit/db";
import { import {
EmojiToUser, EmojiToUser,
Likes, Likes,
@ -264,7 +264,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
return foundRelationship; return foundRelationship;
} }
} else { } else {
await db.insert(Notifications).values({ await Notification.insert({
accountId: this.id, accountId: this.id,
type: otherUser.data.isLocked ? "follow_request" : "follow", type: otherUser.data.isLocked ? "follow_request" : "follow",
notifiedId: otherUser.id, notifiedId: otherUser.id,
@ -290,13 +290,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
} }
} else if (!this.data.isLocked) { } else if (!this.data.isLocked) {
if (relationship.data.following) { if (relationship.data.following) {
await db.insert(Notifications).values({ await Notification.insert({
accountId: followee.id, accountId: followee.id,
type: "unfollow", type: "unfollow",
notifiedId: this.id, notifiedId: this.id,
}); });
} else { } else {
await db.insert(Notifications).values({ await Notification.insert({
accountId: followee.id, accountId: followee.id,
type: "cancel-follow", type: "cancel-follow",
notifiedId: this.id, notifiedId: this.id,
@ -485,7 +485,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
if (this.isLocal() && note.author.isLocal()) { if (this.isLocal() && note.author.isLocal()) {
// Notify the user that their post has been favourited // Notify the user that their post has been favourited
await db.insert(Notifications).values({ await Notification.insert({
accountId: this.id, accountId: this.id,
type: "favourite", type: "favourite",
notifiedId: note.author.id, notifiedId: note.author.id,
@ -519,22 +519,36 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
if (this.isLocal() && note.author.isLocal()) { if (this.isLocal() && note.author.isLocal()) {
// Remove any eventual notifications for this like // Remove any eventual notifications for this like
await db await likeToDelete.clearRelatedNotifications();
.delete(Notifications)
.where(
and(
eq(Notifications.accountId, this.id),
eq(Notifications.type, "favourite"),
eq(Notifications.notifiedId, note.author.id),
eq(Notifications.noteId, note.id),
),
);
} else if (this.isLocal() && note.author.isRemote()) { } else if (this.isLocal() && note.author.isRemote()) {
// User is local, federate the delete // User is local, federate the delete
this.federateToFollowers(likeToDelete.unlikeToVersia(this)); 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> { public async updateFromRemote(): Promise<User> {
if (!this.isRemote()) { if (!this.isRemote()) {
throw new Error( 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 { beforeEach, describe, expect, jest, mock, test } from "bun:test";
import { SignatureValidator } from "@versia/federation"; import { SignatureValidator } from "@versia/federation";
import type { Entity, Note as VersiaNote } from "@versia/federation/types"; 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 type { Context } from "hono";
import { ValidationError } from "zod-validation-error"; import { ValidationError } from "zod-validation-error";
import { config } from "~/packages/config-manager/index.ts"; import { config } from "~/packages/config-manager/index.ts";
@ -34,6 +34,10 @@ mock.module("@versia/kit/db", () => ({
Like: { Like: {
fromSql: jest.fn(), fromSql: jest.fn(),
}, },
Notification: {
fromSql: jest.fn(),
insert: jest.fn(),
},
})); }));
mock.module("@versia/federation", () => ({ mock.module("@versia/federation", () => ({
@ -235,6 +239,7 @@ describe("InboxProcessor", () => {
Relationship.fromOwnerAndSubject = jest Relationship.fromOwnerAndSubject = jest
.fn() .fn()
.mockResolvedValue(mockRelationship); .mockResolvedValue(mockRelationship);
Notification.insert = jest.fn();
mockContext.text = jest.fn().mockReturnValue({ status: 200 }); mockContext.text = jest.fn().mockReturnValue({ status: 200 });
// biome-ignore lint/complexity/useLiteralKeys: Private variable // biome-ignore lint/complexity/useLiteralKeys: Private variable
@ -249,7 +254,6 @@ describe("InboxProcessor", () => {
notifying: true, notifying: true,
languages: [], languages: [],
}); });
expect(db.insert).toHaveBeenCalled();
}); });
test("returns 404 when author not found", async () => { test("returns 404 when author not found", async () => {

View file

@ -15,8 +15,15 @@ import type {
Note as VersiaNote, Note as VersiaNote,
User as VersiaUser, User as VersiaUser,
} from "@versia/federation/types"; } from "@versia/federation/types";
import { Instance, Like, Note, Relationship, User, db } from "@versia/kit/db"; import {
import { Likes, Notes, Notifications } from "@versia/kit/tables"; Instance,
Like,
Note,
Notification,
Relationship,
User,
} from "@versia/kit/db";
import { Likes, Notes } from "@versia/kit/tables";
import type { SocketAddress } from "bun"; import type { SocketAddress } from "bun";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Context, TypedResponse } from "hono"; import type { Context, TypedResponse } from "hono";
@ -312,7 +319,7 @@ export class InboxProcessor {
languages: [], languages: [],
}); });
await db.insert(Notifications).values({ await Notification.insert({
accountId: author.id, accountId: author.id,
type: followee.data.isLocked ? "follow_request" : "follow", type: followee.data.isLocked ? "follow_request" : "follow",
notifiedId: followee.id, notifiedId: followee.id,

View file

@ -11,3 +11,4 @@ export { db } from "~/drizzle/db.ts";
export { Relationship } from "~/classes/database/relationship.ts"; export { Relationship } from "~/classes/database/relationship.ts";
export { Like } from "~/classes/database/like.ts"; export { Like } from "~/classes/database/like.ts";
export { Token } from "~/classes/database/token.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,
};
}