diff --git a/src/util/entities/Member.ts b/src/util/entities/Member.ts
new file mode 100644
index 00000000..baac58ed
--- /dev/null
+++ b/src/util/entities/Member.ts
@@ -0,0 +1,360 @@
+import { PublicUser, User } from "./User";
+import { BaseClass } from "./BaseClass";
+import {
+ Column,
+ Entity,
+ Index,
+ JoinColumn,
+ JoinTable,
+ ManyToMany,
+ ManyToOne,
+ PrimaryGeneratedColumn,
+ RelationId,
+} from "typeorm";
+import { Guild } from "./Guild";
+import { Config, emitEvent } from "../util";
+import {
+ GuildCreateEvent,
+ GuildDeleteEvent,
+ GuildMemberAddEvent,
+ GuildMemberRemoveEvent,
+ GuildMemberUpdateEvent,
+} from "../interfaces";
+import { HTTPError } from "../util/imports/HTTPError";
+import { Role } from "./Role";
+import { BaseClassWithoutId } from "./BaseClass";
+import { Ban, PublicGuildRelations } from ".";
+import { DiscordApiErrors } from "../util/Constants";
+import { OrmUtils } from "../util/imports/OrmUtils";
+
+export const MemberPrivateProjection: (keyof Member)[] = [
+ "id",
+ "guild",
+ "guild_id",
+ "deaf",
+ "joined_at",
+ "last_message_id",
+ "mute",
+ "nick",
+ "pending",
+ "premium_since",
+ "roles",
+ "settings",
+ "user",
+];
+
+@Entity("members")
+@Index(["id", "guild_id"], { unique: true })
+export class Member extends BaseClassWithoutId {
+ @PrimaryGeneratedColumn()
+ index: string;
+
+ @Column()
+ @RelationId((member: Member) => member.user)
+ id: string;
+
+ @JoinColumn({ name: "id" })
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ user: User;
+
+ @Column()
+ @RelationId((member: Member) => member.guild)
+ guild_id: string;
+
+ @JoinColumn({ name: "guild_id" })
+ @ManyToOne(() => Guild, {
+ onDelete: "CASCADE",
+ })
+ guild: Guild;
+
+ @Column({ nullable: true })
+ nick?: string;
+
+ @JoinTable({
+ name: "member_roles",
+ joinColumn: { name: "index", referencedColumnName: "index" },
+ inverseJoinColumn: {
+ name: "role_id",
+ referencedColumnName: "id",
+ },
+ })
+ @ManyToMany(() => Role, { cascade: true })
+ roles: Role[];
+
+ @Column()
+ joined_at: Date;
+
+ @Column({ nullable: true })
+ premium_since?: Date;
+
+ @Column()
+ deaf: boolean;
+
+ @Column()
+ mute: boolean;
+
+ @Column()
+ pending: boolean;
+
+ @Column({ type: "simple-json", select: false })
+ settings: UserGuildSettings;
+
+ @Column({ nullable: true })
+ last_message_id?: string;
+
+ /**
+ @JoinColumn({ name: "id" })
+ @ManyToOne(() => User, {
+ onDelete: "DO NOTHING",
+ // do not auto-kick force-joined members just because their joiners left the server
+ }) **/
+ @Column({ nullable: true })
+ joined_by?: string;
+
+ // TODO: add this when we have proper read receipts
+ // @Column({ type: "simple-json" })
+ // read_state: ReadState;
+
+ static async IsInGuildOrFail(user_id: string, guild_id: string) {
+ if (await Member.count({ where: { id: user_id, guild: { id: guild_id } } })) return true;
+ throw new HTTPError("You are not member of this guild", 403);
+ }
+
+ static async removeFromGuild(user_id: string, guild_id: string) {
+ const guild = await Guild.findOneOrFail({ select: ["owner_id", "member_count"], where: { id: guild_id } });
+ if (guild.owner_id === user_id) throw new Error("The owner cannot be removed of the guild");
+ const member = await Member.findOneOrFail({ where: { id: user_id, guild_id }, relations: ["user"] });
+
+ // use promise all to execute all promises at the same time -> save time
+ //TODO: check for bugs
+ if (guild.member_count) guild.member_count--;
+ return Promise.all([
+ Member.delete({
+ id: user_id,
+ guild_id,
+ }),
+ //Guild.decrement({ id: guild_id }, "member_count", -1),
+
+ emitEvent({
+ event: "GUILD_DELETE",
+ data: {
+ id: guild_id,
+ },
+ user_id: user_id,
+ } as GuildDeleteEvent),
+ emitEvent({
+ event: "GUILD_MEMBER_REMOVE",
+ data: { guild_id, user: member.user },
+ guild_id,
+ } as GuildMemberRemoveEvent),
+ ]);
+ }
+
+ static async addRole(user_id: string, guild_id: string, role_id: string) {
+ const [member, role] = await Promise.all([
+ // @ts-ignore
+ Member.findOneOrFail({
+ where: { id: user_id, guild_id },
+ relations: ["user", "roles"], // we don't want to load the role objects just the ids
+ select: ["index"],
+ }),
+ Role.findOneOrFail({ where: { id: role_id, guild_id }, select: ["id"] }),
+ ]);
+ member.roles.push(OrmUtils.mergeDeep(new Role(), { id: role_id }));
+
+ await Promise.all([
+ member.save(),
+ emitEvent({
+ event: "GUILD_MEMBER_UPDATE",
+ data: {
+ guild_id,
+ user: member.user,
+ roles: member.roles.map((x) => x.id),
+ },
+ guild_id,
+ } as GuildMemberUpdateEvent),
+ ]);
+ }
+
+ static async removeRole(user_id: string, guild_id: string, role_id: string) {
+ const [member] = await Promise.all([
+ // @ts-ignore
+ Member.findOneOrFail({
+ where: { id: user_id, guild_id },
+ relations: ["user", "roles"], // we don't want to load the role objects just the ids
+ select: ["index"],
+ }),
+ await Role.findOneOrFail({ where: { id: role_id, guild_id } }),
+ ]);
+ member.roles = member.roles.filter((x) => x.id == role_id);
+
+ await Promise.all([
+ member.save(),
+ emitEvent({
+ event: "GUILD_MEMBER_UPDATE",
+ data: {
+ guild_id,
+ user: member.user,
+ roles: member.roles.map((x) => x.id),
+ },
+ guild_id,
+ } as GuildMemberUpdateEvent),
+ ]);
+ }
+
+ static async changeNickname(user_id: string, guild_id: string, nickname: string) {
+ const member = await Member.findOneOrFail({
+ where: {
+ id: user_id,
+ guild_id,
+ },
+ relations: ["user"],
+ });
+ member.nick = nickname;
+
+ await Promise.all([
+ member.save(),
+
+ emitEvent({
+ event: "GUILD_MEMBER_UPDATE",
+ data: {
+ guild_id,
+ user: member.user,
+ nick: nickname,
+ },
+ guild_id,
+ } as GuildMemberUpdateEvent),
+ ]);
+ }
+
+ static async addToGuild(user_id: string, guild_id: string) {
+ const user = await User.getPublicUser(user_id);
+ const isBanned = await Ban.count({ where: { guild_id, user_id } });
+ if (isBanned) {
+ throw DiscordApiErrors.USER_BANNED;
+ }
+ const { maxGuilds } = Config.get().limits.user;
+ const guild_count = await Member.count({ where: { id: user_id } });
+ if (guild_count >= maxGuilds) {
+ throw new HTTPError(`You are at the ${maxGuilds} server limit.`, 403);
+ }
+
+ const guild = await Guild.findOneOrFail({
+ where: {
+ id: guild_id,
+ },
+ relations: PublicGuildRelations,
+ });
+
+ if (await Member.count({ where: { id: user.id, guild: { id: guild_id } } }))
+ throw new HTTPError("You are already a member of this guild", 400);
+
+ const member = {
+ id: user_id,
+ guild_id,
+ nick: undefined,
+ roles: [guild_id], // @everyone role
+ joined_at: new Date(),
+ premium_since: null,
+ deaf: false,
+ mute: false,
+ pending: false,
+ };
+ //TODO: check for bugs
+ if (guild.member_count) guild.member_count++;
+ await Promise.all([
+ OrmUtils.mergeDeep(new Member(), {
+ ...member,
+ roles: [OrmUtils.mergeDeep(new Role(), { id: guild_id })],
+ // read_state: {},
+ settings: {
+ channel_overrides: [],
+ message_notifications: 0,
+ mobile_push: true,
+ muted: false,
+ suppress_everyone: false,
+ suppress_roles: false,
+ version: 0,
+ },
+ // Member.save is needed because else the roles relations wouldn't be updated
+ }).save(),
+ //Guild.increment({ id: guild_id }, "member_count", 1),
+ emitEvent({
+ event: "GUILD_MEMBER_ADD",
+ data: {
+ ...member,
+ user,
+ guild_id,
+ },
+ guild_id,
+ } as GuildMemberAddEvent),
+ emitEvent({
+ event: "GUILD_CREATE",
+ data: {
+ ...guild,
+ members: [...guild.members, { ...member, user }],
+ member_count: (guild.member_count || 0) + 1,
+ guild_hashes: {},
+ guild_scheduled_events: [],
+ joined_at: member.joined_at,
+ presences: [],
+ stage_instances: [],
+ threads: [],
+ },
+ user_id,
+ } as GuildCreateEvent),
+ ]);
+ }
+}
+
+export interface UserGuildSettings {
+ channel_overrides: {
+ channel_id: string;
+ message_notifications: number;
+ mute_config: MuteConfig;
+ muted: boolean;
+ }[];
+ message_notifications: number;
+ mobile_push: boolean;
+ mute_config: MuteConfig;
+ muted: boolean;
+ suppress_everyone: boolean;
+ suppress_roles: boolean;
+ version: number;
+}
+
+export interface MuteConfig {
+ end_time: number;
+ selected_time_window: number;
+}
+
+export type PublicMemberKeys =
+ | "id"
+ | "guild_id"
+ | "nick"
+ | "roles"
+ | "joined_at"
+ | "pending"
+ | "deaf"
+ | "mute"
+ | "premium_since";
+
+export const PublicMemberProjection: PublicMemberKeys[] = [
+ "id",
+ "guild_id",
+ "nick",
+ "roles",
+ "joined_at",
+ "pending",
+ "deaf",
+ "mute",
+ "premium_since",
+];
+
+// @ts-ignore
+export type PublicMember = Pick<Member, Omit<PublicMemberKeys, "roles">> & {
+ user: PublicUser;
+ roles: string[]; // only role ids not objects
+};
|