diff --git a/src/database/entities/Guild.ts b/src/database/entities/Guild.ts
new file mode 100644
index 00000000..ab56b76a
--- /dev/null
+++ b/src/database/entities/Guild.ts
@@ -0,0 +1,492 @@
+/*
+ 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 <https://www.gnu.org/licenses/>.
+*/
+
+import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
+import { arrayRemove } from "@spacebar/extensions";
+import { Config, GuildWelcomeScreen, Snowflake, handleFile } from "@spacebar/util";
+import { Ban } from "./Ban";
+import { BaseClass } from "./BaseClass";
+import { Channel } from "./Channel";
+import { Emoji } from "./Emoji";
+import { Invite } from "./Invite";
+import { Member } from "./Member";
+import { Role } from "./Role";
+import { Sticker } from "./Sticker";
+import { Template } from "./Template";
+import { User } from "./User";
+import { VoiceState } from "./VoiceState";
+import { Webhook } from "./Webhook";
+// TODO: application_command_count, application_command_counts: {1: 0, 2: 0, 3: 0}
+// TODO: guild_scheduled_events
+// TODO: stage_instances
+// TODO: threads
+// TODO:
+// "keywords": [
+// "Genshin Impact",
+// "Paimon",
+// "Honkai Impact",
+// "ARPG",
+// "Open-World",
+// "Waifu",
+// "Anime",
+// "Genshin",
+// "miHoYo",
+// "Gacha"
+// ],
+
+export const PublicGuildRelations = [
+ "channels",
+ "emojis",
+ "roles",
+ "stickers",
+ "voice_states",
+ // "members", // TODO: These are public, but all members should not be fetched.
+ // "members.user",
+];
+
+@Entity({
+ name: "guilds",
+})
+export class Guild extends BaseClass {
+ @Column({ type: String, nullable: true })
+ @RelationId((guild: Guild) => guild.afk_channel)
+ afk_channel_id?: string | null;
+
+ @JoinColumn({ name: "afk_channel_id" })
+ @ManyToOne(() => Channel)
+ afk_channel?: Channel;
+
+ @Column({ nullable: true })
+ afk_timeout?: number;
+
+ // * commented out -> use owner instead
+ // application id of the guild creator if it is bot-created
+ // @Column({ nullable: true })
+ // application?: string;
+
+ @JoinColumn({ name: "ban_ids" })
+ @OneToMany(() => Ban, (ban: Ban) => ban.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ bans: Ban[];
+
+ @Column({ nullable: true })
+ banner?: string;
+
+ @Column({ nullable: true })
+ default_message_notifications?: number;
+
+ @Column({ nullable: true })
+ description?: string;
+
+ @Column({ nullable: true })
+ discovery_splash?: string;
+
+ @Column({ nullable: true })
+ explicit_content_filter?: number;
+
+ @Column({ type: "varchar", array: true })
+ features: string[] = []; //TODO use enum
+ //TODO: https://discord.com/developers/docs/resources/guild#guild-object-guild-features
+
+ @Column({ nullable: true })
+ primary_category_id?: string; // TODO: this was number?
+
+ @Column({ nullable: true })
+ icon?: string;
+
+ @Column()
+ large?: boolean = false;
+
+ @Column({ nullable: true })
+ max_members?: number;
+
+ @Column({ nullable: true })
+ max_presences?: number;
+
+ @Column({ nullable: true })
+ max_video_channel_users?: number;
+
+ @Column({ nullable: true })
+ member_count?: number;
+
+ @Column({ nullable: true })
+ presence_count?: number; // users online
+
+ @OneToMany(() => Member, (member: Member) => member.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ members: Member[];
+
+ @JoinColumn({ name: "role_ids" })
+ @OneToMany(() => Role, (role: Role) => role.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ roles: Role[];
+
+ @JoinColumn({ name: "channel_ids" })
+ @OneToMany(() => Channel, (channel: Channel) => channel.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ })
+ channels: Channel[];
+
+ @Column({ nullable: true })
+ @RelationId((guild: Guild) => guild.template)
+ template_id?: string;
+
+ @JoinColumn({ name: "template_id", referencedColumnName: "id" })
+ @ManyToOne(() => Template)
+ template: Template;
+
+ @JoinColumn({ name: "emoji_ids" })
+ @OneToMany(() => Emoji, (emoji: Emoji) => emoji.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ emojis: Emoji[];
+
+ @JoinColumn({ name: "sticker_ids" })
+ @OneToMany(() => Sticker, (sticker: Sticker) => sticker.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ stickers: Sticker[];
+
+ @JoinColumn({ name: "invite_ids" })
+ @OneToMany(() => Invite, (invite: Invite) => invite.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ invites: Invite[];
+
+ @JoinColumn({ name: "voice_state_ids" })
+ @OneToMany(() => VoiceState, (voicestate: VoiceState) => voicestate.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ voice_states: VoiceState[];
+
+ @JoinColumn({ name: "webhook_ids" })
+ @OneToMany(() => Webhook, (webhook: Webhook) => webhook.guild, {
+ cascade: true,
+ orphanedRowAction: "delete",
+ onDelete: "CASCADE",
+ })
+ webhooks: Webhook[];
+
+ @Column({ nullable: true })
+ mfa_level?: number;
+
+ @Column()
+ name: string;
+
+ @Column({ nullable: true })
+ @RelationId((guild: Guild) => guild.owner)
+ owner_id?: string; // optional to allow for ownerless guilds
+
+ @JoinColumn({ name: "owner_id", referencedColumnName: "id" })
+ @ManyToOne(() => User)
+ owner?: User; // optional to allow for ownerless guilds
+
+ @Column({ nullable: true })
+ preferred_locale?: string;
+
+ @Column({ nullable: true })
+ premium_subscription_count?: number;
+
+ @Column()
+ premium_tier?: number; // crowd premium level
+
+ @Column({ type: String, nullable: true })
+ @RelationId((guild: Guild) => guild.public_updates_channel)
+ public_updates_channel_id: string | null;
+
+ @JoinColumn({ name: "public_updates_channel_id" })
+ @ManyToOne(() => Channel)
+ public_updates_channel?: Channel;
+
+ @Column({ type: String, nullable: true })
+ @RelationId((guild: Guild) => guild.rules_channel)
+ rules_channel_id?: string | null;
+
+ @JoinColumn({ name: "rules_channel_id" })
+ @ManyToOne(() => Channel)
+ rules_channel?: string;
+
+ @Column({ nullable: true })
+ region?: string;
+
+ @Column({ nullable: true })
+ splash?: string;
+
+ @Column({ type: String, nullable: true })
+ @RelationId((guild: Guild) => guild.system_channel)
+ system_channel_id?: string | null;
+
+ @JoinColumn({ name: "system_channel_id" })
+ @ManyToOne(() => Channel)
+ system_channel?: Channel;
+
+ @Column({ nullable: true })
+ system_channel_flags?: number;
+
+ @Column()
+ unavailable: boolean = false;
+
+ @Column({ nullable: true })
+ verification_level?: number;
+
+ /**
+ * DEPRECATED: Look at the new Guild onboarding screens.
+ */
+ @Column({ type: "jsonb" })
+ welcome_screen: GuildWelcomeScreen;
+
+ @Column({ nullable: true, type: "int8" })
+ @RelationId((guild: Guild) => guild.widget_channel)
+ widget_channel_id?: string;
+
+ @JoinColumn({ name: "widget_channel_id" })
+ @ManyToOne(() => Channel)
+ widget_channel?: Channel;
+
+ @Column()
+ widget_enabled: boolean = true;
+
+ @Column({ nullable: true })
+ nsfw_level?: number;
+
+ @Column()
+ nsfw: boolean = false;
+
+ // TODO: nested guilds
+ @Column({ nullable: true })
+ parent?: string;
+
+ // only for developer portal
+ permissions?: number;
+
+ //new guild settings, 11/08/2022:
+ @Column({ nullable: true })
+ premium_progress_bar_enabled: boolean = false;
+
+ @Column({ select: false, type: "int8", array: true })
+ channel_ordering: string[];
+
+ @Column()
+ discovery_weight: number = 0;
+
+ @Column()
+ discovery_excluded: boolean = false;
+
+ async ToGuildSource() {
+ if (!this.features.includes("DISCOVERABLE")) {
+ return null;
+ }
+ return {
+ id: this.id,
+ name: this.name,
+ icon: this.icon,
+ description: this.description,
+ banner: this.banner,
+ splash: this.splash,
+ discovery_splash: this.discovery_splash,
+ features: this.features,
+ vanity_url_code: null,
+ preferred_locale: this.preferred_locale || "en",
+ premium_subscription_count: this.premium_subscription_count,
+ approximate_member_count: await Member.countBy({
+ guild_id: this.id,
+ }),
+ approximate_presence_count: await Member.countBy({
+ guild_id: this.id,
+ user: {
+ sessions: {
+ status: "online",
+ },
+ },
+ }),
+ emojis: this.emojis ?? undefined,
+ emoji_count: this.emojis ? this.emojis.length : undefined,
+ stickers: this.stickers ?? undefined,
+ sticker_count: this.stickers ? this.stickers.length : undefined,
+ auto_removed: false,
+ primary_category_id: this.primary_category_id,
+ keywords: [],
+ is_published: false,
+ reasons_to_join: [],
+ };
+ }
+
+ static async createGuild(body: {
+ name?: string;
+ icon?: string | null;
+ owner_id?: string;
+ roles?: Partial<Role>[];
+ channels?: Partial<Channel>[];
+ source_guild_id: string | null;
+ }) {
+ const guild_id = Snowflake.generate();
+
+ const guild = await Guild.create({
+ id: guild_id,
+ name: body.name || "Spacebar",
+ icon: await handleFile(`/icons/${guild_id}`, body.icon as string),
+ owner_id: body.owner_id, // TODO: need to figure out a way for ownerless guilds and multiply-owned guilds
+ presence_count: 0,
+ member_count: 0, // will automatically be increased by addMember()
+ mfa_level: 0,
+ preferred_locale: "en-US",
+ premium_subscription_count: 0,
+ premium_tier: 0,
+ system_channel_flags: 4, // defaults effect: suppress the setup tips to save performance
+ nsfw_level: 0,
+ verification_level: 0,
+ welcome_screen: {
+ enabled: false,
+ description: "",
+ welcome_channels: [],
+ },
+ channel_ordering: [],
+ afk_timeout: Config.get().defaults.guild.afkTimeout,
+ default_message_notifications: Config.get().defaults.guild.defaultMessageNotifications,
+ explicit_content_filter: Config.get().defaults.guild.explicitContentFilter,
+ features: Config.get().guild.defaultFeatures,
+ max_members: Config.get().limits.guild.maxMembers,
+ max_presences: Config.get().defaults.guild.maxPresences,
+ max_video_channel_users: Config.get().defaults.guild.maxVideoChannelUsers,
+ region: Config.get().regions.default,
+ }).save();
+
+ // we have to create the role _after_ the guild because else we would get a foreign key error
+ // TODO: make the @everyone a pseudorole that is dynamically generated at runtime so we can save storage
+ await Role.create({
+ id: guild_id,
+ guild_id: guild_id,
+ color: 0,
+ colors: { primary_color: 0 },
+ hoist: false,
+ managed: false,
+ mentionable: false,
+ name: "@everyone",
+ permissions: "2251804225",
+ position: 0,
+ icon: undefined,
+ unicode_emoji: undefined,
+ flags: 0, // TODO?
+ }).save();
+
+ // create custom roles if provided
+ if (body.roles && body.roles.length) {
+ await Promise.all(
+ body.roles?.map(
+ (role) =>
+ new Promise((resolve) => {
+ Role.create({
+ ...role,
+ guild_id,
+ id:
+ // role.id === body.template_guild_id indicates that this is the @everyone role
+ role.id === body.source_guild_id || role.id == "0" ? guild_id : Snowflake.generate(),
+ })
+ .save()
+ .then(resolve);
+ }),
+ ),
+ );
+ }
+
+ if (!body.channels || !body.channels.length) {
+ body.channels = [{ id: "01", type: 0, name: "general", nsfw: false }];
+ }
+
+ const ids = new Map();
+
+ body.channels.forEach((x) => {
+ if (x.id) {
+ ids.set(x.id, Snowflake.generate());
+ }
+ });
+
+ for (const channel of body.channels.sort((a) => (a.parent_id ? 1 : -1))) {
+ const id = ids.get(channel.id) || Snowflake.generate();
+
+ const parent_id = ids.get(channel.parent_id);
+
+ const saved = await Channel.createChannel({ ...channel, guild_id, id, parent_id }, body.owner_id, {
+ keepId: true,
+ skipExistsCheck: true,
+ skipPermissionCheck: true,
+ skipEventEmit: true,
+ });
+
+ await Guild.insertChannelInOrder(guild.id, saved.id, parent_id ?? channel.position ?? 0, guild);
+ }
+
+ return guild;
+ }
+
+ /** Insert a channel into the guild ordering by parent channel id or position */
+ static async insertChannelInOrder(guild_id: string, channel_id: string, position: number, guild?: Guild): Promise<number>;
+ static async insertChannelInOrder(guild_id: string, channel_id: string, parent_id: string, guild?: Guild): Promise<number>;
+ static async insertChannelInOrder(guild_id: string, channel_id: string, insertPoint: string | number, guild?: Guild): Promise<number>;
+ static async insertChannelInOrder(guild_id: string, channel_id: string, insertPoint: string | number, guild?: Guild): Promise<number> {
+ if (!guild)
+ guild = await Guild.findOneOrFail({
+ where: { id: guild_id },
+ select: { channel_ordering: true },
+ });
+
+ guild.channel_ordering ??= [];
+
+ let position;
+ if (typeof insertPoint == "string") position = guild.channel_ordering.indexOf(insertPoint) + 1;
+ else position = insertPoint;
+
+ arrayRemove(guild.channel_ordering, channel_id);
+
+ guild.channel_ordering.splice(position, 0, channel_id);
+ await Guild.update({ id: guild_id }, { channel_ordering: guild.channel_ordering });
+ return position;
+ }
+
+ toJSON(): Guild {
+ return {
+ ...this,
+ unavailable: this.unavailable == false ? undefined : true,
+ channel_ordering: undefined,
+ discovery_weight: undefined,
+ discovery_excluded: undefined,
+ parent: undefined,
+ primary_category_id: undefined,
+ nsfw: undefined,
+ template_id: undefined,
+ presence_count: undefined,
+ };
+ }
+}
|