diff --git a/src/api/routes/channels/#channel_id/tags.ts b/src/api/routes/channels/#channel_id/tags.ts
new file mode 100644
index 000000000..34b491565
--- /dev/null
+++ b/src/api/routes/channels/#channel_id/tags.ts
@@ -0,0 +1,66 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+import { route } from "@spacebar/api";
+import { Channel, ChannelUpdateEvent, emitEvent, Tag } from "@spacebar/util";
+import { Request, Response, Router } from "express";
+import { TagCreateSchema } from "@spacebar/schemas";
+
+const router: Router = Router({ mergeParams: true });
+
+router.post(
+ "/",
+ route({
+ requestBody: "TagCreateSchema",
+ permission: "MANAGE_CHANNELS",
+ responses: {
+ 201: {},
+ 404: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const body = req.body as TagCreateSchema;
+ const { channel_id } = req.params as Record;
+
+ const channel = await Channel.findOneOrFail({
+ where: { id: channel_id },
+ relations: ["available_tags"],
+ });
+
+ if (!channel.isForum()) throw new Error("is not thread only channel");
+
+ const tag = Tag.create({
+ channel,
+ ...body,
+ });
+ channel.available_tags?.push(tag);
+
+ await Promise.all([
+ tag.save(),
+ emitEvent({
+ event: "CHANNEL_UPDATE",
+ data: channel.toJSON(),
+ channel_id,
+ } as ChannelUpdateEvent),
+ ]);
+
+ res.json(channel.toJSON());
+ },
+);
+
+export default router;
diff --git a/src/gateway/opcodes/Identify.ts b/src/gateway/opcodes/Identify.ts
index efed94fc6..00635e30b 100644
--- a/src/gateway/opcodes/Identify.ts
+++ b/src/gateway/opcodes/Identify.ts
@@ -304,6 +304,7 @@ export async function onIdentify(this: WebSocket, data: Payload) {
type: Not(In([ChannelType.GUILD_PUBLIC_THREAD, ChannelType.GUILD_PRIVATE_THREAD, ChannelType.GUILD_NEWS_THREAD])),
},
order: { guild_id: "ASC" },
+ relations: ["available_tags"],
}),
),
timePromise(() =>
diff --git a/src/schemas/uncategorised/TagCreateSchema.ts b/src/schemas/uncategorised/TagCreateSchema.ts
new file mode 100644
index 000000000..70c5cc30e
--- /dev/null
+++ b/src/schemas/uncategorised/TagCreateSchema.ts
@@ -0,0 +1,24 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+*/
+
+export interface TagCreateSchema {
+ name: string;
+ moderated?: boolean;
+ emoji_id?: string;
+ emoji_name?: string;
+}
diff --git a/src/schemas/uncategorised/index.ts b/src/schemas/uncategorised/index.ts
index 9083fd2ab..a18105453 100644
--- a/src/schemas/uncategorised/index.ts
+++ b/src/schemas/uncategorised/index.ts
@@ -95,3 +95,4 @@ export * from "./MessageThreadCreationSchema";
export * from "./ThreadCreationSchema";
export * from "./MessageActivity";
export * from "./PostDataSchema";
+export * from "./TagCreateSchema";
diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts
index afd907bab..52867a0c0 100644
--- a/src/util/entities/Channel.ts
+++ b/src/util/entities/Channel.ts
@@ -593,6 +593,9 @@ export class Channel extends BaseClass {
isThread() {
return this.type === ChannelType.GUILD_NEWS_THREAD || this.type === ChannelType.GUILD_PUBLIC_THREAD || this.type === ChannelType.GUILD_PRIVATE_THREAD;
}
+ isForum() {
+ return this.type === ChannelType.GUILD_FORUM || this.type === ChannelType.GUILD_MEDIA;
+ }
isPrivateThread() {
return this.type === ChannelType.GUILD_PRIVATE_THREAD;
diff --git a/src/util/entities/Tag.ts b/src/util/entities/Tag.ts
index 3b4136d78..143fad54b 100644
--- a/src/util/entities/Tag.ts
+++ b/src/util/entities/Tag.ts
@@ -39,10 +39,10 @@ export class Tag extends BaseClass {
@Column()
moderated: boolean = false;
- @Column()
+ @Column({ nullable: true })
emoji_id?: string;
- @Column()
+ @Column({ nullable: true })
emoji_name?: string;
toJSON() {
diff --git a/src/util/migration/postgres/1770310028511-nullableTags.ts b/src/util/migration/postgres/1770310028511-nullableTags.ts
new file mode 100644
index 000000000..ce4b26096
--- /dev/null
+++ b/src/util/migration/postgres/1770310028511-nullableTags.ts
@@ -0,0 +1,15 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class NullableTags1770310028511 implements MigrationInterface {
+ name = "NullableTags1770310028511";
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "tags" ALTER COLUMN "emoji_id" DROP NOT NULL`);
+ await queryRunner.query(`ALTER TABLE "tags" ALTER COLUMN "emoji_name" DROP NOT NULL`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "tags" ALTER COLUMN "emoji_name" SET NOT NULL`);
+ await queryRunner.query(`ALTER TABLE "tags" ALTER COLUMN "emoji_id" SET NOT NULL`);
+ }
+}