diff --git a/server/drizzle/0000_curious_arachne.sql b/server/drizzle/0000_curious_arachne.sql deleted file mode 100644 index 76c18d5..0000000 --- a/server/drizzle/0000_curious_arachne.sql +++ /dev/null @@ -1,47 +0,0 @@ -DO $$ BEGIN - CREATE TYPE "public"."member_role" AS ENUM('owner', 'admin', 'member'); -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "groups" ( - "id" bigserial PRIMARY KEY NOT NULL, - "name" varchar(50) NOT NULL, - "owner_id" bigint NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "members" ( - "id" bigserial PRIMARY KEY NOT NULL, - "user_id" bigint NOT NULL, - "group_id" bigint NOT NULL, - "role" "member_role" DEFAULT 'member' NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "members_user_id_group_id_unique" UNIQUE("user_id","group_id") -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "messages" ( - "id" bigserial PRIMARY KEY NOT NULL, - "sender_id" bigint NOT NULL, - "receiver_id" bigint, - "group_id" bigint, - "content" text NOT NULL -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS "users" ( - "id" bigserial PRIMARY KEY NOT NULL, - "username" varchar(40) NOT NULL, - "password" varchar NOT NULL, - "full_name" varchar NOT NULL, - "created_at" timestamp with time zone DEFAULT now() NOT NULL, - CONSTRAINT "users_username_unique" UNIQUE("username") -); ---> statement-breakpoint -DO $$ BEGIN - ALTER TABLE "groups" ADD CONSTRAINT "groups_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; -EXCEPTION - WHEN duplicate_object THEN null; -END $$; ---> statement-breakpoint -CREATE INDEX IF NOT EXISTS "members_user_id_index" ON "members" USING btree ("user_id");--> statement-breakpoint -CREATE INDEX IF NOT EXISTS "members_group_id_index" ON "members" USING btree ("group_id"); \ No newline at end of file diff --git a/server/drizzle/0000_flashy_old_lace.sql b/server/drizzle/0000_flashy_old_lace.sql new file mode 100644 index 0000000..bbdcc4e --- /dev/null +++ b/server/drizzle/0000_flashy_old_lace.sql @@ -0,0 +1,103 @@ +DO $$ BEGIN + CREATE TYPE "public"."member_role" AS ENUM('member', 'admin', 'owner'); +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "groups" ( + "id" bigserial PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now(), + "name" varchar(50) NOT NULL, + "owner_id" bigint NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "members" ( + "id" bigserial PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now(), + "user_id" bigint NOT NULL, + "group_id" bigint NOT NULL, + "role" "member_role" DEFAULT 'member' NOT NULL, + CONSTRAINT "members_user_id_group_id_unique" UNIQUE("user_id","group_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "message_recipients" ( + "id" bigserial PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now(), + "message_id" bigint NOT NULL, + "recipient_id" bigint NOT NULL, + CONSTRAINT "message_recipients_message_id_recipient_id_unique" UNIQUE("message_id","recipient_id") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "messages" ( + "id" bigserial PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now(), + "sender_id" bigint NOT NULL, + "receiver_id" bigint, + "group_id" bigint, + "content" text NOT NULL +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "users" ( + "id" bigserial PRIMARY KEY NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now(), + "username" varchar(40) NOT NULL, + "password" varchar NOT NULL, + "full_name" varchar NOT NULL, + CONSTRAINT "users_username_unique" UNIQUE("username") +); +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "groups" ADD CONSTRAINT "groups_owner_id_users_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "members" ADD CONSTRAINT "members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "members" ADD CONSTRAINT "members_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "message_recipients" ADD CONSTRAINT "message_recipients_message_id_messages_id_fk" FOREIGN KEY ("message_id") REFERENCES "public"."messages"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "message_recipients" ADD CONSTRAINT "message_recipients_recipient_id_users_id_fk" FOREIGN KEY ("recipient_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_receiver_id_users_id_fk" FOREIGN KEY ("receiver_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +DO $$ BEGIN + ALTER TABLE "messages" ADD CONSTRAINT "messages_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE no action ON UPDATE no action; +EXCEPTION + WHEN duplicate_object THEN null; +END $$; +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "members_user_id_index" ON "members" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "members_group_id_index" ON "members" USING btree ("group_id"); \ No newline at end of file diff --git a/server/drizzle/0001_legal_mariko_yashida.sql b/server/drizzle/0001_legal_mariko_yashida.sql deleted file mode 100644 index f50b24f..0000000 --- a/server/drizzle/0001_legal_mariko_yashida.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE "groups" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now();--> statement-breakpoint -ALTER TABLE "members" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now();--> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN "created_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint -ALTER TABLE "messages" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now();--> statement-breakpoint -ALTER TABLE "users" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now(); \ No newline at end of file diff --git a/server/drizzle/meta/0000_snapshot.json b/server/drizzle/meta/0000_snapshot.json index df01584..4e126fd 100644 --- a/server/drizzle/meta/0000_snapshot.json +++ b/server/drizzle/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "05894d3c-04b3-4ad5-b926-fa23714eb1cb", + "id": "f5ef2fa7-847e-408f-86ad-30a3d7490cbc", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -14,6 +14,20 @@ "primaryKey": true, "notNull": true }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, "name": { "name": "name", "type": "varchar(50)", @@ -25,13 +39,6 @@ "type": "bigint", "primaryKey": false, "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" } }, "indexes": {}, @@ -63,6 +70,20 @@ "primaryKey": true, "notNull": true }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, "user_id": { "name": "user_id", "type": "bigint", @@ -82,13 +103,6 @@ "primaryKey": false, "notNull": true, "default": "'member'" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" } }, "indexes": { @@ -123,7 +137,34 @@ "with": {} } }, - "foreignKeys": {}, + "foreignKeys": { + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "members_group_id_groups_id_fk": { + "name": "members_group_id_groups_id_fk", + "tableFrom": "members", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": { "members_user_id_group_id_unique": { @@ -136,6 +177,84 @@ } } }, + "public.message_recipients": { + "name": "message_recipients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "recipient_id": { + "name": "recipient_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "message_recipients_message_id_messages_id_fk": { + "name": "message_recipients_message_id_messages_id_fk", + "tableFrom": "message_recipients", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "message_recipients_recipient_id_users_id_fk": { + "name": "message_recipients_recipient_id_users_id_fk", + "tableFrom": "message_recipients", + "tableTo": "users", + "columnsFrom": [ + "recipient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "message_recipients_message_id_recipient_id_unique": { + "name": "message_recipients_message_id_recipient_id_unique", + "nullsNotDistinct": false, + "columns": [ + "message_id", + "recipient_id" + ] + } + } + }, "public.messages": { "name": "messages", "schema": "", @@ -146,6 +265,20 @@ "primaryKey": true, "notNull": true }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, "sender_id": { "name": "sender_id", "type": "bigint", @@ -172,7 +305,47 @@ } }, "indexes": {}, - "foreignKeys": {}, + "foreignKeys": { + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_receiver_id_users_id_fk": { + "name": "messages_receiver_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "receiver_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_group_id_groups_id_fk": { + "name": "messages_group_id_groups_id_fk", + "tableFrom": "messages", + "tableTo": "groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, "compositePrimaryKeys": {}, "uniqueConstraints": {} }, @@ -186,6 +359,20 @@ "primaryKey": true, "notNull": true }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, "username": { "name": "username", "type": "varchar(40)", @@ -203,13 +390,6 @@ "type": "varchar", "primaryKey": false, "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" } }, "indexes": {}, @@ -231,9 +411,9 @@ "name": "member_role", "schema": "public", "values": [ - "owner", + "member", "admin", - "member" + "owner" ] } }, diff --git a/server/drizzle/meta/0001_snapshot.json b/server/drizzle/meta/0001_snapshot.json deleted file mode 100644 index feb2c00..0000000 --- a/server/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,281 +0,0 @@ -{ - "id": "7e751533-330c-4d49-9bd7-2dda8da6df0f", - "prevId": "05894d3c-04b3-4ad5-b926-fa23714eb1cb", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.groups": { - "name": "groups", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "name": { - "name": "name", - "type": "varchar(50)", - "primaryKey": false, - "notNull": true - }, - "owner_id": { - "name": "owner_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "groups_owner_id_users_id_fk": { - "name": "groups_owner_id_users_id_fk", - "tableFrom": "groups", - "tableTo": "users", - "columnsFrom": [ - "owner_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "public.members": { - "name": "members", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "user_id": { - "name": "user_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "group_id": { - "name": "group_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "role": { - "name": "role", - "type": "member_role", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'member'" - } - }, - "indexes": { - "members_user_id_index": { - "name": "members_user_id_index", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "members_group_id_index": { - "name": "members_group_id_index", - "columns": [ - { - "expression": "group_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "members_user_id_group_id_unique": { - "name": "members_user_id_group_id_unique", - "nullsNotDistinct": false, - "columns": [ - "user_id", - "group_id" - ] - } - } - }, - "public.messages": { - "name": "messages", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "sender_id": { - "name": "sender_id", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "receiver_id": { - "name": "receiver_id", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "group_id": { - "name": "group_id", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "content": { - "name": "content", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {} - }, - "public.users": { - "name": "users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "bigserial", - "primaryKey": true, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false, - "default": "now()" - }, - "username": { - "name": "username", - "type": "varchar(40)", - "primaryKey": false, - "notNull": true - }, - "password": { - "name": "password", - "type": "varchar", - "primaryKey": false, - "notNull": true - }, - "full_name": { - "name": "full_name", - "type": "varchar", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "users_username_unique": { - "name": "users_username_unique", - "nullsNotDistinct": false, - "columns": [ - "username" - ] - } - } - } - }, - "enums": { - "public.member_role": { - "name": "member_role", - "schema": "public", - "values": [ - "owner", - "admin", - "member" - ] - } - }, - "schemas": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 6436d15..6315693 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "7", - "when": 1719812511026, - "tag": "0000_curious_arachne", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1719814587505, - "tag": "0001_legal_mariko_yashida", + "when": 1720706275691, + "tag": "0000_flashy_old_lace", "breakpoints": true } ] diff --git a/server/src/index.ts b/server/src/index.ts index ff8ef2a..89b906e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -21,7 +21,7 @@ import { InterServerEvents, ServerToClientEvents, SocketData, -} from './socket/socket.inteface' +} from './socket/socket.interface' const createApp = async () => { if (cluster.isPrimary && config.isProd) { diff --git a/server/src/modules/groups/groups.controller.ts b/server/src/modules/groups/groups.controller.ts index 17d86fd..0a7c5ef 100644 --- a/server/src/modules/groups/groups.controller.ts +++ b/server/src/modules/groups/groups.controller.ts @@ -1,13 +1,16 @@ import { db } from '@/database' import { getPaginationParams, withPagination } from '@/database/helpers' import { deleteGroupMembersRoles } from '@/redis/handlers' -import { TypedIOServer } from '@/socket/socket.inteface' +import { getGroupRoomId } from '@/socket/helpers' +import { TypedIOServer } from '@/socket/socket.interface' import { notFound } from '@/utils/api' import { and, + count, desc, eq, getTableColumns, + isNull, like, lt, notInArray, @@ -16,7 +19,7 @@ import { import { RequestHandler } from 'express' import { members } from '../members/members.schema' import { addMembers } from '../members/members.service' -import { messages } from '../messages/messages.schema' +import { messageRecipients, messages } from '../messages/messages.schema' import { users } from '../users/users.schema' import { groups } from './groups.schema' @@ -99,41 +102,83 @@ export const listGroups: RequestHandler = async (req, res, next) => { export const listUserGroups: RequestHandler = async (req, res, next) => { try { - const sq = db - .select({ - ...getTableColumns(messages), - seqNum: - sql`ROW_NUMBER() OVER (PARTITION BY ${messages.groupId} ORDER BY ${desc(messages.createdAt)})`.as( - 'seq_num', - ), - }) - .from(messages) - .as('messages_with_seq') + const messagesWithSequence = db.$with('messages_with_sequence').as( + db + .select({ + ...getTableColumns(messages), + seqNum: + sql`ROW_NUMBER() OVER (PARTITION BY ${messages.groupId} ORDER BY ${desc(messages.createdAt)})`.as( + 'seq_num', + ), + }) + .from(messages), + ) const groupsWithLastMessage = db.$with('groups_with_last_message').as( db + .with(messagesWithSequence) .select({ ...getTableColumns(groups), lastMessage: { - id: sql`${sq.id}`.as('message_id'), - content: sq.content, - senderId: sq.senderId, + id: sql`${messagesWithSequence.id}`.as('message_id'), + content: messagesWithSequence.content, + senderId: messagesWithSequence.senderId, }, lastActivity: - sql`COALESCE (${sq.createdAt}, ${groups.createdAt})`.as( + sql`COALESCE (${messagesWithSequence.createdAt}, ${groups.createdAt})`.as( 'last_activity', ), }) .from(groups) - .leftJoin(sq, and(eq(groups.id, sq.groupId), eq(sq.seqNum, 1))) + .leftJoin( + messagesWithSequence, + and( + eq(groups.id, messagesWithSequence.groupId), + eq(messagesWithSequence.seqNum, 1), + ), + ) .innerJoin(members, eq(members.groupId, groups.id)) .where(eq(members.userId, req.user!.id)), ) + const unreadCounts = db.$with('unread_counts').as( + db + .select({ + groupId: groups.id, + unreadCount: count(messages.id).as('unread_count'), + }) + .from(groups) + .leftJoin(messages, eq(groups.id, messages.groupId)) + .leftJoin( + messageRecipients, + and( + eq(messageRecipients.messageId, messages.id), + eq(messageRecipients.recipientId, req.user!.id), + ), + ) + .where(isNull(messageRecipients.messageId)) + .groupBy(groups.id), + ) + const qb = db - .with(groupsWithLastMessage) - .select() + .with(groupsWithLastMessage, unreadCounts) + .select({ + lastMessage: groupsWithLastMessage.lastMessage, + lastActivity: groupsWithLastMessage.lastActivity, + id: groupsWithLastMessage.id, + name: groupsWithLastMessage.name, + ownerId: groupsWithLastMessage.ownerId, + createdAt: groupsWithLastMessage.createdAt, + updatedAt: groupsWithLastMessage.updatedAt, + unreadCount: sql`COALESCE (${unreadCounts.unreadCount}, 0)` + .mapWith(Number) + .as('unread_count'), + }) .from(groupsWithLastMessage) + .leftJoin( + unreadCounts, + eq(unreadCounts.groupId, groupsWithLastMessage.id), + ) .$dynamic() const { cursor, limit } = getPaginationParams(req.query, 'date') @@ -177,7 +222,7 @@ export const addGroupMembers: RequestHandler = async (req, res, next) => { const io = req.app.get('io') as TypedIOServer - io.to(req.params.groupId).emit('newMembers', newMembers) + io.to(getGroupRoomId(req.params.groupId)).emit('newMembers', newMembers) // let existing members know new member is joined in member list diff --git a/server/src/modules/members/members.controller.ts b/server/src/modules/members/members.controller.ts index 38e5e53..d68b5d5 100644 --- a/server/src/modules/members/members.controller.ts +++ b/server/src/modules/members/members.controller.ts @@ -1,7 +1,12 @@ import { db } from '@/database' import { getPaginationParams, withPagination } from '@/database/helpers' -import { checkOnlineUsers, setGroupMemberRoleTxn } from '@/redis/handlers' -import { TypedIOServer } from '@/socket/socket.inteface' +import { + checkOnlineUsers, + getMultipleUserSockets, + setGroupMemberRoleTxn, +} from '@/redis/handlers' +import { getGroupRoomId } from '@/socket/helpers' +import { TypedIOServer } from '@/socket/socket.interface' import { badRequest } from '@/utils/api' import { and, asc, eq, getTableColumns, gt } from 'drizzle-orm' import { RequestHandler } from 'express' @@ -31,12 +36,19 @@ export const joinRooms: RequestHandler = async (req, res, next) => { rows.forEach(member => { groupMemberRoles[member.groupId] = [member.userId, 'member'] - io.to(member.groupId.toString()).emit('newMember', { + io.to(getGroupRoomId(member.groupId)).emit('newMember', { ...member, username: req.user!.username, }) }) + const currentUserSockets = await getMultipleUserSockets([req.user!.id]) + + currentUserSockets.forEach(socketId => { + const socket = io.sockets.sockets.get(socketId) + socket?.join(rows.map(member => member.groupId.toString())) + }) + setGroupMemberRoleTxn(groupMemberRoles) res.status(201).json(rows) diff --git a/server/src/modules/members/members.schema.ts b/server/src/modules/members/members.schema.ts index bbc4d3f..29e8933 100644 --- a/server/src/modules/members/members.schema.ts +++ b/server/src/modules/members/members.schema.ts @@ -23,9 +23,9 @@ export const members = pgTable( role: memberRoleEnum('role').notNull().default('member'), }, table => ({ - pk: unique().on(table.userId, table.groupId), - memberUserIndex: index().on(table.userId), - memberGroupIndex: index().on(table.groupId), + uniqueUserGroupIndex: unique().on(table.userId, table.groupId), + userIndex: index().on(table.userId), + groupIndex: index().on(table.groupId), }), ) diff --git a/server/src/modules/members/members.service.ts b/server/src/modules/members/members.service.ts index 2bc0bd7..8410592 100644 --- a/server/src/modules/members/members.service.ts +++ b/server/src/modules/members/members.service.ts @@ -1,10 +1,10 @@ import { db } from '@/database' import { getMemberRole, - getUserSockets, + getMultipleUserSockets, setMemberRolesForAGroup, } from '@/redis/handlers' -import { TypedIOServer } from '@/socket/socket.inteface' +import { TypedIOServer } from '@/socket/socket.interface' import { and, eq } from 'drizzle-orm' import { NodePgDatabase } from 'drizzle-orm/node-postgres' import { Group } from '../groups/groups.schema' @@ -52,21 +52,35 @@ export const addMembers = async ( const newMembers = await db.insert(members).values(memberValues).returning() - const userIds: number[] = [] const memberRoles: Record = {} newMembers.forEach(member => { - if (member.userId !== group.ownerId) { - userIds.push(member.userId) - } memberRoles[member.userId] = member.role }) setMemberRolesForAGroup(group.id, memberRoles) - const userSockets = await getUserSockets(userIds) + const userSockets = await getMultipleUserSockets(memberIds) + + userSockets.forEach(socketId => { + const socket = io.sockets.sockets.get(socketId) + console.log('member socket', socket) + socket?.join(group.id.toString()) + }) io.to(userSockets).emit('newGroup', group) return newMembers } + +export const joinMultiSocketRooms = async ( + io: TypedIOServer, + userIds: number[], + groupIds: number[], +) => { + const userSockets = await getMultipleUserSockets(userIds) + + userSockets.forEach(socketId => { + io.sockets.sockets.get(socketId)?.join(groupIds.map(String)) + }) +} diff --git a/server/src/modules/messages/messages.schema.ts b/server/src/modules/messages/messages.schema.ts index cc7560a..1d5c655 100644 --- a/server/src/modules/messages/messages.schema.ts +++ b/server/src/modules/messages/messages.schema.ts @@ -1,19 +1,26 @@ import { baseSchema } from '@/database/constants' -import { bigint, pgTable, text, unique } from 'drizzle-orm/pg-core' +import { bigint, index, pgTable, text, unique } from 'drizzle-orm/pg-core' import { groups } from '../groups/groups.schema' import { users } from '../users/users.schema' -export const messages = pgTable('messages', { - ...baseSchema, - senderId: bigint('sender_id', { mode: 'number' }) - .references(() => users.id) - .notNull(), - receiverId: bigint('receiver_id', { mode: 'number' }).references( - () => users.id, - ), - groupId: bigint('group_id', { mode: 'number' }).references(() => groups.id), - content: text('content').notNull(), -}) +export const messages = pgTable( + 'messages', + { + ...baseSchema, + senderId: bigint('sender_id', { mode: 'number' }) + .references(() => users.id) + .notNull(), + receiverId: bigint('receiver_id', { mode: 'number' }).references( + () => users.id, + ), + groupId: bigint('group_id', { mode: 'number' }).references(() => groups.id), + content: text('content').notNull(), + }, + table => ({ + groupIdIndex: index().on(table.groupId), + createdAtIndex: index().on(table.createdAt), + }), +) export const messageRecipients = pgTable( 'message_recipients', @@ -27,7 +34,10 @@ export const messageRecipients = pgTable( .notNull(), }, table => ({ - uniqueMessageRecipient: unique().on(table.messageId, table.recipientId), + uniqueMessageRecipientIndex: unique().on( + table.messageId, + table.recipientId, + ), }), ) diff --git a/server/src/redis/handlers.ts b/server/src/redis/handlers.ts index e4d807c..e8f2955 100644 --- a/server/src/redis/handlers.ts +++ b/server/src/redis/handlers.ts @@ -94,7 +94,11 @@ export const removeUserSocket = async (userId: number, socketId: string) => { return redisClient.srem(redisKeys.SOCKET_MAP(userId), socketId) } -export const getUserSockets = async (userIds: number[]) => { +export const getUserSockets = async (userId: number) => { + return redisClient.smembers(redisKeys.SOCKET_MAP(userId)) +} + +export const getMultipleUserSockets = async (userIds: number[]) => { if (!userIds.length) return [] return redisClient.sunion(userIds.map(uid => redisKeys.SOCKET_MAP(uid))) } diff --git a/server/src/scripts/seed.ts b/server/src/scripts/seed.ts index d0c2bab..f2af73c 100644 --- a/server/src/scripts/seed.ts +++ b/server/src/scripts/seed.ts @@ -8,10 +8,10 @@ import { hash } from 'argon2' import 'colors' const USER_PASSWORD = 'bob@123' -const USER_COUNT = 300 +const USER_COUNT = 50 const GROUP_COUNT_PER_USER = 5 -const MEMBER_COUNT_PER_GROUP = 35 +const MEMBER_COUNT_PER_GROUP = 5 const MESSAGE_PER_MEMBER = 5 const BATCH_SIZE = 100 diff --git a/server/src/socket/events.ts b/server/src/socket/events.ts index 7dd91e9..ed947d3 100644 --- a/server/src/socket/events.ts +++ b/server/src/socket/events.ts @@ -1,12 +1,15 @@ import { db } from '@/database' +import { groups } from '@/modules/groups/groups.schema' +import { members } from '@/modules/members/members.schema' import { checkPermission } from '@/modules/members/members.service' -import { messages } from '@/modules/messages/messages.schema' +import { messageRecipients, messages } from '@/modules/messages/messages.schema' import { insertMessage, markMessageAsRead, } from '@/modules/messages/messages.service' import { addUserSocket, + getMultipleUserSockets, getTypingUsers, getUserSockets, markUserOffline, @@ -15,23 +18,16 @@ import { removeUserSocket, setTypingUser, } from '@/redis/handlers' -import { eq } from 'drizzle-orm' +import { and, eq, isNull } from 'drizzle-orm' import { config } from '../config' -import { TypedIOServer, TypedSocket } from './socket.inteface' +import { getGroupRoomId } from './helpers' +import { TypedIOServer, TypedSocket } from './socket.interface' + +export const groupRoomPrefix = 'group' async function emitTypingUsers(socket: TypedSocket, groupId: number) { const typingUsers = await getTypingUsers(groupId) - socket.broadcast.to(String(groupId)).emit('typingUsers', typingUsers) -} - -function leaveAllRoom(socket: TypedSocket) { - const rooms = Array.from(socket.rooms) - rooms.forEach(room => { - if (room !== socket.id) { - // don't leave the default room - socket.leave(room) - } - }) + socket.broadcast.to(getGroupRoomId(groupId)).emit('typingUsers', typingUsers) } export const registerSocketEvents = (io: TypedIOServer) => { @@ -40,9 +36,22 @@ export const registerSocketEvents = (io: TypedIOServer) => { await addUserSocket(socket.data.user.id, socket.id) socket.broadcast.emit('userOnline', socket.data.user.id) - socket.on('joinGroup', groupId => { - leaveAllRoom(socket) - socket.join(String(groupId)) + const userGroups = await db + .select({ id: groups.id }) + .from(groups) + .innerJoin(members, eq(members.groupId, groups.id)) + .where(eq(members.userId, socket.data.user.id)) + socket.join(userGroups.map(group => group.id.toString())) + + socket.on('joinGroup', async (groupId: number) => { + const rooms = Array.from(socket.rooms) + rooms.forEach(room => { + if (room !== socket.id && room.startsWith(groupRoomPrefix)) { + // don't leave the default room + socket.leave(room) + } + }) + socket.join(getGroupRoomId(groupId)) }) socket.on('userStartedTyping', async groupId => { @@ -97,10 +106,54 @@ export const registerSocketEvents = (io: TypedIOServer) => { } await markMessageAsRead(messageId, socket.data.user.id) - const senderSocketIds = await getUserSockets([message.senderId]) + const senderSocketIds = await getMultipleUserSockets([message.senderId]) io.to(senderSocketIds).emit('messageRead', messageId) }) + socket.on('markGroupMessagesAsRead', async groupId => { + const { isAllowed } = await checkPermission( + groupId, + socket.data.user.id, + 'member', + ) + + if (!isAllowed) { + throw new Error( + "markGroupMessagesAsRead: you don't have permission to mark the message as read", + ) + } + + const unreadMessages = await db + .select({ messageId: messages.id, senderId: messages.senderId }) + .from(messages) + .leftJoin( + messageRecipients, + and( + eq(messageRecipients.messageId, messages.id), + eq(messageRecipients.recipientId, socket.data.user.id), + ), + ) + .where( + and( + eq(messages.groupId, groupId), + isNull(messageRecipients.messageId), + ), + ) + + if (unreadMessages.length) { + await db.insert(messageRecipients).values( + unreadMessages.map(message => ({ + messageId: message.messageId, + recipientId: socket.data.user.id, + })), + ) + + const socketIds = await getUserSockets(socket.data.user.id) + + io.to(socketIds).emit('groupMarkedAsRead', groupId) + } + }) + socket.on('error', err => { console.log('socket error:', err) }) diff --git a/server/src/socket/helpers.ts b/server/src/socket/helpers.ts new file mode 100644 index 0000000..f2cf1da --- /dev/null +++ b/server/src/socket/helpers.ts @@ -0,0 +1,4 @@ +import { groupRoomPrefix } from './events' + +export const getGroupRoomId = (groupId: number | string) => + `${groupRoomPrefix}:${groupId}` diff --git a/server/src/socket/socket.inteface.ts b/server/src/socket/socket.interface.ts similarity index 93% rename from server/src/socket/socket.inteface.ts rename to server/src/socket/socket.interface.ts index 2312bbb..83d5f7e 100644 --- a/server/src/socket/socket.inteface.ts +++ b/server/src/socket/socket.interface.ts @@ -7,10 +7,11 @@ export interface ServerToClientEvents { userOnline: (userId: number) => void userOffline: (userId: number) => void newMessage: (message: Message & { username: string }) => void - messageRead: (messageId: number) => void newMember: (member: Member & { username: string }) => void newMembers: (member: Member[]) => void newGroup: (group: Group) => void + messageRead: (messageId: number) => void + groupMarkedAsRead: (groupId: number) => void typingUsers: (users: { id: number; username: string }[]) => void } @@ -21,6 +22,7 @@ export interface ClientToServerEvents { callback: (response: { message?: Message; error?: unknown }) => void, ) => void markMessageAsRead: (messageId: number) => void + markGroupMessagesAsRead: (groupId: number) => void userStartedTyping: (groupId: number) => void userStoppedTyping: (groupId: number) => void } diff --git a/web/src/components/Skeleton.tsx b/web/src/components/Skeleton.tsx index 66cf73f..3384927 100644 --- a/web/src/components/Skeleton.tsx +++ b/web/src/components/Skeleton.tsx @@ -15,12 +15,14 @@ export const Skeleton = ({ className }: { className?: string }) => { export const ArraySkeleton = ({ length = 3, className, + wrapperClassName, }: { className?: string + wrapperClassName?: string length?: number }) => { return ( -
+
{new Array(length).fill(0).map((_, i) => ( ))} diff --git a/web/src/features/group/components/CreateGroup.tsx b/web/src/features/group/components/CreateGroup.tsx index db793de..fa80cc9 100644 --- a/web/src/features/group/components/CreateGroup.tsx +++ b/web/src/features/group/components/CreateGroup.tsx @@ -6,7 +6,7 @@ import { useUsersSelect } from '@/features/user/hooks/useUsersSelect' import { useAutoFocus } from '@/hooks/useAutoFocus' import { useDisclosure } from '@/hooks/useDisclosure' import { useToast } from '@/hooks/useToast' -import { useMutation, useQueryClient } from '@tanstack/react-query' +import { useMutation } from '@tanstack/react-query' import { useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { createNewGroup } from '../group.service' @@ -16,7 +16,6 @@ const CreateGroupForm = ({ onComplete }: { onComplete: () => void }) => { const { toast } = useToast() const navigate = useNavigate() const inputRef = useRef(null) - const queryClient = useQueryClient() const userSelectProps = useUsersSelect() const { mutate: createGroup, isPending } = useMutation({ @@ -25,7 +24,6 @@ const CreateGroupForm = ({ onComplete }: { onComplete: () => void }) => { if (result.id) { toast({ title: `Group "${name}" created`, severity: 'success' }) onComplete() - queryClient.invalidateQueries({ queryKey: ['userGroups'] }) navigate(`/chat/${result.id}`) } }, diff --git a/web/src/features/group/components/UserGroupItem.tsx b/web/src/features/group/components/UserGroupItem.tsx index 2bff503..953002b 100644 --- a/web/src/features/group/components/UserGroupItem.tsx +++ b/web/src/features/group/components/UserGroupItem.tsx @@ -29,10 +29,23 @@ export const UserGroupItem = ({ group }: UserGroupItemProps) => { {group.lastMessage?.content}
-
- +
+ 0 ? 'text-green-600' : 'text-gray-500', + )} + > {formatGroupDate(group.lastActivity)} + 0 ? 'bg-green-600' : 'hidden', + )} + > + {group.unreadCount} +
diff --git a/web/src/features/group/components/UserGroupList.tsx b/web/src/features/group/components/UserGroupList.tsx index ae4ffb5..d0de6b1 100644 --- a/web/src/features/group/components/UserGroupList.tsx +++ b/web/src/features/group/components/UserGroupList.tsx @@ -1,17 +1,24 @@ import { Skeleton } from '@/components/Skeleton' +import { IMessage } from '@/features/message/message.interface' import { useAuth } from '@/hooks/useAuth' import { useInView } from '@/hooks/useInView' import { getSocketIO } from '@/utils/socket' import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' import { produce } from 'immer' import { Fragment, useEffect, useRef } from 'react' -import { IGroup, IPaginatedInfiniteGroups } from '../group.interface' +import { useParams } from 'react-router-dom' +import { + IGroup, + IGroupWithLastMessage, + IPaginatedInfiniteGroups, +} from '../group.interface' import { fetchUserGroups } from '../group.service' import { UserGroupItem } from './UserGroupItem' export const UserGroupList = () => { const { auth } = useAuth() const queryClient = useQueryClient() + const params = useParams() const { data, isLoading, isSuccess, hasNextPage, fetchNextPage, error } = useInfiniteQuery({ queryKey: ['userGroups', auth], @@ -38,26 +45,89 @@ export const UserGroupList = () => { const socket = getSocketIO() - function unshiftGroup(group: IGroup) { + function handleNewGroup(group: IGroup) { queryClient.setQueryData( ['userGroups', auth], data => { if (!data) return const updatedData = produce(data, draft => { - draft.pages[0].data.unshift(group) + draft.pages[0].data.unshift({ + ...group, + lastActivity: group.createdAt, + unreadCount: 0, + }) }) return updatedData }, ) } - socket.on('newGroup', unshiftGroup) + function handleNewMessage(message: IMessage) { + queryClient.setQueryData( + ['userGroups', auth], + data => { + if (!data) return + const updatedData = produce(data, draft => { + let messageGroup: IGroupWithLastMessage | undefined + + draft.pages.forEach(page => { + const groupIndex = page.data.findIndex( + group => group.id === message.groupId, + ) + if (groupIndex !== -1) { + messageGroup = page.data[groupIndex] + page.data.splice(groupIndex, 1) + } + }) + + if (messageGroup) { + messageGroup.lastMessage = { + id: message.id, + content: message.content, + senderId: message.senderId, + } + messageGroup.lastActivity = message.createdAt + if (params.groupId === message.groupId.toString()) { + socket.emit('markMessageAsRead', message.id) + } else { + messageGroup.unreadCount++ + } + draft.pages[0].data.unshift(messageGroup) + } + }) + return updatedData + }, + ) + } + + function handleGroupMarkedAsRead(groupId: number) { + queryClient.setQueryData( + ['userGroups', auth], + data => { + if (!data) return + const updatedData = produce(data, draft => { + draft.pages.forEach(page => { + const group = page.data.find(group => group.id === groupId) + if (group) { + group.unreadCount = 0 + } + }) + }) + return updatedData + }, + ) + } + + socket.on('newGroup', handleNewGroup) + socket.on('newMessage', handleNewMessage) + socket.on('groupMarkedAsRead', handleGroupMarkedAsRead) return () => { - socket.off('newGroup', unshiftGroup) + socket.off('newGroup', handleNewGroup) + socket.off('newMessage', handleNewMessage) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [auth]) + }, [auth, params.groupId]) let content @@ -67,7 +137,7 @@ export const UserGroupList = () => { content = new Array(5).map((_, idx) => ( )) - } else if (data?.pages.length) { + } else if (data?.pages[0].data.length) { content = (
    {data.pages.map((page, i) => ( diff --git a/web/src/features/group/group.interface.ts b/web/src/features/group/group.interface.ts index e00b18b..115ca09 100644 --- a/web/src/features/group/group.interface.ts +++ b/web/src/features/group/group.interface.ts @@ -17,10 +17,13 @@ export interface IGroupWithLastMessage extends IGroup { content: string senderId: number } + unreadCount: number lastActivity: string } -export type IPaginatedInfiniteGroups = InfiniteData> +export type IPaginatedInfiniteGroups = InfiniteData< + IPaginatedResult +> export type TGetUserGroupsQueryVariables = TPaginatedParams & { userId: number diff --git a/web/src/features/message/components/MessageList.tsx b/web/src/features/message/components/MessageList.tsx index 7c7f89d..4d095ff 100644 --- a/web/src/features/message/components/MessageList.tsx +++ b/web/src/features/message/components/MessageList.tsx @@ -37,6 +37,9 @@ export const MessageList = ({ groupId }: MessageListProps) => { const socket = getSocketIO() function updateMessage(message: IMessage) { + if (message.groupId !== groupId) { + return + } function scrollToBottom() { listRef.current?.scrollTo(0, listRef.current?.scrollHeight) } diff --git a/web/src/interfaces/socket.interface.ts b/web/src/interfaces/socket.interface.ts index ced2363..427b444 100644 --- a/web/src/interfaces/socket.interface.ts +++ b/web/src/interfaces/socket.interface.ts @@ -10,6 +10,8 @@ export interface ServerToClientEvents { newMember: (member: IMember) => void newMembers: (member: IMember[]) => void newGroup: (group: IGroup) => void + messageRead: (messageId: number) => void + groupMarkedAsRead: (groupId: number) => void typingUsers: (users: { id: number; username: string }[]) => void } @@ -23,6 +25,8 @@ export interface ClientToServerEvents { args: { groupId: number; text: string }, callback: (response: { message?: IMessage; error?: unknown }) => void, ) => void + markMessageAsRead: (messageId: number) => void + markGroupMessagesAsRead: (groupId: number) => void userStartedTyping: (groupId: number) => void userStoppedTyping: (groupId: number) => void } diff --git a/web/src/pages/ChatRoom.tsx b/web/src/pages/ChatRoom.tsx index e9817df..e5966e9 100644 --- a/web/src/pages/ChatRoom.tsx +++ b/web/src/pages/ChatRoom.tsx @@ -21,6 +21,7 @@ export const Component = () => { const socket = getSocketIO() if (groupId) { socket.emit('joinGroup', Number(groupId)) + socket.emit('markGroupMessagesAsRead', groupId) } }, [groupId])