feat(database): Add Reaction database class

This commit is contained in:
Jesse Wierzbinski 2024-12-18 20:01:26 +01:00
parent e00182cf54
commit f67fed12e0
No known key found for this signature in database
6 changed files with 2635 additions and 11 deletions

View file

@ -0,0 +1,265 @@
import type { Emoji as APIEmoji } from "@versia/client/types";
import type { ReactionExtension } from "@versia/federation/types";
import { Emoji, Instance, Note, User, db } from "@versia/kit/db";
import { type Notes, Reactions, type Users } from "@versia/kit/tables";
import {
type InferInsertModel,
type InferSelectModel,
type SQL,
desc,
eq,
inArray,
} from "drizzle-orm";
import { z } from "zod";
import { config } from "~/packages/config-manager/index.ts";
import { BaseInterface } from "./base.ts";
type ReactionType = InferSelectModel<typeof Reactions> & {
emoji: typeof Emoji.$type | null;
author: InferSelectModel<typeof Users>;
note: InferSelectModel<typeof Notes>;
};
export interface APIReaction {
id: string;
author_id: string;
emoji: APIEmoji | string;
}
export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
public static schema: z.ZodType<APIReaction> = z.object({
id: z.string().uuid(),
author_id: z.string().uuid(),
emoji: z.lazy(() => Emoji.schema),
});
public static $type: ReactionType;
public async reload(): Promise<void> {
const reloaded = await Reaction.fromId(this.data.id);
if (!reloaded) {
throw new Error("Failed to reload reaction");
}
this.data = reloaded.data;
}
public static async fromId(id: string | null): Promise<Reaction | null> {
if (!id) {
return null;
}
return await Reaction.fromSql(eq(Reactions.id, id));
}
public static async fromIds(ids: string[]): Promise<Reaction[]> {
return await Reaction.manyFromSql(inArray(Reactions.id, ids));
}
public static async fromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Reactions.id),
): Promise<Reaction | null> {
const found = await db.query.Reactions.findFirst({
where: sql,
with: {
emoji: {
with: {
instance: true,
},
},
author: true,
note: true,
},
orderBy,
});
if (!found) {
return null;
}
return new Reaction(found);
}
public static async manyFromSql(
sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(Reactions.id),
limit?: number,
offset?: number,
extra?: Parameters<typeof db.query.Reactions.findMany>[0],
): Promise<Reaction[]> {
const found = await db.query.Reactions.findMany({
where: sql,
orderBy,
limit,
offset,
with: {
...extra?.with,
emoji: {
with: {
instance: true,
},
},
author: true,
note: true,
},
});
return found.map((s) => new Reaction(s));
}
public async update(
newReaction: Partial<ReactionType>,
): Promise<ReactionType> {
await db
.update(Reactions)
.set(newReaction)
.where(eq(Reactions.id, this.id));
const updated = await Reaction.fromId(this.data.id);
if (!updated) {
throw new Error("Failed to update reaction");
}
this.data = updated.data;
return updated.data;
}
public save(): Promise<ReactionType> {
return this.update(this.data);
}
public async delete(ids?: string[]): Promise<void> {
if (Array.isArray(ids)) {
await db.delete(Reactions).where(inArray(Reactions.id, ids));
} else {
await db.delete(Reactions).where(eq(Reactions.id, this.id));
}
}
public static async insert(
data: InferInsertModel<typeof Reactions>,
): Promise<Reaction> {
// Needs one of emojiId or emojiText, but not both
if (!(data.emojiId || data.emojiText)) {
throw new Error("EmojiID or emojiText is required");
}
if (data.emojiId && data.emojiText) {
throw new Error("Cannot have both emojiId and emojiText");
}
const inserted = (
await db.insert(Reactions).values(data).returning()
)[0];
const reaction = await Reaction.fromId(inserted.id);
if (!reaction) {
throw new Error("Failed to insert reaction");
}
return reaction;
}
public get id(): string {
return this.data.id;
}
public toApi(): APIReaction {
return {
id: this.data.id,
author_id: this.data.authorId,
emoji: this.hasCustomEmoji()
? new Emoji(this.data.emoji as typeof Emoji.$type).toApi()
: this.data.emojiText || "",
};
}
public getUri(baseUrl: string): string {
return (
this.data.uri ||
new URL(
`/objects/${this.data.noteId}/reactions/${this.id}`,
baseUrl,
).toString()
);
}
public isLocal(): boolean {
return this.data.author.instanceId === null;
}
public hasCustomEmoji(): boolean {
return !!this.data.emoji || !this.data.emojiText;
}
public toVersia(): ReactionExtension {
if (!this.isLocal()) {
throw new Error("Cannot convert a non-local reaction to Versia");
}
return {
uri: this.getUri(config.http.base_url),
type: "pub.versia:reactions/Reaction",
author: User.getUri(
this.data.authorId,
this.data.author.uri,
config.http.base_url,
),
created_at: new Date(this.data.createdAt).toISOString(),
id: this.id,
object: Note.getUri(
this.data.note.id,
this.data.note.uri,
) as string,
content: this.hasCustomEmoji()
? `:${this.data.emoji?.shortcode}:`
: this.data.emojiText || "",
extensions: this.hasCustomEmoji()
? {
"pub.versia:custom_emojis": {
emojis: [
new Emoji(
this.data.emoji as typeof Emoji.$type,
).toVersia(),
],
},
}
: undefined,
};
}
public static async fromVersia(
reactionToConvert: ReactionExtension,
author: User,
note: Note,
): Promise<Reaction> {
if (author.isLocal()) {
throw new Error("Cannot process a reaction from a local user");
}
const emojiEntity =
reactionToConvert.extensions?.["pub.versia:custom_emojis"]
?.emojis[0];
const emoji = emojiEntity
? await Emoji.fetchFromRemote(
emojiEntity,
new Instance(
author.data.instance as NonNullable<
(typeof User.$type)["instance"]
>,
),
)
: null;
return Reaction.insert({
uri: reactionToConvert.uri,
authorId: author.id,
noteId: note.id,
emojiId: emoji ? emoji.id : null,
emojiText: emoji ? null : reactionToConvert.content,
});
}
}

View file

@ -0,0 +1,6 @@
ALTER TABLE "Reaction" ALTER COLUMN "emojiId" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "Reaction" ADD COLUMN "uri" text;--> statement-breakpoint
ALTER TABLE "Reaction" ADD COLUMN "emoji_text" text;--> statement-breakpoint
ALTER TABLE "Reaction" ADD COLUMN "created_at" timestamp(3) DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "Reaction" ADD COLUMN "update_at" timestamp(3) DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "Reaction" ADD CONSTRAINT "Reaction_uri_unique" UNIQUE("uri");

File diff suppressed because it is too large Load diff

View file

@ -267,6 +267,13 @@
"when": 1734546300555, "when": 1734546300555,
"tag": "0037_condemned_talisman", "tag": "0037_condemned_talisman",
"breakpoints": true "breakpoints": true
},
{
"idx": 38,
"version": "7",
"when": 1734548407684,
"tag": "0038_friendly_supernaut",
"breakpoints": true
} }
] ]
} }

View file

@ -51,14 +51,15 @@ export const Emojis = pgTable("Emojis", {
category: text("category"), category: text("category"),
}); });
export const Reaction = pgTable("Reaction", { export const Reactions = pgTable("Reaction", {
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(), id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
emojiId: uuid("emojiId") uri: text("uri").unique(),
.notNull() // Emoji ID is nullable, in which case it is a text emoji, and the emojiText field is used
.references(() => Emojis.id, { emojiId: uuid("emojiId").references(() => Emojis.id, {
onDelete: "cascade", onDelete: "cascade",
onUpdate: "cascade", onUpdate: "cascade",
}), }),
emojiText: text("emoji_text"),
noteId: uuid("noteId") noteId: uuid("noteId")
.notNull() .notNull()
.references(() => Notes.id, { .references(() => Notes.id, {
@ -71,19 +72,28 @@ export const Reaction = pgTable("Reaction", {
onDelete: "cascade", onDelete: "cascade",
onUpdate: "cascade", onUpdate: "cascade",
}), }),
createdAt: timestamp("created_at", { precision: 3, mode: "string" })
.defaultNow()
.notNull(),
updatedAt: timestamp("update_at", {
precision: 3,
mode: "string",
})
.defaultNow()
.notNull(),
}); });
export const ReactionRelations = relations(Reaction, ({ one }) => ({ export const ReactionRelations = relations(Reactions, ({ one }) => ({
emoji: one(Emojis, { emoji: one(Emojis, {
fields: [Reaction.emojiId], fields: [Reactions.emojiId],
references: [Emojis.id], references: [Emojis.id],
}), }),
note: one(Notes, { note: one(Notes, {
fields: [Reaction.noteId], fields: [Reactions.noteId],
references: [Notes.id], references: [Notes.id],
}), }),
author: one(Users, { author: one(Users, {
fields: [Reaction.authorId], fields: [Reactions.authorId],
references: [Users.id], references: [Users.id],
}), }),
})); }));

View file

@ -12,3 +12,4 @@ 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"; export { Notification } from "~/classes/database/notification.ts";
export { Reaction } from "~/classes/database/reaction.ts";