Track client and server sources
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Submodule messenger-server deleted from 6255e4e012
19
messenger-server/.env.example
Normal file
19
messenger-server/.env.example
Normal file
@@ -0,0 +1,19 @@
|
||||
DATABASE_URL=your_database_url
|
||||
APP_NAME=your_app_name
|
||||
|
||||
SHORT_URL_DOMAIN=your_short_url_domain
|
||||
|
||||
JWT_SECRET_KEY=your_jwt_secret_key
|
||||
|
||||
SERVER_PORT=your_server_port
|
||||
WEBSOCKET_PORT=your_websocket_port
|
||||
|
||||
MAX_TEXT_MESSAGE_LENGTH=4096
|
||||
|
||||
FIREBASE_SERVICE_ACCOUNT_PATH=config/serviceAccountKey.json
|
||||
|
||||
S3_ACCESS_KEY=your_s3_access_key
|
||||
S3_SECRET_KEY=your_s3_secret_key
|
||||
S3_END_POINT=your_s3_end_point
|
||||
S3_BUCKET_NAME=your_s3_bucket_name
|
||||
S3_REGION=your_s3_region
|
||||
7
messenger-server/.prettierrc
Normal file
7
messenger-server/.prettierrc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"useTabs": true,
|
||||
"trailingComma": "none"
|
||||
}
|
||||
201
messenger-server/LICENSE.txt
Normal file
201
messenger-server/LICENSE.txt
Normal file
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
14
messenger-server/ecosystem.config.js
Normal file
14
messenger-server/ecosystem.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
export const apps = [
|
||||
{
|
||||
name: "messenger-server",
|
||||
script: "./dist/Server.js",
|
||||
|
||||
env: {
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
env_production: {
|
||||
NODE_ENV: "production",
|
||||
},
|
||||
env_file: '.env',
|
||||
},
|
||||
]
|
||||
35
messenger-server/eslint.config.mjs
Normal file
35
messenger-server/eslint.config.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
// @ts-check
|
||||
import eslint from '@eslint/js'
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'
|
||||
import globals from 'globals'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['eslint.config.mjs'],
|
||||
},
|
||||
eslint.configs.recommended,
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.jest,
|
||||
},
|
||||
sourceType: 'commonjs',
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
rules: {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
)
|
||||
8
messenger-server/nest-cli.json
Normal file
8
messenger-server/nest-cli.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
18054
messenger-server/package-lock.json
generated
Normal file
18054
messenger-server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
105
messenger-server/package.json
Normal file
105
messenger-server/package.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "messenger",
|
||||
"version": "1.0.0",
|
||||
"author": "Karen Aiwazian",
|
||||
"license": "ISC",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1004.0",
|
||||
"@aws-sdk/lib-storage": "^3.1004.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1004.0",
|
||||
"@nestjs/common": "^11.1.11",
|
||||
"@nestjs/config": "^4.0.2",
|
||||
"@nestjs/core": "^11.1.11",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/platform-express": "^11.1.11",
|
||||
"@nestjs/platform-socket.io": "^11.1.11",
|
||||
"@nestjs/throttler": "^6.5.0",
|
||||
"@nestjs/websockets": "^11.1.11",
|
||||
"@prisma/adapter-better-sqlite3": "^7.4.2",
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "^7.4.2",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.2.1",
|
||||
"firebase-admin": "^13.6.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lru-cache": "^11.2.4",
|
||||
"multer": "^2.0.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"socket.io": "^4.8.3",
|
||||
"sqlite": "^5.1.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"ws": "^8.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/cli": "^7.28.3",
|
||||
"@babel/core": "^7.28.5",
|
||||
"@babel/preset-env": "^7.28.5",
|
||||
"@babel/preset-typescript": "^7.28.5",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@nestjs/cli": "^11.0.14",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@nestjs/testing": "^11.1.11",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/sqlite3": "^5.1.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"globals": "^16.5.0",
|
||||
"jest": "^30.2.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prisma": "^7.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.1.4",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-loader": "^9.5.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.51.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
12
messenger-server/prisma.config.ts
Normal file
12
messenger-server/prisma.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'dotenv/config'
|
||||
import { defineConfig, env } from "prisma/config"
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations"
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL")
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
CREATE TABLE "KanbanBoard" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"ownerId" BIGINT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"createdAt" BIGINT NOT NULL,
|
||||
"updatedAt" BIGINT NOT NULL,
|
||||
CONSTRAINT "KanbanBoard_ownerId_fkey"
|
||||
FOREIGN KEY ("ownerId") REFERENCES "User" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE "KanbanColumn" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"boardId" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"position" INTEGER NOT NULL,
|
||||
CONSTRAINT "KanbanColumn_boardId_fkey"
|
||||
FOREIGN KEY ("boardId") REFERENCES "KanbanBoard" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE "KanbanTask" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"columnId" INTEGER NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"position" INTEGER NOT NULL,
|
||||
"createdAt" BIGINT NOT NULL,
|
||||
"updatedAt" BIGINT NOT NULL,
|
||||
CONSTRAINT "KanbanTask_columnId_fkey"
|
||||
FOREIGN KEY ("columnId") REFERENCES "KanbanColumn" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
ALTER TABLE "Message" ADD COLUMN "kanbanBoardId" INTEGER
|
||||
REFERENCES "KanbanBoard" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
ALTER TABLE "Message" ADD COLUMN "kanbanTaskId" INTEGER
|
||||
REFERENCES "KanbanTask" ("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
CREATE INDEX "KanbanBoard_ownerId_idx" ON "KanbanBoard" ("ownerId");
|
||||
CREATE INDEX "KanbanColumn_boardId_position_idx" ON "KanbanColumn" ("boardId", "position");
|
||||
CREATE INDEX "KanbanTask_columnId_position_idx" ON "KanbanTask" ("columnId", "position");
|
||||
CREATE INDEX "Message_kanbanBoardId_idx" ON "Message" ("kanbanBoardId");
|
||||
CREATE INDEX "Message_kanbanTaskId_idx" ON "Message" ("kanbanTaskId");
|
||||
355
messenger-server/prisma/schema.prisma
Normal file
355
messenger-server/prisma/schema.prisma
Normal file
@@ -0,0 +1,355 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
}
|
||||
|
||||
enum SenderType {
|
||||
USER
|
||||
CHANNEL
|
||||
BOT
|
||||
}
|
||||
|
||||
enum ChannelType {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
enum ConversationType {
|
||||
DIRECT
|
||||
GROUP
|
||||
CHANNEL
|
||||
}
|
||||
|
||||
enum ConversationRole {
|
||||
OWNER
|
||||
ADMIN
|
||||
MEMBER
|
||||
}
|
||||
|
||||
enum GroupType {
|
||||
PUBLIC
|
||||
PRIVATE
|
||||
}
|
||||
|
||||
model Sender {
|
||||
id BigInt @id
|
||||
type SenderType
|
||||
|
||||
userId BigInt? @unique
|
||||
channelId BigInt? @unique
|
||||
botId BigInt? @unique
|
||||
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
channel Channel? @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
|
||||
messages Message[]
|
||||
}
|
||||
|
||||
model User {
|
||||
id BigInt @id
|
||||
firstName String @default("")
|
||||
lastName String?
|
||||
bio String?
|
||||
username String? @unique
|
||||
login String @unique
|
||||
password String
|
||||
dateOfBirth BigInt?
|
||||
|
||||
sender Sender?
|
||||
|
||||
chats Chat[]
|
||||
sessions Session[]
|
||||
conversationMembers ConversationMember[]
|
||||
messageReads MessageRead[]
|
||||
|
||||
ownedGroups Group[]
|
||||
groupMemberships GroupMember[]
|
||||
groupBlackLists GroupBlackList[]
|
||||
ownedChannels Channel[]
|
||||
channelSubscriptions ChannelSubscriber[]
|
||||
|
||||
privacySettings PrivacySettings?
|
||||
channelBlackLists ChannelBlackList[]
|
||||
kanbanBoards KanbanBoard[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
id Int @id @default(autoincrement())
|
||||
userId BigInt
|
||||
token String @unique
|
||||
fcmToken String?
|
||||
deviceModel String
|
||||
osVersion String
|
||||
osName String
|
||||
createdAt BigInt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, token])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Message {
|
||||
id Int @id @default(autoincrement())
|
||||
sequenceId BigInt
|
||||
conversationId Int
|
||||
text String?
|
||||
sendTime BigInt
|
||||
editedAt BigInt?
|
||||
|
||||
isRead Boolean @default(false)
|
||||
deletedBySender Boolean @default(false)
|
||||
deletedByReceiver Boolean @default(false)
|
||||
|
||||
senderId BigInt
|
||||
sender Sender @relation(fields: [senderId], references: [id], onDelete: Cascade)
|
||||
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
|
||||
readReceipts MessageRead[]
|
||||
files File[]
|
||||
kanbanBoardId Int?
|
||||
kanbanBoard KanbanBoard? @relation(fields: [kanbanBoardId], references: [id], onDelete: SetNull)
|
||||
kanbanTaskId Int?
|
||||
kanbanTask KanbanTask? @relation(fields: [kanbanTaskId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@unique([conversationId, sequenceId])
|
||||
@@index([conversationId, sendTime])
|
||||
@@index([senderId])
|
||||
@@index([kanbanBoardId])
|
||||
@@index([kanbanTaskId])
|
||||
}
|
||||
|
||||
model KanbanBoard {
|
||||
id Int @id @default(autoincrement())
|
||||
ownerId BigInt
|
||||
title String
|
||||
createdAt BigInt
|
||||
updatedAt BigInt
|
||||
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
columns KanbanColumn[]
|
||||
messages Message[]
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
model KanbanColumn {
|
||||
id Int @id @default(autoincrement())
|
||||
boardId Int
|
||||
title String
|
||||
position Int
|
||||
|
||||
board KanbanBoard @relation(fields: [boardId], references: [id], onDelete: Cascade)
|
||||
tasks KanbanTask[]
|
||||
|
||||
@@index([boardId, position])
|
||||
}
|
||||
|
||||
model KanbanTask {
|
||||
id Int @id @default(autoincrement())
|
||||
columnId Int
|
||||
title String
|
||||
description String?
|
||||
position Int
|
||||
createdAt BigInt
|
||||
updatedAt BigInt
|
||||
|
||||
column KanbanColumn @relation(fields: [columnId], references: [id], onDelete: Cascade)
|
||||
messages Message[]
|
||||
|
||||
@@index([columnId, position])
|
||||
}
|
||||
|
||||
model File {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
size BigInt
|
||||
mimeType String
|
||||
path String
|
||||
status FileStatus @default(PENDING)
|
||||
createdAt BigInt
|
||||
updatedAt BigInt
|
||||
|
||||
messageId Int?
|
||||
message Message? @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([messageId])
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
enum FileStatus {
|
||||
PENDING
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model MessageRead {
|
||||
id Int @id @default(autoincrement())
|
||||
messageId Int
|
||||
userId BigInt
|
||||
readAt BigInt
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([messageId, userId])
|
||||
@@index([userId, readAt])
|
||||
}
|
||||
|
||||
model Conversation {
|
||||
id Int @id @default(autoincrement())
|
||||
type ConversationType
|
||||
createdAt BigInt
|
||||
|
||||
groupId BigInt? @unique
|
||||
channelId BigInt? @unique
|
||||
|
||||
group Group? @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
channel Channel? @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
|
||||
members ConversationMember[]
|
||||
messages Message[]
|
||||
chats Chat[]
|
||||
inviteLinks InviteLink[]
|
||||
}
|
||||
|
||||
model ConversationMember {
|
||||
id Int @id @default(autoincrement())
|
||||
conversationId Int
|
||||
userId BigInt
|
||||
role ConversationRole @default(MEMBER)
|
||||
joinedAt BigInt
|
||||
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([conversationId, userId])
|
||||
@@index([userId])
|
||||
@@index([conversationId])
|
||||
}
|
||||
|
||||
model Chat {
|
||||
id Int @id @default(autoincrement())
|
||||
userId BigInt
|
||||
conversationId Int
|
||||
|
||||
isPinned Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, conversationId])
|
||||
@@index([userId])
|
||||
@@index([conversationId])
|
||||
}
|
||||
|
||||
model Channel {
|
||||
id BigInt @id
|
||||
name String
|
||||
bio String?
|
||||
ownerId BigInt
|
||||
channelType ChannelType @default(PRIVATE)
|
||||
username String? @unique
|
||||
|
||||
sender Sender?
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
|
||||
subscribers ChannelSubscriber[]
|
||||
blockedUsers ChannelBlackList[]
|
||||
|
||||
conversations Conversation[]
|
||||
}
|
||||
|
||||
model ChannelSubscriber {
|
||||
id Int @id @default(autoincrement())
|
||||
userId BigInt
|
||||
channelId BigInt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, channelId])
|
||||
@@index([userId])
|
||||
@@index([channelId])
|
||||
}
|
||||
|
||||
model ChannelBlackList {
|
||||
id Int @id @default(autoincrement())
|
||||
userId BigInt
|
||||
channelId BigInt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
channel Channel @relation(fields: [channelId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, channelId])
|
||||
}
|
||||
|
||||
model Group {
|
||||
id BigInt @id
|
||||
ownerId BigInt
|
||||
name String
|
||||
username String? @unique
|
||||
bio String?
|
||||
groupType GroupType @default(PRIVATE)
|
||||
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
members GroupMember[]
|
||||
blocked GroupBlackList[]
|
||||
|
||||
conversations Conversation[]
|
||||
}
|
||||
|
||||
model GroupMember {
|
||||
id Int @id @default(autoincrement())
|
||||
groupId BigInt
|
||||
userId BigInt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([groupId, userId])
|
||||
@@index([userId])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
model GroupBlackList {
|
||||
id Int @id @default(autoincrement())
|
||||
userId BigInt
|
||||
groupId BigInt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, groupId])
|
||||
}
|
||||
|
||||
model InviteLink {
|
||||
id BigInt @id
|
||||
code String @unique
|
||||
conversationId Int
|
||||
creatorId BigInt
|
||||
maxUses Int?
|
||||
uses Int @default(0)
|
||||
expiresAt BigInt?
|
||||
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model PrivacySettings {
|
||||
id Int @id @default(autoincrement())
|
||||
userId BigInt @unique
|
||||
|
||||
lastSeen Int
|
||||
messages Int
|
||||
bio Int
|
||||
dateOfBirth Int
|
||||
invites Int
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
BIN
messenger-server/public/Messenger.apk
Normal file
BIN
messenger-server/public/Messenger.apk
Normal file
Binary file not shown.
226
messenger-server/public/android.html
Normal file
226
messenger-server/public/android.html
Normal file
@@ -0,0 +1,226 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Messenger for Android</title>
|
||||
<meta name="theme-color" content="#388bfd">
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
<link rel="icon" href="static/icon.webp">
|
||||
<style>
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
font-weight: 500;
|
||||
line-height: 56px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.intro-subtitle {
|
||||
font-size: 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.intro-download {
|
||||
display: inline-block;
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
color: white;
|
||||
padding: 16px 24px;
|
||||
border-radius: 20px;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.step-container {
|
||||
padding: 40px;
|
||||
margin-bottom: 40px;
|
||||
border-radius: 40px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgb(252 252 252);
|
||||
}
|
||||
|
||||
.step-container>h2 {
|
||||
font-size: 32px;
|
||||
margin-top: 24px;
|
||||
font-weight: 500;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: flex;
|
||||
width: 50px;
|
||||
background-color: var(--primary-color);
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 100px;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.first-step {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.first-step>a {
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
padding: 14px 24px;
|
||||
display: inline-block;
|
||||
border-radius: 20px;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 20px;
|
||||
margin-top: 16px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
@media screen and (width<=1024px) {
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
line-height: 38px;
|
||||
}
|
||||
|
||||
.intro-download {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
text-align: center;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.step-container {
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.first-step>a {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.intro-subtitle {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.step-container>h2 {
|
||||
font-size: 20px;
|
||||
line-height: 26px;
|
||||
}
|
||||
|
||||
.step-text {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (width<=768px) {
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
line-height: 34px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<nav class="container">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/">Главная</a>
|
||||
</li>
|
||||
<li class="active">
|
||||
<a href="/">Приложения</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section>
|
||||
<h1>Как скачать Messenger на Android</h1>
|
||||
<p class="intro-subtitle">Поддерживаются устройства от Android 9</p>
|
||||
<a href="https://www.rustore.ru/catalog/app/com.aiwazian.messenger" target="_blank" class="intro-download">
|
||||
Скачать из RuStore
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="step-container">
|
||||
<div class="step-number">1</div>
|
||||
<h2>Скачайте файл Messenger.apk на свой смартфон</h2>
|
||||
<div class="first-step">
|
||||
<a href="Messenger.apk">
|
||||
Скачать
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step-container">
|
||||
<div class="step-number">2</div>
|
||||
<h2>Разрешите загрузку файла</h2>
|
||||
<div class="step-text">
|
||||
Android покажет стандартное предупреждение для неизвестных файлов. Нажмите «Все равно скачать» — файл
|
||||
безопасен
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step-container">
|
||||
<div class="step-number">3</div>
|
||||
<h2>Подтвердите установку приложения</h2>
|
||||
<div class="step-text">Нажмите «Установить». Файл можно найти в браузере: Меню > Загрузки > Messenger.apk
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="step-container">
|
||||
<div class="step-number">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="14" viewBox="0 0 19 14" fill="none">
|
||||
<path
|
||||
d="M16.8637 0.363701C17.2152 0.0122295 17.785 0.0122298 18.1365 0.363702C18.488 0.715174 18.488 1.28502 18.1365 1.63649L6.63649 13.1365C6.28502 13.488 5.71517 13.488 5.3637 13.1365L0.363701 8.13649C0.0122295 7.78502 0.0122298 7.21517 0.363702 6.8637C0.715174 6.51223 1.28502 6.51223 1.63649 6.8637L6.00009 11.2273L16.8637 0.363701Z"
|
||||
fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2>Готово</h2>
|
||||
<div class="step-text">Общайтесь, не беспокоясь о безопасности</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<div class="desktop">
|
||||
<div class="col slang">
|
||||
<h3>Messenger</h3> - открытая и безопасная платформа для общения.
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="title">
|
||||
<h3>Приложения</h3>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/android">Android</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="title">
|
||||
<h3>Открытый код</h3>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/karenaiwazian/Messenger_Client" target="_blank">Клиент</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/karenaiwazian/Messenger_Server" target="_blank">Сервер</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile">
|
||||
<a href="/android">Приложения</a>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
555
messenger-server/public/app-ads.txt
Normal file
555
messenger-server/public/app-ads.txt
Normal file
@@ -0,0 +1,555 @@
|
||||
yandex.com, 105284619, DIRECT
|
||||
betweendigital.com, 43554, RESELLER
|
||||
openx.com, 541177349, RESELLER, 6a698e2ec38604c6
|
||||
pubmatic.com, 159668, RESELLER, 5d62403b186f2ace
|
||||
rubiconproject.com, 19724, RESELLER, 0bfd66d529a55807
|
||||
adcolony.com, 29b7f4a14dc689eb, RESELLER, 1ad675c9de6b5176
|
||||
emxdgt.com, 2047, RESELLER, 1e1d41537f7cad7f
|
||||
onetag.com, 5d1628750185ace, RESELLER
|
||||
loopme.com, 11278, RESELLER, 6c8d5f95897a5a3b
|
||||
appnexus.com, 13817, RESELLER, f5ab79cb980f11d1
|
||||
yahoo.com, 59966, RESELLER, e1a5b5b6e3255540
|
||||
silvermob.com, 449, RESELLER
|
||||
pubmatic.com, 160707, RESELLER, 5d62403b186f2ace
|
||||
video.unrulymedia.com, 3734122830, RESELLER
|
||||
rhythmone.com, 3734122830, RESELLER, a670c89d4a324e47
|
||||
engagebdr.com, 10308, RESELLER
|
||||
yeahmobi.com, 5135322, RESELLER
|
||||
apptv.com, 3992, RESELLER
|
||||
app.tv, 3992, RESELLER
|
||||
conso.la, 3992, RESELLER
|
||||
readserver.net, 3992, RESELLER
|
||||
adro.io, 3992, RESELLER
|
||||
adne.tv, 3992, RESELLER
|
||||
adtarget.me, 64, RESELLER
|
||||
uis.mobfox.com, 165, RESELLER
|
||||
video.unrulymedia.com, 456081263, RESELLER
|
||||
appnexus.com, 2637, RESELLER, f5ab79cb980f11d1
|
||||
openx.com, 539249210, RESELLER, 6a698e2ec38604c6
|
||||
pubmatic.com, 156451, RESELLER, 5d62403b186f2ace
|
||||
rubiconproject.com, 13132, RESELLER, 0bfd66d529a55807
|
||||
xad.com, 589, RESELLER, 81cbf0a75a5e0e9a
|
||||
inmobi.com, a5e661acdc384e91a79a58eb3418e99f, RESELLER, 83e75a7ae333ca9d
|
||||
vidcrunch.com, 5fb267bc6e2dd520fd551415, RESELLER
|
||||
freewheel.tv, 895601, RESELLER
|
||||
google.com, pub-9417114411593463, RESELLER, f08c47fec0942fa0
|
||||
spotxchange.com, 271911, RESELLER, 7842df1d2fe2db34
|
||||
spotx.tv, 271911, RESELLER, 7842df1d2fe2db34
|
||||
rhythmone.com, 456081263, RESELLER, a670c89d4a324e47
|
||||
indexexchange.com, 192806, RESELLER, 50b1c356f2c5c8fc
|
||||
rubiconproject.com, 17608,RESELLER, 0bfd66d529a55807
|
||||
triplelift.com, 10522, RESELLER, 6c33edb13117fd86
|
||||
smartadserver.com, 4140, RESELLER
|
||||
conversantmedia.com, 100269, RESELLER, 03113cd04947736d
|
||||
aol.com, 58935, RESELLER, e1a5b5b6e3255540
|
||||
yahoo.com, 58935, RESELLER, e1a5b5b6e3255540
|
||||
synacor.com, 82460, RESELLER, e108f11b2cdf7d5b
|
||||
video.unrulymedia.com, 123476257, RESELLER
|
||||
rhythmone.com, 123476257, RESELLER, a670c89d4a324e47
|
||||
openx.com, 544015448, RESELLER, 6a698e2ec38604c6
|
||||
mgid.com, 528163, RESELLER, d4c29acad76ce94f
|
||||
cgnl.io, k18s, RESELLER, d9819e7b540bd6e3
|
||||
bizzclick.com, 30, RESELLER, 7e936b1feafdaa61
|
||||
onetag.com, 66cff8e37d871be, RESELLER
|
||||
tpmn.io, 472, RESELLER
|
||||
mobilefuse.com, 3719, RESELLER, 71e88b065d69c021
|
||||
lumate.com, 966389a1, RESELLER
|
||||
bigo.sg, 136, RESELLER
|
||||
pubmatic.com, 161151, RESELLER, 5d62403b186f2ace
|
||||
pubnative.net, 1007334, RESELLER, d641df8625486a7b
|
||||
ignitemediatech.com, pub_61128, RESELLER
|
||||
admixer.net, 2f833c20-7378-4b86-9b73-a2b56263d4d4, RESELLER
|
||||
inmobi.com, 062029933580429f9920bad476d8d70a, RESELLER, 83e75a7ae333ca9d
|
||||
loopme.com, 11295, RESELLER, 6c8d5f95897a5a3b
|
||||
openx.com, 542281387, RESELLER, 6a698e2ec38604c6
|
||||
pubnative.net, 1007615, RESELLER, d641df8625486a7b
|
||||
silvermob.com, 464, RESELLER
|
||||
smaato.com, 1100044156, RESELLER, 07bcf65f187117b4
|
||||
webeyemob.com, 70100, RESELLER
|
||||
openx.com, 540679900, RESELLER, 6a698e2ec38604c6
|
||||
adcolony.com, 801e49d1be83b5f9, RESELLER, 1ad675c9de6b5176
|
||||
meitu.com, 663, RESELLER
|
||||
ignitemediatech.com, 61119, RESELLER
|
||||
video.unrulymedia.com, 564934834, RESELLER
|
||||
conversantmedia.com, 100342, RESELLER, 03113cd04947736d
|
||||
mobimight.com, 30271, RESELLER
|
||||
appnexus.com, 11450, RESELLER, f5ab79cb980f11d1
|
||||
elixirvideo.co, 30271, RESELLER
|
||||
video.unrulymedia.com, 144481089, RESELLER
|
||||
pubmatic.com, 161373, RESELLER, 5d62403b186f2ace
|
||||
ubrikvideo.com, 30271, RESELLER
|
||||
rhythmone.com, 564934834, RESELLER, a670c89d4a324e47
|
||||
algorix.co, 60444, RESELLER, 5b394c12fea27a1d
|
||||
ucfunnel.com, par-D2346AAB7ABD36B4CDD7BBD264BA92E2, RESELLER
|
||||
aralego.com, par-D2346AAB7ABD36B4CDD7BBD264BA92E2, RESELLER
|
||||
themediagrid.com, fh3tkj, RESELLER, 35d5010d7789b49d
|
||||
appnexus.com, 13297, RESELLER, f5ab79cb980f11d1
|
||||
xandr.com, 13297, RESELLER, f5ab79cb980f11d1
|
||||
pubmatic.com, 160974, RESELLER, 5d62403b186f2ace
|
||||
rubiconproject.com, 20050, RESELLER, 0bfd66d529a55807
|
||||
openx.com, 540838151, RESELLER, 6a698e2ec38604c6
|
||||
pubnative.net, 1007262, RESELLER, d641df8625486a7b
|
||||
loopme.com, 11424, RESELLER, 6c8d5f95897a5a3b
|
||||
olaex.biz, 100039, RESELLER
|
||||
peak226.com, 12900, RESELLER
|
||||
engagebdr.com, 10423, RESELLER
|
||||
smartadserver.com, 3817, RESELLER
|
||||
rhythmone.com, 1295892552, RESELLER, a670c89d4a324e47
|
||||
e-planning.net, 53f866af404c4b62, RESELLER, c1ba615865ed87b2
|
||||
brightcom.com, 15800, RESELLER
|
||||
onetag.com, 5a02ff98ba6be67, RESELLER
|
||||
advertising.com, 28246, RESELLER
|
||||
inmobi.com, 22e5354e453f49348325184e25464adb, RESELLER, 83e75a7ae333ca9d
|
||||
tpmn.io, 415, RESELLER
|
||||
bold-win.com, 45325, RESELLER
|
||||
gamoshi.io, 267-b4627, RESELLER
|
||||
meitu.com, 581, RESELLER
|
||||
admixer.co.kr, 1629, RESELLER
|
||||
mintegral.com, 10046, RESELLER, 0aeed750c80d6423
|
||||
criteo.com, B-057955, RESELLER, 9fac4a4a87c2a44f
|
||||
themediagrid.com, NG9STC, RESELLER, 35d5010d7789b49d
|
||||
loopme.com, 11414, RESELLER, 6c8d5f95897a5a3b
|
||||
admanmedia.com, 894, RESELLER
|
||||
pubmatic.com, 160113, RESELLER, 5d62403b186f2ace
|
||||
acd.op.hicloud.com, PUB_HW_1003, RESELLER
|
||||
adx-dre.op.hicloud.com, PUB_HW_1003, RESELLER
|
||||
webeyemob.com, 70098, RESELLER
|
||||
indexexchange.com, 198417, RESELLER, 50b1c356f2c5c8fc
|
||||
video.unrulymedia.com, 3383599585, RESELLER
|
||||
rhythmone.com, 3383599585, RESELLER, a670c89d4a324e47
|
||||
rubiconproject.com, 24526, RESELLER, 0bfd66d529a55807
|
||||
ignitemediatech.com, pub_61170, RESELLER
|
||||
inmobi.com, 6cc71dd159864641a03ce0c8792d801f, RESELLER, 83e75a7ae333ca9d
|
||||
152media.info, 152M312, RESELLER
|
||||
appnexus.com, 3153, RESELLER, f5ab79cb980f11d1
|
||||
adtelligent.com, 640813, RESELLER
|
||||
pubmatic.com, 157113, RESELLER, 5d62403b186f2ace
|
||||
motionspots.com, 166484, RESELLER
|
||||
opera.com, pub7319665936192, RESELLER, 55a0c5fd61378de3
|
||||
triplelift.com, 11656, RESELLER, 6c33edb13117fd86
|
||||
pubmatic.com, 158565, RESELLER, 5d62403b186f2ace
|
||||
appnexus.com, 13227, RESELLER, f5ab79cb980f11d1
|
||||
outbrain.com, 002d7f7ba0bd74452f2b155d0dfb5cd6c8, RESELLER
|
||||
acexchange.co.kr, 1416775282, RESELLER
|
||||
admixer.co.kr, 1610, RESELLER
|
||||
adyoulike.com, 22389b7165228ff4ecbe2b72818ae524, RESELLER, 4ad745ead2958bf7
|
||||
pubmatic.com, 157704, RESELLER, 5d62403b186f2ace
|
||||
contextweb.com, 562842, RESELLER, 89ff185a4c4e857c
|
||||
google.com, pub-2843405949989126, RESELLER, f08c47fec0942fa0
|
||||
rtbsape.com, 1622050, RESELLER
|
||||
smartyads.com, 100135, RESELLER, fd2bde0ff2e62c5d
|
||||
adriver.ru, 187150, RESELLER
|
||||
appnexus.com, 12447, RESELLER, f5ab79cb980f11d1
|
||||
rubiconproject.com, 23946, RESELLER, 0bfd66d529a55807
|
||||
advertising.com, 28764, RESELLER
|
||||
google.com, pub-5289985627731322, RESELLER, f08c47fec0942fa0
|
||||
sovrn.com, 273644, RESELLER, fafdf38b16bf6b2b
|
||||
ssp.e-volution.ai, AJxF6R111a9M6CaTvK, RESELLER
|
||||
smartadserver.com, 1247, RESELLER
|
||||
adform.com, 2664, RESELLER
|
||||
loopme.com, 11342, RESELLER, 6c8d5f95897a5a3b
|
||||
conversantmedia.com, 100264, RESELLER, 03113cd04947736d
|
||||
ampliffy.com, amp00293, RESELLER
|
||||
publiffy.com, pub00293, RESELLER
|
||||
indexexchange.com, 186318, RESELLER, 50b1c356f2c5c8fc
|
||||
rhythmone.com, 1575167821, RESELLER, a670c89d4a324e47
|
||||
appnexus.com, 4052, RESELLER, f5ab79cb980f11d1
|
||||
openx.com, 540031703, RESELLER, 6a698e2ec38604c6
|
||||
contextweb.com, 562669, RESELLER, 89ff185a4c4e857c
|
||||
yahoo.com, 59338, RESELLER
|
||||
buzzoola.com, 579132, RESELLER
|
||||
marversal.com, 116, RESELLER
|
||||
e-planning.net, b9165ad221c51225, RESELLER, c1ba615865ed87b2
|
||||
triplelift.com, 8446, RESELLER, 6c33edb13117fd86
|
||||
sharethrough.com, 23830661, RESELLER, d53b998a7bd4ecd2
|
||||
axonix.com, 56222, RESELLER
|
||||
uis.mobfox.com, 1916, RESELLER, 5529a3d1f59865be
|
||||
vidoomy.com, 6858194, RESELLER
|
||||
adform.com, 2742, RESELLER
|
||||
instal.com, 5a59277b-91e8-4b5c-a4b5-ee9a7a6c0644, RESELLER
|
||||
openx.com, 540773939, RESELLER, 6a698e2ec38604c6
|
||||
appnexus.com, 11236, RESELLER, f5ab79cb980f11d1
|
||||
silvermob.com, 357, RESELLER
|
||||
aceex.io, 6, RESELLER
|
||||
spinx.biz, spnx-1000021, RESELLER
|
||||
pubmatic.com, 161853, RESELLER, 5d62403b186f2ace
|
||||
contextweb.com, 562899, RESELLER, 89ff185a4c4e857c
|
||||
adsyield.com, 1069, RESELLER
|
||||
e-planning.net, 45845eaf076148f9, RESELLER, c1ba615865ed87b2
|
||||
loopme.com, 11569, RESELLER, 6c8d5f95897a5a3b
|
||||
xandr.com, 13799, RESELLER
|
||||
triplelift.com, 12158, RESELLER, 6c33edb13117fd86
|
||||
152media.info, 152M499, RESELLER
|
||||
appnexus.com, 11924, RESELLER, f5ab79cb980f11d1
|
||||
gamoshi.io, 267-b5428, RESELLER, 20e30b2ae1f670f2
|
||||
brightcom.com, 20498, RESELLER
|
||||
sharethrough.com, r4ScMSsf, RESELLER, d53b998a7bd4ecd2
|
||||
video.unrulymedia.com, 2464975885, RESELLER
|
||||
rhythmone.com, 2464975885, RESELLER, a670c89d4a324e47
|
||||
pubmatic.com, 162223, RESELLER, 5d62403b186f2ace
|
||||
pubmatic.com, 161162, RESELLER, 5d62403b186f2ace
|
||||
pubnative.net, 1007349, RESELLER, d641df8625486a7b
|
||||
bematterfull.com, 22289765, RESELLER
|
||||
inmobi.com, 30f3830cfef249a3ad46ee1a0bba7af3, RESELLER, 83e75a7ae333ca9d
|
||||
lunamedia.io, fa0c2eaa5fae45b888d23460e1cac6e7, RESELLER, 524ecb396915caaf
|
||||
themediagrid.com, R28I9J, RESELLER, 35d5010d7789b49d
|
||||
pubmatic.com, 160492, RESELLER, 5d62403b186f2ace
|
||||
pubmatic.com, 160493, RESELLER, 5d62403b186f2ace
|
||||
loopme.com, 11367, RESELLER, 6c8d5f95897a5a3b
|
||||
rubiconproject.com, 24170, RESELLER, 0bfd66d529a55807
|
||||
inmobi.com, ddb41d8a9f434a918d05a0fc9999d9f9, RESELLER, 83e75a7ae333ca9d
|
||||
yahoo.com, 59627, RESELLER
|
||||
video.unrulymedia.com, 4631344382657206988, RESELLER
|
||||
contextweb.com, 562329, RESELLER, 89ff185a4c4e857c
|
||||
smartadserver.com, 4539, RESELLER, 060d053dcf45cbf3
|
||||
consumable.com, 2001470, RESELLER, aefcd3d2f45b5070
|
||||
conversantmedia.com, 100322, RESELLER, 03113cd04947736d
|
||||
flatads.com, 276, RESELLER
|
||||
adview.com, 77646260, RESELLER, 1b2cc038a11ea319
|
||||
openx.com, 540326226, RESELLER, 6a698e2ec38604c6
|
||||
conversantmedia.com, 100081, RESELLER, 03113cd04947736d
|
||||
adcolony.com, 382d79cd1387e603, RESELLER, 1ad675c9de6b5176
|
||||
contextweb.com, 562122, RESELLER, 89ff185a4c4e857c
|
||||
inmobi.com, 867c89bb53994aaeb9dae3ce75b03e78, RESELLER, 83e75a7ae333ca9d
|
||||
bidence.com, 3de04db04d6eb28b13281a39b1c16d67, RESELLER
|
||||
smartyads.com, 368, RESELLER, fd2bde0ff2e62c5d
|
||||
loopme.com, 10178, RESELLER, 6c8d5f95897a5a3b
|
||||
pubmatic.com, 156835, RESELLER, 5d62403b186f2ace
|
||||
pubnative.net, 1006936, RESELLER, d641df8625486a7b
|
||||
rixengine.com, 604513, RESELLER
|
||||
pubmatic.com, 163075, RESELLER, 5d62403b186f2ace
|
||||
mangomob.net, ozgarer34a, RESELLER
|
||||
start.io, mgr, RESELLER
|
||||
themediagrid.com, 6XGFYQ, RESELLER, 35d5010d7789b49d
|
||||
smartadserver.com, 4655, RESELLER, 060d053dcf45cbf3
|
||||
inmobi.com, 12a9a79d60214a40a444a6103b81747c, RESELLER, 83e75a7ae333ca9d
|
||||
conversantmedia.com, 100339, RESELLER, 03113cd04947736d
|
||||
loopme.com, 11318, RESELLER, 6c8d5f95897a5a3b
|
||||
pubmatic.com, 157559, RESELLER, 5d62403b186f2ace
|
||||
rubiconproject.com, 24400, RESELLER, 0bfd66d529a55807
|
||||
outbrain.com, 0023749a2264ea0429a71b54ac9ca0de9a, RESELLER
|
||||
opera.com, pub5925993551616, RESELLER, 55a0c5fd61378de3
|
||||
xapads.com, 155075, RESELLER
|
||||
pubmatic.com, 162882, RESELLER, 5d62403b186f2ace
|
||||
pubmatic.com, 163319, RESELLER, 5d62403b186f2ace
|
||||
video.unrulymedia.com, 524101463, RESELLER, 29bc7d05d309e1bc
|
||||
google.com, pub-3990748024667386, RESELLER, f08c47fec0942fa0
|
||||
lijit.com, 224984, RESELLER, fafdf38b16bf6b2b
|
||||
criteo.com, B-072395, RESELLER, 9fac4a4a87c2a44f
|
||||
display.io, 156748, RESELLER
|
||||
rubiconproject.com, 19938, RESELLER, 0bfd66d529a55807
|
||||
pubmatic.com, 158101, RESELLER, 5d62403b186f2ace
|
||||
loopme.com, 11229, RESELLER, 6c8d5f95897a5a3b
|
||||
inmobi.com, b164fef73b06493b92a9fae09941eef4, RESELLER, 83e75a7ae333ca9d
|
||||
sonobi.com, 4980f2fde3, RESELLER, d1a215d9eb5aee9e
|
||||
admanmedia.com, 34, RESELLER
|
||||
visiblemeasures.com, 1009, RESELLER
|
||||
conversantmedia.com, 100260, RESELLER, 03113cd04947736d
|
||||
gothamads.com, 1950, RESELLER, d9c86e5dec870222
|
||||
ushareit.com, LCfafa51927e71233e, RESELLER
|
||||
pubmatic.com, 163307, RESELLER, 5d62403b186f2ace
|
||||
loopme.com, 11633, RESELLER, 6c8d5f95897a5a3b
|
||||
opera.com, pub8049291171776, RESELLER, 55a0c5fd61378de3
|
||||
webeyemob.com, 70121, RESELLER
|
||||
lenovoads.com, 4000, RESELLER
|
||||
contextweb.com, 563047, RESELLER, 89ff185a4c4e857c
|
||||
sharethrough.com, 6qlnf8SY, RESELLER, d53b998a7bd4ecd2
|
||||
pubmatic.com, 158154, RESELLER, 5d62403b186f2ace
|
||||
contextweb.com, 562499, RESELLER, 89ff185a4c4e857c
|
||||
conversantmedia.com, 100246, RESELLER, 03113cd04947736d
|
||||
openx.com, 540298543, RESELLER, 6a698e2ec38604c6
|
||||
engagemedia.tv, 1063, RESELLER, cb58d2185b16309a
|
||||
loopme.com, 11591, RESELLER, 6c8d5f95897a5a3b
|
||||
freewheel.tv, 1137745, RESELLER
|
||||
smartadserver.com, 4564, RESELLER, 060d053dcf45cbf3
|
||||
video.unrulymedia.com, 8373427519576873959, RESELLER
|
||||
opera.com, pub8625217480640, RESELLER, 55a0c5fd61378de3
|
||||
improvedigital.com, 2273, RESELLER
|
||||
onetag.com, 7d9af0b85b5070e, RESELLER
|
||||
pubmatic.com, 162239, RESELLER, 5d62403b186f2ace
|
||||
empower.net, 63cb0332e4b01718f069d4cb, RESELLER
|
||||
google.com, pub-7983651257838282, RESELLER, f08c47fec0942fa0
|
||||
opera.com, pub9443931419968, RESELLER, 55a0c5fd61378de3
|
||||
improvedigital.com, 2320, RESELLER
|
||||
rubiconproject.com, 26442, DIRECT, 0bfd66d529a55807
|
||||
rubiconproject.com, 26440, DIRECT, 0bfd66d529a55807
|
||||
smartadserver.com, 3172, RESELLER, 060d053dcf45cbf3
|
||||
rubiconproject.com, 20086, RESELLER, 0bfd66d529a5
|
||||
adform.com, 2904, RESELLER
|
||||
appnexus.com, 14416, RESELLER
|
||||
orangeclickmedia.com, C-1054, RESELLER
|
||||
spinx.biz, 1373698864, RESELLER
|
||||
display.io, 201090, RESELLER
|
||||
dauup.com, 34109, RESELLER
|
||||
rhythmone.com, 3948367200, RESELLER, a670c89d4a324e47
|
||||
rubiconproject.com, 14558, RESELLER, 0bfd66d529a55807
|
||||
rubiconproject.com, 25386, RESELLER, 0bfd66d529a55807
|
||||
instreamatic.com, 78, RESELLER
|
||||
vidoomy.com, 2704434, RESELLER
|
||||
adswizz.com, entravision, RESELLER
|
||||
video.unrulymedia.com, 2979066401945419350, RESELLER
|
||||
blueseasx.com, 203620, RESELLER, 7998eac5087f6110
|
||||
rubiconproject.com, 26132, RESELLER, 0bfd66d529a55807
|
||||
outbrain.com, 00ab8f68679cc060c8bbc1035e70030614, RESELLER
|
||||
pangleglobal.com, 82832, RESELLER
|
||||
bigo.sg, 196, RESELLER
|
||||
openx.com, 537045659, RESELLER, 6a698e2ec38604c6
|
||||
appnexus.com, 7597, RESELLER, f5ab79cb980f11d1
|
||||
netsvision.com, 9992301, RESELLER
|
||||
netsvision.com, 9992310, RESELLER
|
||||
netsvision.com, 9992311, RESELLER
|
||||
netsvision.com, 9992315, RESELLER
|
||||
netsvision.com, 9992397, RESELLER
|
||||
rtbsape.com, 1728038, RESELLER
|
||||
yeahmobi.com, 115446, RESELLER
|
||||
zmaticoo.com, 115446, RESELLER
|
||||
rubiconproject.com, 24362, RESELLER, 0bfd66d529a55807
|
||||
freewheel.tv, 1599106, RESELLER
|
||||
freewheel.tv, 1599109, RESELLER
|
||||
admanmedia.com, 990, RESELLER
|
||||
visiblemeasures.com, 1020, RESELLER
|
||||
pubmatic.com, 161136, RESELLER, 5d62403b186f2ace
|
||||
smaato.com, 1100051149, RESELLER, 07bcf65f187117b4
|
||||
smaato.com, 1100004890, RESELLER, 07bcf65f187117b4
|
||||
conversantmedia.com, 100455, RESELLER, 03113cd04947736d
|
||||
smartadserver.com, 4457, RESELLER, 060d053dcf45cbf3
|
||||
sharethrough.com, DQQogebZ, RESELLER, d53b998a7bd4ecd2
|
||||
pubnative.net, 1007475, RESELLER, d641df8625486a7b
|
||||
pubnative.net, 1007303, RESELLER, d641df8625486a7b
|
||||
pubnative.net, 1007311, RESELLER, d641df8625486a7b
|
||||
Contextweb.com, 562762, RESELLER, 89ff185a4c4e857c
|
||||
rubiconproject.com, 25482, RESELLER, 0bfd66d529a55807
|
||||
admixer.co.kr, 1654, RESELLER
|
||||
tpmn.io, 663, RESELLER
|
||||
tpmn.io, 664, RESELLER
|
||||
loopme.com, 11605, RESELLER, 6c8d5f95897a5a3b
|
||||
rubiconproject.com, 20744, RESELLER, 0bfd66d529a55807
|
||||
freewheel.tv, 1138513, RESELLER
|
||||
media.net, 8CUN37DCC, RESELLER
|
||||
improvedigital.com, 2110, RESELLER
|
||||
improvedigital.com, 1532, RESELLER
|
||||
onairglobal.com, 4357627, RESELLER
|
||||
thebrave.io, 1234585, RESELLER, c25b2154543746ac
|
||||
videoheroes.tv, 212503, RESELLER, 064bc410192443d8
|
||||
Se7en.es, 212503, RESELLER, 064bc410192443d8
|
||||
opera.com, pub7275292332480, RESELLER, 55a0c5fd61378de3
|
||||
pokkt.com, 7606, RESELLER, c45702d9311e25fd
|
||||
appads.in, 107606, RESELLER
|
||||
rubiconproject.com, 23644, RESELLER, 0bfd66d529a55807
|
||||
lijit.com, 411121, RESELLER, fafdf38b16bf6b2b #SOVRN
|
||||
admanmedia.com, 2050, RESELLER
|
||||
xandr.com, 13238, RESELLER, f5ab79cb980f11d1
|
||||
opera.com, pub5865193350528, RESELLER, 55a0c5fd61378de3
|
||||
sonobi.com, a85c5f6129, RESELLER, d1a215d9eb5aee9e
|
||||
improvedigital.com, 2276, RESELLER
|
||||
inmobi.com, 7847fe1f9ac54b4abe609cde4011243b, RESELLER, 83e75a7ae333ca9d
|
||||
rubiconproject.com, 17608, RESELLER, 0bfd66d529a55807
|
||||
axonix.com, 59089, RESELLER, bc385f2b4a87b721
|
||||
display.io, 173162, RESELLER
|
||||
mman.kr, 33000, RESELLER
|
||||
betweendigital.com, 44659, RESELLER
|
||||
hyperad.tech, 172, RESELLER
|
||||
hyperad.tech, 182, RESELLER
|
||||
inmobi.com, 38e36193f3c944d0b6254c71e511041b, RESELLER, 83e75a7ae333ca9d
|
||||
webeyemob.com, 70104, RESELLER
|
||||
meitu.com, 699, RESELLER
|
||||
eskimi.com, 2020000011, RESELLER
|
||||
video.unrulymedia.com, 498216989, RESELLER
|
||||
admatic.de, ade-pub-5648520832, RESELLER, uufps1dh5stc6euk
|
||||
luponmedia.com, 19956462, RESELLER
|
||||
indexexchange.com, 188165, RESELLER, 50b1c356f2c5c8fc
|
||||
indexexchange.com, 196757, RESELLER, 50b1c356f2c5c8fc
|
||||
adform.com, 1985, RESELLER, 9f5210a2f0999e32
|
||||
rubiconproject.com, 12398, RESELLER, 0bfd66d529a55807
|
||||
pubmatic.com, 158697, RESELLER, 5d62403b186f2ace
|
||||
pubmatic.com, 159760, RESELLER, 5d62403b186f2ace
|
||||
adipolo.com, 23092378058, RESELLER
|
||||
adipolosolutions.com, 23092378058, RESELLER
|
||||
pmbmonetize.com, 23092378058, RESELLER
|
||||
opamarketplace.com, 23092378058, RESELLER
|
||||
google.com, pub-2930805104418204, RESELLER, f08c47fec0942fa0 #GreeterAPL
|
||||
google.com, pub-4903453974745530, RESELLER, f08c47fec0942fa0 #APL
|
||||
google.com, pub-4836542095728076, RESELLER, f08c47fec0942fa0 #Positive
|
||||
google.com, pub-9135355251665930, RESELLER, f08c47fec0942fa0 #opa
|
||||
freewheel.tv, 1605950, RESELLER
|
||||
freewheel.tv, 1605951, RESELLER
|
||||
themediagrid.com, DJQVCM, RESELLER, 35d5010d7789b49d
|
||||
video.unrulymedia.com, 270404831, RESELLER
|
||||
adform.com, 3035, RESELLER, 9f5210a2f0999e32
|
||||
onetag.com, 61d88450bdb25bc, RESELLER
|
||||
onetag.com, 61d88450bdb25bc-OB, RESELLER
|
||||
loopme.com, 11647, RESELLER, 6c8d5f95897a5a3b
|
||||
netaddiction.it, 1064, RESELLER
|
||||
adform.com, 2668, RESELLER
|
||||
appnexus.com, 11673, RESELLER, f5ab79cb980f11d1
|
||||
pubmatic.com, 155968, RESELLER, 5d62403b186f2ace
|
||||
rubiconproject.com, 11398, RESELLER, 0bfd66d529a55807
|
||||
smartadserver.com, 989, RESELLER, 060d053dcf45cbf3
|
||||
gumgum.com, 15747, RESELLER, ffdef49475d318a9
|
||||
openx.com, 541163168, RESELLER, 6a698e2ec38604c6
|
||||
improvedigital.com, 1616, RESELLER
|
||||
rtbhouse.com, e3qznauVqenvza0c5wWJ, RESELLER
|
||||
criteo.com, B-064389, RESELLER, 9fac4a4a87c2a44f
|
||||
themediagrid.com, BIH5U6, RESELLER, 35d5010d7789b49d
|
||||
appnexus.com, 12290, RESELLER, f5ab79cb980f11d1
|
||||
rubiconproject.com, 17960, RESELLER, 0bfd66d529a55807
|
||||
adform.com, 2865, RESELLER
|
||||
appnexus.com, 9393, RESELLER, f5ab79cb980f11d1
|
||||
indexexchange.com, 191503, RESELLER, 50b1c356f2c5c8fc
|
||||
openx.com, 559680764, RESELLER, 6a698e2ec38604c6
|
||||
rubiconproject.com, 23844, RESELLER, 0bfd66d529a55807
|
||||
smartadserver.com, 3056, RESELLER, 060d053dcf45cbf3
|
||||
yahoo.com, 49648, RESELLER
|
||||
pubmatic.com, 161527, RESELLER, 5d62403b186f2ace
|
||||
pubmatic.com, 158355, RESELLER, 5d62403b186f2ace
|
||||
lijit.com, 260380, RESELLER, fafdf38b16bf6b2b
|
||||
amxrtb.com, 105199787, RESELLER
|
||||
appnexus.com, 11786, RESELLER
|
||||
eskimi.com, 2020000676, RESELLER
|
||||
rubiconproject.com, 26250, RESELLER, 0bfd66d529a55807
|
||||
lijit.com, 502284, RESELLER, fafdf38b16bf6b2b
|
||||
pubmatic.com, 162270, RESELLER, 5d62403b186f2ace
|
||||
appnexus.com, 15670, RESELLER
|
||||
contextweb.com, 562818, RESELLER, 89ff185a4c4e857c
|
||||
vidoomy.com, 3655923, RESELLER
|
||||
smartadserver.com, 4998, RESELLER, 060d053dcf45cbf3
|
||||
onetag.com, 8c90176af2e65c8, RESELLER
|
||||
adtarget.com.tr, 751601, RESELLER
|
||||
admatic.com.tr, adm-pub-2977111241, RESELLER, uufps1dh5stc6euk
|
||||
pixad.com.tr, px-pub-6514176248, RESELLER, uufps1dh5stc6euk
|
||||
google.com, pub-8929667634210480, RESELLER, f08c47fec0942fa0
|
||||
rubiconproject.com, 24266, RESELLER, 0bfd66d529a55807
|
||||
pubmatic.com, 158849, RESELLER, 5d62403b186f2ace
|
||||
adform.com, 2083, RESELLER
|
||||
adform.com, 2968, RESELLER
|
||||
rtbhouse.com, eqt3MD0DmNFukxfZqFm0, RESELLER
|
||||
rtbhouse.com, 36401e736811e8034581, RESELLER
|
||||
rtbhouse.com, Bl90aHDHpnUdxORfqhhI, RESELLER
|
||||
rtbhouse.com, KicWDRpi0GCWX2lQJcYn, RESELLER
|
||||
rubiconproject.com, 25100, RESELLER, 0bfd66d529a55807
|
||||
rubiconproject.com, 25102, RESELLER, 0bfd66d529a55807
|
||||
pubmatic.com, 157800, RESELLER, 5d62403b186f2ace
|
||||
rubiconproject.com, 18364, RESELLER, 0bfd66d529a55807
|
||||
smartadserver.com, 4456, RESELLER, 060d053dcf45cbf3
|
||||
themediagrid.com, SWH94X, RESELLER, 35d5010d7789b49d
|
||||
xandr.com, 13293, RESELLER, f5ab79cb980f11d1
|
||||
bidmachine.io, 200, DIRECT
|
||||
betweendigital.com, 44727, RESELLER
|
||||
pubmatic.com, 163420, RESELLER, 5d62403b186f2ace
|
||||
pubmatic.com, 165340, RESELLER, 5d62403b186f2ace
|
||||
pubnative.net, 1009485, RESELLER, d641df8625486a7b
|
||||
openx.com, 540543195, RESELLER, 6a698e2ec38604c6
|
||||
showheroes.com, 6036, RESELLER
|
||||
rubiconproject.com, 26476, RESELLER, 0bfd66d529a55807
|
||||
openx.com, 540022851, RESELLER, 6a698e2ec38604c6
|
||||
adform.com, 2845, RESELLER, 9f5210a2f0999e32
|
||||
appnexus.com, 11487, RESELLER, f5ab79cb980f11d1
|
||||
pubmatic.com, 120391, RESELLER, 5d62403b186f2ace
|
||||
adform.com, 2688, RESELLER, 9f5210a2f0999e32
|
||||
appnexus.com, 13774, RESELLER, f5ab79cb980f11d1
|
||||
rubiconproject.com, 11762, RESELLER, 0bfd66d529a55807
|
||||
adform.com, 3116, RESELLER, 9f5210a2f0999e32
|
||||
opera.com, pub9166643429696, RESELLER, 55a0c5fd61378de3
|
||||
contextweb.com, 562791, RESELLER, 89ff185a4c4e857c
|
||||
pubnative.net, 1009046, RESELLER, d641df8625486a7b
|
||||
lijit.com, 465542, RESELLER, fafdf38b16bf6b2b
|
||||
onetag.com, 82e44d118b79600, RESELLER
|
||||
startapp.com, ope, RESELLER
|
||||
start.io, ope, RESELLER
|
||||
start.io, 116712987, RESELLER
|
||||
freewheel.tv, 1600180, RESELLER
|
||||
freewheel.tv, 1600214, RESELLER
|
||||
lacunads.com, LCfafa51927e71233e, RESELLER
|
||||
rubiconproject.com, 26846, RESELLER, 0bfd66d529a55807
|
||||
Vidoomy.com, 1320781, RESELLER
|
||||
freewheel.tv, 1607802, RESELLER
|
||||
freewheel.tv, 1607807, RESELLER
|
||||
smaato.com, 1100058043, RESELLER, 07bcf65f187117b4
|
||||
smartyads.com, 100016, RESELLER, fd2bde0ff2e62c5d
|
||||
inmobi.com, 791b84bdd791470faa8dca5f04e6a83b, RESELLER, 83e75a7ae333ca9d
|
||||
smartadserver.com, 4467, RESELLER
|
||||
contextweb.com, 562827, RESELLER, 89ff185a4c4e857c
|
||||
lijit.com, 273644, RESELLER, fafdf38b16bf6b2b
|
||||
pubmatic.com, 166180, RESELLER, 5d62403b186f2ace
|
||||
criteo.com, B-057601, RESELLER, 9fac4a4a87c2a44f
|
||||
loopme.com, 11347, RESELLER, 6c8d5f95897a5a3b
|
||||
inmobi.com, 33f042dcf10549ae9fb1b9e2ee0ecfc3, RESELLER, 83e75a7ae333ca9d
|
||||
onairglobal.com, 4357629, RESELLER
|
||||
openx.com, 540396775, RESELLER, 6a698e2ec38604c6
|
||||
thebrave.io, 9840732, RESELLER, c25b2154543746ac
|
||||
start.io, 146282053, RESELLER
|
||||
themediagrid.com, FWN84J, RESELLER, 35d5010d7789b49d
|
||||
triplelift.com, 14127, RESELLER, 6c33edb13117fd86
|
||||
openx.com, 559912325, RESELLER, 6a698e2ec38604c6
|
||||
pubmatic.com, 163476, RESELLER, 5d62403b186f2ace
|
||||
markappmedia.site, C-1054, RESELLER
|
||||
rubiconproject.com, 20086, RESELLER
|
||||
appnexus.com, 14808, RESELLER
|
||||
bidease.com, bidease_seller_19, RESELLER
|
||||
pubnative.net, 1009966, RESELLER, d641df8625486a7b
|
||||
pubmatic.com, 166078, RESELLER, 5d62403b186f2ace
|
||||
adagio.io, 1514, RESELLER
|
||||
inmobi.com, 6dc038804add44ffa2d5f61854f6ab33, RESELLER, 83e75a7ae333ca9d
|
||||
adbro.me, eb76b782-9af7-4536-aac7-3d280f1f6652, RESELLER
|
||||
opera.com, pub6148735850944, RESELLER, 55a0c5fd61378de3
|
||||
sabio.us, 100092, RESELLER, 96ed93aaa9795702
|
||||
freewheel.tv, 1606620, RESELLER
|
||||
freewheel.tv, 1606633, RESELLER
|
||||
themediagrid.com, A6CWLO, RESELLER, 35d5010d7789b49d
|
||||
onetag.com, 925c32ef718e9fe, RESELLER
|
||||
smaato.com, 1100057547, RESELLER, 07bcf65f187117b4
|
||||
zmaticoo.com, 5135063, RESELLER
|
||||
admanmedia.com, 613, RESELLER
|
||||
smartadserver.com, 3713, RESELLER, 060d053dcf45cbf3
|
||||
pubmatic.com, 165117, RESELLER, 5d62403b186f2ace
|
||||
sharethrough.com, XeKuhSkz, RESELLER, d53b998a7bd4ecd2
|
||||
appnexus.com, 15349, RESELLER, f5ab79cb980f11d1
|
||||
video.unrulymedia.com, 3948367200, RESELLER
|
||||
triplelift.com, 12456, RESELLER, 6c33edb13117fd86
|
||||
themediagrid.com, A8X5S7, RESELLER, 35d5010d7789b49d
|
||||
conversantmedia.com, 100308, RESELLER, 03113cd04947736d
|
||||
adyoulike.com, a2226c27fc2a6773f6a2b365e013513a, RESELLER, 4ad745ead2958bf7
|
||||
pubmatic.com, 158481, RESELLER, 5d62403b186f2ace
|
||||
media.net, 8CUSC3UJ7, RESELLER
|
||||
lijit.com, 417620, RESELLER, fafdf38b16bf6b2b
|
||||
verve.com, 14619, RESELLER, 0c8f5958fc2d6270
|
||||
pubmatic.com, 156439, RESELLER, 5d62403b186f2ace
|
||||
pubmatic.com, 154037, RESELLER, 5d62403b186f2ace
|
||||
rubiconproject.com, 16114, RESELLER, 0bfd66d529a55807
|
||||
openx.com, 537149888, RESELLER, 6a698e2ec38604c6
|
||||
appnexus.com, 3703, RESELLER, f5ab79cb980f11d1
|
||||
loopme.com, 5679, RESELLER, 6c8d5f95897a5a3b
|
||||
xad.com, 958, RESELLER, 81cbf0a75a5e0e9a
|
||||
video.unrulymedia.com, 2564526802, RESELLER, 6f752381ad5ec0e5
|
||||
smaato.com, 1100044045, RESELLER, 07bcf65f187117b4
|
||||
pubnative.net, 1006576, RESELLER, d641df8625486a7b
|
||||
verve.com, 15503, RESELLER, 0c8f5958fc2d6270
|
||||
adyoulike.com, b4bf4fdd9b0b915f746f6747ff432bde, RESELLER, 4ad745ead2958bf7
|
||||
axonix.com, 57264, RESELLER, bc385f2b4a87b721
|
||||
admanmedia.com, 43, RESELLER
|
||||
sharethrough.com, OAW69Fon, RESELLER, d53b998a7bd4ecd2
|
||||
toponad.com, 166e12f3c48018, RESELLER, 1d49fe424a1a456d
|
||||
pubmatic.com, 165329, RESELLER, 5d62403b186f2ace
|
||||
loopme.com, 11635, RESELLER, 6c8d5f95897a5a3b
|
||||
conversantmedia.com, 100569, RESELLER, 03113cd04947736d
|
||||
app-stock.com,558223, RESELLER, ed8c126ea5971415
|
||||
rubiconproject.com, 15044, RESELLER, 0bfd66d529a55807
|
||||
video.unrulymedia.com, 557688749, RESELLER
|
||||
rubiconproject.com, 15268, RESELLER, 0bfd66d529a55807
|
||||
pubmatic.com, 159277, RESELLER, 5d62403b186f2ace
|
||||
appnexus.com, 6849, RESELLER, f5ab79cb980f11d1
|
||||
opera.com, pub12998959884416, RESELLER, 55a0c5fd61378de3
|
||||
dauup.com, 34191, RESELLER, 4daba13e2b0dfd92
|
||||
rubiconproject.com, 26270, RESELLER, 0bfd66d529a55807
|
||||
lijit.com, 541964, RESELLER, fafdf38b16bf6b2b #SOVRN
|
||||
inmobi.com, f73add69216f42d9ba45940a548416d3, RESELLER, 83e75a7ae333ca9d
|
||||
conversantmedia.com, 100949, RESELLER, 03113cd04947736d
|
||||
munimob.com, 2100033154, RESELLER, 09fcf65f918716a4
|
||||
playdigo.com, 2041, RESELLER, 92011346d63d3c30
|
||||
improvedigital.com, 2534, RESELLER
|
||||
acexchange.co.kr, 1288659831, RESELLER
|
||||
lijit.com, 558045, RESELLER, fafdf38b16bf6b2b #SOVRN
|
||||
video.unrulymedia.com, 389957698, RESELLER
|
||||
risecodes.com, 685c25b72cb7980001e4ff27, RESELLER
|
||||
criteo.com, b-062019, RESELLER, 9fac4a4a87c2a44f
|
||||
smaato.com, 1100058938, RESELLER, 07bcf65f187117b4
|
||||
BIN
messenger-server/public/icon.webp
Normal file
BIN
messenger-server/public/icon.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.0 KiB |
119
messenger-server/public/index.html
Normal file
119
messenger-server/public/index.html
Normal file
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Messenger</title>
|
||||
<meta name="theme-color" content="#388bfd">
|
||||
<link rel="stylesheet" href="static/style.css">
|
||||
<link rel="icon" href="static/icon.webp">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header>
|
||||
<nav class="container">
|
||||
<ul>
|
||||
<li class="active">
|
||||
<a href="/">Главная</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/android">Приложения</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="container">
|
||||
<section class="hero">
|
||||
<div class="hero">
|
||||
<h1>Messenger нового поколения</h1>
|
||||
<p>Быстрый. Приватный. Безопасный.</p>
|
||||
<a href="/android" class="btn-hero">
|
||||
Скачать для Android
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features">
|
||||
<h2 class="section-title">Создан для приватности</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="512" height="512">
|
||||
<path
|
||||
d="M18.581,2.14,12.316.051a1,1,0,0,0-.632,0L5.419,2.14A4.993,4.993,0,0,0,2,6.883V12c0,7.563,9.2,11.74,9.594,11.914a1,1,0,0,0,.812,0C12.8,23.74,22,19.563,22,12V6.883A4.993,4.993,0,0,0,18.581,2.14ZM20,12c0,5.455-6.319,9.033-8,9.889-1.683-.853-8-4.42-8-9.889V6.883A3,3,0,0,1,6.052,4.037L12,2.054l5.948,1.983A3,3,0,0,1,20,6.883Z" />
|
||||
<path
|
||||
d="M15.3,8.3,11.112,12.5,8.868,10.16a1,1,0,1,0-1.441,1.386l2.306,2.4a1.872,1.872,0,0,0,1.345.6h.033a1.873,1.873,0,0,0,1.335-.553l4.272-4.272A1,1,0,0,0,15.3,8.3Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Приватность</h3>
|
||||
<p>Для регистрации не требуется номер телефона или email. Создайте аккаунт и начните общаться,
|
||||
сохраняя свою анонимность.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="512" height="512">
|
||||
<path
|
||||
d="M9,22c-.373,0-.746-.138-1.037-.416L1.342,15.256c-1.779-1.778-1.779-4.633-.024-6.388L7.955,2.424c.594-.576,1.544-.563,2.121,.031,.577,.594,.563,1.544-.031,2.121L3.424,11.005c-.268,.268-.424,.645-.424,1.045s.156,.777,.439,1.061l6.597,6.305c.599,.572,.62,1.521,.048,2.12-.295,.309-.689,.464-1.084,.464Zm7.046-.426l6.618-6.445c1.754-1.755,1.754-4.609-.023-6.387l-6.604-6.325c-.599-.575-1.548-.554-2.121,.045-.573,.598-.553,1.548,.045,2.121l6.58,6.303c.585,.585,.585,1.537,.015,2.108l-6.604,6.432c-.594,.578-.606,1.527-.028,2.121,.294,.302,.684,.453,1.075,.453,.377,0,.755-.142,1.046-.426Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Открытый код</h3>
|
||||
<p>100% открытый исходный код. Прозрачность, которой можно доверять. Никаких сюрпризов.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="opensource">
|
||||
<div class="opensource-content">
|
||||
<h2 class="section-title">Прозрачность и доверие</h2>
|
||||
<p>
|
||||
Мы верим в силу сообщества. Исходный код нашего клиента и сервера доступен на GitHub.
|
||||
Присоединяйтесь к разработке, предлагайте улучшения или просто убедитесь в нашей честности.
|
||||
</p>
|
||||
<div class="opensource-buttons">
|
||||
<a href="https://github.com/karenaiwazian/Messenger_Client" target="_blank">
|
||||
Клиент на GitHub
|
||||
</a>
|
||||
<a href="https://github.com/karenaiwazian/Messenger_Server" target="_blank">
|
||||
Сервер на GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<div class="desktop">
|
||||
<div class="col slang">
|
||||
<h3>Messenger</h3> – открытая и безопасная платформа для общения.
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="title">
|
||||
<h3>Приложения</h3>
|
||||
</div>
|
||||
<div>
|
||||
<a href="/android">Android</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="title">
|
||||
<h3>Открытый код</h3>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/karenaiwazian/Messenger_Client" target="_blank">Клиент</a>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://github.com/karenaiwazian/Messenger_Server" target="_blank">Сервер</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mobile">
|
||||
<a href="/android">Приложения</a>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
310
messenger-server/public/style.css
Normal file
310
messenger-server/public/style.css
Normal file
@@ -0,0 +1,310 @@
|
||||
:root {
|
||||
--text-color: #222222;
|
||||
--primary-color: #388bfd;
|
||||
--primary-hover-color: #006aff;
|
||||
--transition: 0.3s;
|
||||
--border-color: rgb(211 211 211 / 60%);
|
||||
--border-radius: 40px;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
a,
|
||||
li,
|
||||
button {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html::selection {
|
||||
color: white;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
html {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgb(255 255 255 / 40%);
|
||||
}
|
||||
|
||||
header nav ul {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
header nav ul li {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
header nav ul li a {
|
||||
font-size: 18px;
|
||||
padding: 14px 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
header nav ul li::after {
|
||||
pointer-events: none;
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
width: 0;
|
||||
height: 4px;
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
background-color: var(--primary-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
header nav li:hover::after,
|
||||
header nav li.active::after {
|
||||
left: 20px;
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
footer .mobile {
|
||||
display: none;
|
||||
text-align: center;
|
||||
padding-block: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
footer .desktop {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-block: 20px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: var(--primary-hover-color);
|
||||
}
|
||||
|
||||
footer .slang h3 {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
footer .col {
|
||||
max-width: 300px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
footer .title {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1024px;
|
||||
padding-inline: 20px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
margin-block: 50px;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 2rem;
|
||||
background: linear-gradient(90deg, #58a6ff, #a371f7);
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-hero {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
border-radius: 20px;
|
||||
padding: 16px 24px;
|
||||
background-color: var(--primary-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.btn-hero:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-hero:active {
|
||||
scale: 0.98;
|
||||
}
|
||||
|
||||
section {
|
||||
padding-block: 100px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-gap: 20px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
padding: 28px;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgb(252 252 252);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.feature-card {
|
||||
opacity: 0;
|
||||
translate: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.feature-card:hover .icon svg {
|
||||
fill: var(--primary-color);
|
||||
}
|
||||
|
||||
.feature-card .icon {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.feature-card .icon svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
font-size: 1.6rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.opensource-content {
|
||||
padding: 28px;
|
||||
margin-inline: auto;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border-color);
|
||||
background: rgb(252 252 252);
|
||||
}
|
||||
|
||||
.opensource p {
|
||||
line-height: 1.4;
|
||||
text-wrap: balance;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.opensource-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.opensource-buttons a {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
padding: 14px 28px;
|
||||
color: var(--primary-color);
|
||||
border-radius: 20px;
|
||||
border: 2px solid var(--primary-color);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.opensource-buttons a:hover {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@media screen and (width <=768px) {
|
||||
.hero h1 {
|
||||
font-size: 28px;
|
||||
line-height: 34px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 16px;
|
||||
margin-block: 16px 32px;
|
||||
}
|
||||
|
||||
.hero .btn-hero {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.btn-hero {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.opensource-content {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.opensource-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.opensource-buttons a {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.desktop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
7
messenger-server/src/app.controller.ts
Normal file
7
messenger-server/src/app.controller.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Controller } from '@nestjs/common'
|
||||
import { AppService } from './app.service'
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
}
|
||||
62
messenger-server/src/app.module.ts
Normal file
62
messenger-server/src/app.module.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'
|
||||
import { ConfigModule } from '@nestjs/config'
|
||||
import { AppController } from './app.controller'
|
||||
import { AppService } from './app.service'
|
||||
import { UsersModule } from './modules/users/users.module'
|
||||
import { ChannelsModule } from './modules/channels/channels.module'
|
||||
import { AuthModule } from './modules/auth/auth.module'
|
||||
import { AuthMiddleware } from './common/middlewares/auth.middleware'
|
||||
import { SessionsModule } from './modules/sessions/sessions.module'
|
||||
import { RealtimeModule } from './modules/realtime/realtime.module'
|
||||
import { GroupsModule } from './modules/groups/groups.module'
|
||||
import { ChatsModule } from './modules/chats/chats.module'
|
||||
import { MessagesModule } from './modules/messages/messages.module'
|
||||
import { PrismaModule } from './providers/prisma/prisma.module'
|
||||
import { SearchModule } from './modules/search/search.module'
|
||||
import { PushModule } from './modules/push/push.module'
|
||||
import { StorageModule } from './modules/storage/storage.module'
|
||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'
|
||||
import { APP_GUARD } from '@nestjs/core'
|
||||
import { KanbanModule } from './modules/kanban/kanban.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
ThrottlerModule.forRoot([{
|
||||
ttl: 60000,
|
||||
limit: 100,
|
||||
}]),
|
||||
PrismaModule,
|
||||
UsersModule,
|
||||
ChannelsModule,
|
||||
AuthModule,
|
||||
SessionsModule,
|
||||
RealtimeModule,
|
||||
GroupsModule,
|
||||
ChatsModule,
|
||||
MessagesModule,
|
||||
SearchModule,
|
||||
PushModule,
|
||||
StorageModule,
|
||||
KanbanModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AppService,
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: ThrottlerGuard,
|
||||
}
|
||||
]
|
||||
})
|
||||
export class AppModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer
|
||||
.apply(AuthMiddleware)
|
||||
.exclude(
|
||||
{ path: 'auth/*path', method: RequestMethod.ALL },
|
||||
{ path: 'auth', method: RequestMethod.ALL }
|
||||
)
|
||||
.forRoutes('*path')
|
||||
}
|
||||
}
|
||||
4
messenger-server/src/app.service.ts
Normal file
4
messenger-server/src/app.service.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
|
||||
@Injectable()
|
||||
export class AppService {}
|
||||
2
messenger-server/src/common/constants/db.constants.ts
Normal file
2
messenger-server/src/common/constants/db.constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const MAX_INT64 = 9223372036854775807n
|
||||
export const MIN_INT64 = 0n
|
||||
8
messenger-server/src/common/constants/param.constants.ts
Normal file
8
messenger-server/src/common/constants/param.constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const PARAMS = {
|
||||
USER_ID: 'userId',
|
||||
GROUP_ID: 'groupId',
|
||||
CHANNEL_ID: 'channelId',
|
||||
SESSION_ID: 'sessionId',
|
||||
CHAT_ID: 'chatId',
|
||||
MESSAGE_ID: 'messageId'
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Transform } from 'class-transformer'
|
||||
|
||||
export const OmitNull = () => {
|
||||
return Transform(({ value }) => value ?? undefined, { toPlainOnly: true })
|
||||
}
|
||||
10
messenger-server/src/common/decorators/trim.decorator.ts
Normal file
10
messenger-server/src/common/decorators/trim.decorator.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Transform, TransformFnParams } from "class-transformer"
|
||||
|
||||
export function Trim() {
|
||||
return Transform(({ value }: TransformFnParams) => {
|
||||
if (typeof value === 'string') {
|
||||
return value.trim()
|
||||
}
|
||||
return value
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
|
||||
|
||||
export const CurrentUserId = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest()
|
||||
if (!request.user) {
|
||||
throw new Error('User not found in request')
|
||||
}
|
||||
return request.user.id
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common'
|
||||
|
||||
export const CurrentUserToken = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext) => {
|
||||
const request = ctx.switchToHttp().getRequest()
|
||||
const authHeader = request.headers['authorization'] || ''
|
||||
const token = authHeader.replace(/^Bearer\s+/i, '')
|
||||
return token
|
||||
}
|
||||
)
|
||||
6
messenger-server/src/common/enums/chat-type.enum.ts
Normal file
6
messenger-server/src/common/enums/chat-type.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum ChatType {
|
||||
PRIVATE = 0,
|
||||
GROUP = 1,
|
||||
CHANNEL = 2,
|
||||
UNKNOWN = 3
|
||||
}
|
||||
38
messenger-server/src/common/filters/all-exceptions.filter.ts
Normal file
38
messenger-server/src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||
import { HttpAdapterHost } from '@nestjs/core';
|
||||
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
|
||||
constructor(private readonly httpAdapterHost: HttpAdapterHost) { }
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const { httpAdapter } = this.httpAdapterHost;
|
||||
const ctx = host.switchToHttp();
|
||||
|
||||
if (host.getType() !== 'http') {
|
||||
this.logger.error(`Unhandled exception in ${host.getType()}:`, exception);
|
||||
return;
|
||||
}
|
||||
|
||||
const httpStatus =
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const responseBody = {
|
||||
statusCode: httpStatus,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: httpAdapter.getRequestUrl(ctx.getRequest()),
|
||||
message: exception instanceof Error ? exception.message : 'Internal server error',
|
||||
};
|
||||
|
||||
this.logger.error(
|
||||
`Unhandled Exception: ${JSON.stringify(responseBody)}`,
|
||||
exception instanceof Error ? exception.stack : ''
|
||||
);
|
||||
|
||||
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
|
||||
}
|
||||
}
|
||||
47
messenger-server/src/common/guards/auth.guard.ts
Normal file
47
messenger-server/src/common/guards/auth.guard.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common'
|
||||
import { SessionsService } from 'src/modules/sessions/sessions.service'
|
||||
import { JwtAuthService } from 'src/modules/security/jwt.service'
|
||||
import { TokenPayload } from '../types/token-payload.type'
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly sessionService: SessionsService,
|
||||
private readonly jwtService: JwtAuthService
|
||||
) { }
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest()
|
||||
let token = request.token
|
||||
|
||||
if (!token) {
|
||||
const authHeader = request.headers['authorization'] || request.headers['Authorization']
|
||||
|
||||
if (!authHeader || typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Authorization header is missing or invalid')
|
||||
}
|
||||
|
||||
token = authHeader.slice(7).trim()
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Token is missing')
|
||||
}
|
||||
}
|
||||
|
||||
let payload: TokenPayload
|
||||
try {
|
||||
payload = this.jwtService.verifyToken(token)
|
||||
} catch (err) {
|
||||
throw new UnauthorizedException('Invalid or expired token')
|
||||
}
|
||||
|
||||
const session = await this.sessionService.findByTokenAndUserId(token, payload.userId)
|
||||
if (!session) {
|
||||
throw new UnauthorizedException('Invalid token')
|
||||
}
|
||||
|
||||
request.user = { id: payload.userId, token: token }
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common"
|
||||
import { PrismaService } from "src/providers/prisma/prisma.service"
|
||||
import { UserId } from "../types/user-id.type"
|
||||
import { PARAMS } from "../constants/param.constants"
|
||||
import { detectChatType } from "../utils/detect-chat-type.util"
|
||||
import { ChatType } from "../enums/chat-type.enum"
|
||||
import { ConversationType } from "generated/prisma/client"
|
||||
|
||||
@Injectable()
|
||||
export class CanDeleteMessageGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest()
|
||||
const userId: UserId = request.user.id
|
||||
const messageId = parseInt(request.params[PARAMS.MESSAGE_ID])
|
||||
|
||||
if (isNaN(messageId)) {
|
||||
throw new NotFoundException('Message not found')
|
||||
}
|
||||
|
||||
const message = await this.prisma.message.findUnique({
|
||||
where: { id: messageId },
|
||||
include: { conversation: true }
|
||||
})
|
||||
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found')
|
||||
}
|
||||
|
||||
// 1. User is sender
|
||||
if (message.senderId === userId) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 2. User is a member of the direct chat (allow deleting anyone's message in DM)
|
||||
if (message.conversation.type === ConversationType.DIRECT) {
|
||||
const member = await this.prisma.conversationMember.findFirst({
|
||||
where: { conversationId: message.conversation.id, userId: userId }
|
||||
})
|
||||
if (member) return true
|
||||
}
|
||||
|
||||
// 3. User is owner of the group/channel
|
||||
if (message.conversation.type === ConversationType.GROUP) {
|
||||
const group = await this.prisma.group.findFirst({
|
||||
where: { id: message.conversation.id, ownerId: userId }
|
||||
})
|
||||
if (group) return true
|
||||
}
|
||||
|
||||
if (message.conversation.type === ConversationType.CHANNEL) {
|
||||
const channel = await this.prisma.channel.findFirst({
|
||||
where: { id: message.conversation.id, ownerId: userId }
|
||||
})
|
||||
if (channel) return true
|
||||
}
|
||||
|
||||
throw new ForbiddenException('You cannot delete this message')
|
||||
}
|
||||
}
|
||||
29
messenger-server/src/common/guards/can-read-chat.guard.ts
Normal file
29
messenger-server/src/common/guards/can-read-chat.guard.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'
|
||||
import { ChatId } from '../types/chat-id.type'
|
||||
import { UserId } from '../types/user-id.type'
|
||||
import { PARAMS } from '../constants/param.constants'
|
||||
import { ChatsService } from 'src/modules/chats/chats.service'
|
||||
|
||||
@Injectable()
|
||||
export class CanReadChatGuard implements CanActivate {
|
||||
constructor(private readonly chatsService: ChatsService) { }
|
||||
|
||||
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||
const request = ctx.switchToHttp().getRequest()
|
||||
const userId: UserId = request.user.id
|
||||
|
||||
const rawChatId = request.params[PARAMS.CHAT_ID] || request.params[PARAMS.GROUP_ID] || request.params[PARAMS.CHANNEL_ID]
|
||||
const chatId = rawChatId ? ChatId(rawChatId) : undefined
|
||||
const messageId = request.params[PARAMS.MESSAGE_ID] ? Number(request.params[PARAMS.MESSAGE_ID]) : undefined
|
||||
|
||||
if (messageId !== undefined) {
|
||||
return this.chatsService.canReadMessage(userId, messageId, chatId)
|
||||
}
|
||||
|
||||
if (chatId !== undefined) {
|
||||
return this.chatsService.canReadChat(userId, chatId)
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Chat is not specified')
|
||||
}
|
||||
}
|
||||
57
messenger-server/src/common/guards/can-send-message.guard.ts
Normal file
57
messenger-server/src/common/guards/can-send-message.guard.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common"
|
||||
import { ChatType } from "../enums/chat-type.enum"
|
||||
import { PrismaService } from "src/providers/prisma/prisma.service"
|
||||
import { ChatId } from "../types/chat-id.type"
|
||||
import { UserId } from "../types/user-id.type"
|
||||
import { PARAMS } from "../constants/param.constants"
|
||||
import { detectChatType } from "../utils/detect-chat-type.util"
|
||||
|
||||
@Injectable()
|
||||
export class CanSendMessageGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||
const request = ctx.switchToHttp().getRequest()
|
||||
|
||||
const chatId: ChatId = ChatId(request.params[PARAMS.CHAT_ID])
|
||||
const userId: UserId = request.user.id
|
||||
|
||||
const chatType = detectChatType(chatId)
|
||||
|
||||
if (chatType === ChatType.PRIVATE) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (chatType === ChatType.GROUP) {
|
||||
const member = await this.prisma.groupMember.findFirst({
|
||||
where: {
|
||||
groupId: chatId,
|
||||
userId: userId
|
||||
}
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
throw new ForbiddenException('User is not a group member')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (chatType === ChatType.CHANNEL) {
|
||||
const admin = await this.prisma.channel.findFirst({
|
||||
where: {
|
||||
id: chatId,
|
||||
ownerId: userId
|
||||
}
|
||||
})
|
||||
|
||||
if (!admin) {
|
||||
throw new ForbiddenException('Only channel admins can write')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
21
messenger-server/src/common/guards/channel-exists.guard.ts
Normal file
21
messenger-server/src/common/guards/channel-exists.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { ChannelId } from '../types/channel-id.type'
|
||||
import { ChannelsService } from 'src/modules/channels/channels.service'
|
||||
import { PARAMS } from '../constants/param.constants'
|
||||
|
||||
@Injectable()
|
||||
export class ChannelExistsGuard implements CanActivate {
|
||||
constructor(private readonly channelsService: ChannelsService) { }
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest()
|
||||
const channelId: ChannelId = request.params[PARAMS.CHANNEL_ID]
|
||||
|
||||
const channel = await this.channelsService.isExists(channelId)
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
21
messenger-server/src/common/guards/channel-owner.guard.ts
Normal file
21
messenger-server/src/common/guards/channel-owner.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'
|
||||
import { ChannelsService } from 'src/modules/channels/channels.service'
|
||||
import { PARAMS } from '../constants/param.constants'
|
||||
|
||||
@Injectable()
|
||||
export class ChannelOwnerGuard implements CanActivate {
|
||||
constructor(private readonly channelsService: ChannelsService) { }
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest()
|
||||
const user = request.user
|
||||
const channelId = request.params[PARAMS.CHANNEL_ID]
|
||||
|
||||
const isOwner = await this.channelsService.isOwner(channelId, user.id)
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('User is not the owner of the channel')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
21
messenger-server/src/common/guards/group-exists.guard.ts
Normal file
21
messenger-server/src/common/guards/group-exists.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { GroupId } from '../types/group-id.type'
|
||||
import { GroupsService } from 'src/modules/groups/groups.service'
|
||||
import { PARAMS } from '../constants/param.constants'
|
||||
|
||||
@Injectable()
|
||||
export class GroupExistsGuard implements CanActivate {
|
||||
constructor(private readonly groupsService: GroupsService) { }
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest()
|
||||
const groupId: GroupId = request.params[PARAMS.GROUP_ID]
|
||||
|
||||
const group = await this.groupsService.isExists(groupId)
|
||||
if (!group) {
|
||||
throw new NotFoundException('Group not found')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
21
messenger-server/src/common/guards/group-owner.guard.ts
Normal file
21
messenger-server/src/common/guards/group-owner.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'
|
||||
import { GroupsService } from 'src/modules/groups/groups.service'
|
||||
import { PARAMS } from '../constants/param.constants'
|
||||
|
||||
@Injectable()
|
||||
export class GroupOwnerGuard implements CanActivate {
|
||||
constructor(private readonly groupsService: GroupsService) { }
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest()
|
||||
const user = request.user
|
||||
const groupId = request.params[PARAMS.GROUP_ID]
|
||||
|
||||
const isOwner = await this.groupsService.isOwner(groupId, user.id)
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('User is not the owner of the channel')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
48
messenger-server/src/common/guards/privacy.guard.ts
Normal file
48
messenger-server/src/common/guards/privacy.guard.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { PARAMS } from '../constants/param.constants'
|
||||
import { PrismaService } from 'src/providers/prisma/prisma.service'
|
||||
import { UserId } from '../types/user-id.type'
|
||||
|
||||
@Injectable()
|
||||
export class PrivacyGuard implements CanActivate {
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest()
|
||||
const targetUserIdRaw = request.params[PARAMS.USER_ID]
|
||||
if (!targetUserIdRaw) return true
|
||||
|
||||
const targetUserId = UserId(targetUserIdRaw)
|
||||
const currentUserId = request.user?.id
|
||||
|
||||
const settings = await this.prisma.privacySettings.findUnique({
|
||||
where: { userId: targetUserId }
|
||||
})
|
||||
|
||||
if (!settings) {
|
||||
const userExists = await this.prisma.user.count({ where: { id: targetUserId } }) > 0
|
||||
if (!userExists) throw new NotFoundException('User not found')
|
||||
|
||||
request.privacy = {
|
||||
canSeeBio: true,
|
||||
canSeeDateOfBirth: true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if (targetUserId === currentUserId) {
|
||||
request.privacy = {
|
||||
canSeeBio: true,
|
||||
canSeeDateOfBirth: true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
request.privacy = {
|
||||
canSeeBio: settings.bio === 0,
|
||||
canSeeDateOfBirth: settings.dateOfBirth === 0
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
22
messenger-server/src/common/guards/session-owner.guard.ts
Normal file
22
messenger-server/src/common/guards/session-owner.guard.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'
|
||||
import { SessionsService } from 'src/modules/sessions/sessions.service'
|
||||
import { PARAMS } from '../constants/param.constants'
|
||||
import { SessionId } from '../types/session-id.type'
|
||||
|
||||
@Injectable()
|
||||
export class SessionOwnerGuard implements CanActivate {
|
||||
constructor(private readonly sessionsService: SessionsService) { }
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest()
|
||||
const user = request.user
|
||||
const sessionId: SessionId = request.params[PARAMS.SESSION_ID]
|
||||
|
||||
const isOwner = await this.sessionsService.isOwner(sessionId, user.id)
|
||||
if (!isOwner) {
|
||||
throw new ForbiddenException('You are not allowed to delete this session')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
21
messenger-server/src/common/guards/user-exists.guard.ts
Normal file
21
messenger-server/src/common/guards/user-exists.guard.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { PARAMS } from '../constants/param.constants'
|
||||
import { UsersService } from 'src/modules/users/users.service'
|
||||
import { UserId } from '../types/user-id.type'
|
||||
|
||||
@Injectable()
|
||||
export class UserExistsGuard implements CanActivate {
|
||||
constructor(private readonly usersService: UsersService) { }
|
||||
|
||||
async canActivate(context: ExecutionContext) {
|
||||
const request = context.switchToHttp().getRequest()
|
||||
const userId: UserId = request.params[PARAMS.USER_ID]
|
||||
|
||||
const user = await this.usersService.isExists(userId)
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'
|
||||
import { map } from 'rxjs'
|
||||
|
||||
@Injectable()
|
||||
export class BigIntInterceptor implements NestInterceptor {
|
||||
intercept(_: ExecutionContext, next: CallHandler) {
|
||||
return next.handle().pipe(
|
||||
map((data) => {
|
||||
return this.serialize(data)
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private serialize(obj: any): any {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (typeof obj === 'bigint') return obj.toString()
|
||||
if (Array.isArray(obj)) return obj.map((item) => this.serialize(item))
|
||||
if (typeof obj === 'object') {
|
||||
// Check if it's a plain object or instance (avoid serializing complex objects like Date, if any)
|
||||
if (obj instanceof Date) return obj.getTime()
|
||||
|
||||
const newObj = {}
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
newObj[key] = this.serialize(obj[key])
|
||||
}
|
||||
}
|
||||
return newObj
|
||||
}
|
||||
return obj
|
||||
}
|
||||
}
|
||||
27
messenger-server/src/common/middlewares/auth.middleware.ts
Normal file
27
messenger-server/src/common/middlewares/auth.middleware.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Injectable, NestMiddleware, UnauthorizedException } from '@nestjs/common'
|
||||
import { Response, NextFunction } from 'express'
|
||||
|
||||
interface AuthRequest extends Request {
|
||||
token?: string
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthMiddleware implements NestMiddleware {
|
||||
use(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'] || req.headers['Authorization']
|
||||
|
||||
if (!authHeader || typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Authorization header is missing or invalid')
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7).trim()
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Token is required')
|
||||
}
|
||||
|
||||
req.token = token
|
||||
|
||||
next()
|
||||
}
|
||||
}
|
||||
21
messenger-server/src/common/pipes/parse-channel-id.pipe.ts
Normal file
21
messenger-server/src/common/pipes/parse-channel-id.pipe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PipeTransform, BadRequestException } from '@nestjs/common'
|
||||
import { MAX_INT64 } from '../constants/db.constants'
|
||||
import { ChannelId } from '../types/channel-id.type'
|
||||
|
||||
export class ParseChannelIdPipe implements PipeTransform<string, ChannelId> {
|
||||
transform(value: string): ChannelId {
|
||||
let id: ChannelId
|
||||
|
||||
try {
|
||||
id = ChannelId(value)
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid channel id')
|
||||
}
|
||||
|
||||
if (id > MAX_INT64) {
|
||||
throw new BadRequestException('Id is too large')
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
}
|
||||
21
messenger-server/src/common/pipes/parse-chat-id.pipe.ts
Normal file
21
messenger-server/src/common/pipes/parse-chat-id.pipe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PipeTransform, BadRequestException } from '@nestjs/common'
|
||||
import { MAX_INT64 } from '../constants/db.constants'
|
||||
import { ChatId } from '../types/chat-id.type'
|
||||
|
||||
export class ParseChatIdPipe implements PipeTransform<string, ChatId> {
|
||||
transform(value: string): ChatId {
|
||||
let id: ChatId
|
||||
|
||||
try {
|
||||
id = ChatId(value)
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid channel id')
|
||||
}
|
||||
|
||||
if (id > MAX_INT64) {
|
||||
throw new BadRequestException('Id is too large')
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
}
|
||||
21
messenger-server/src/common/pipes/parse-group-id.pipe.ts
Normal file
21
messenger-server/src/common/pipes/parse-group-id.pipe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PipeTransform, BadRequestException } from '@nestjs/common'
|
||||
import { MAX_INT64 } from '../constants/db.constants'
|
||||
import { GroupId } from '../types/group-id.type'
|
||||
|
||||
export class ParseGroupIdPipe implements PipeTransform<string, GroupId> {
|
||||
transform(value: string): GroupId {
|
||||
let id: GroupId
|
||||
|
||||
try {
|
||||
id = GroupId(value)
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid group id')
|
||||
}
|
||||
|
||||
if (id > MAX_INT64) {
|
||||
throw new BadRequestException('Id is too large')
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
}
|
||||
21
messenger-server/src/common/pipes/parse-user-id.pipe.ts
Normal file
21
messenger-server/src/common/pipes/parse-user-id.pipe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PipeTransform, BadRequestException } from '@nestjs/common'
|
||||
import { MAX_INT64 } from '../constants/db.constants'
|
||||
import { UserId } from '../types/user-id.type'
|
||||
|
||||
export class ParseUserIdPipe implements PipeTransform<string, UserId> {
|
||||
transform(value: string): UserId {
|
||||
let id: UserId
|
||||
|
||||
try {
|
||||
id = UserId(value)
|
||||
} catch {
|
||||
throw new BadRequestException('Invalid user id')
|
||||
}
|
||||
|
||||
if (id > MAX_INT64) {
|
||||
throw new BadRequestException('Id is too large')
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
}
|
||||
20
messenger-server/src/common/socket/socket-events.ts
Normal file
20
messenger-server/src/common/socket/socket-events.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const SocketEvent = {
|
||||
MESSAGE_NEW: "message:new",
|
||||
MESSAGE_EDIT: "message:edit",
|
||||
MESSAGE_UPDATE: "message:update",
|
||||
MESSAGE_DELETE: "message:delete",
|
||||
|
||||
CHAT_TYPING: "chat:typing",
|
||||
CHAT_OPEN: "chat:open",
|
||||
CHAT_CLOSE: "chat:close",
|
||||
CHAT_READ: "chat:read",
|
||||
CHAT_NEW: "chat:new",
|
||||
HISTORY_CLEAR: "chat:history_clear",
|
||||
|
||||
USER_ONLINE: "user:online",
|
||||
USER_OFFLINE: "user:offline",
|
||||
AUTH_ERROR: "auth:error",
|
||||
KANBAN_UPDATE: "kanban:update"
|
||||
} as const
|
||||
|
||||
export type SocketEventType = typeof SocketEvent[keyof typeof SocketEvent]
|
||||
1
messenger-server/src/common/types/brand.ts
Normal file
1
messenger-server/src/common/types/brand.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type Brand<K, T> = K & { __brand: T }
|
||||
11
messenger-server/src/common/types/channel-id.type.ts
Normal file
11
messenger-server/src/common/types/channel-id.type.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Brand } from './brand'
|
||||
|
||||
export type ChannelId = Brand<bigint, 'ChannelId'>
|
||||
|
||||
export function ChannelId(value: string | bigint | number) {
|
||||
if (!/^\d+$/.test(value.toString())) {
|
||||
throw new Error('Invalid channel id')
|
||||
}
|
||||
|
||||
return BigInt(value) as ChannelId
|
||||
}
|
||||
11
messenger-server/src/common/types/chat-id.type.ts
Normal file
11
messenger-server/src/common/types/chat-id.type.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Brand } from './brand'
|
||||
|
||||
export type ChatId = Brand<bigint, 'ChatId'>
|
||||
|
||||
export function ChatId(value: string | bigint | number) {
|
||||
if (!/^\d+$/.test(value.toString())) {
|
||||
throw new Error('Invalid chat id')
|
||||
}
|
||||
|
||||
return BigInt(value) as ChatId
|
||||
}
|
||||
11
messenger-server/src/common/types/group-id.type.ts
Normal file
11
messenger-server/src/common/types/group-id.type.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Brand } from './brand'
|
||||
|
||||
export type GroupId = Brand<bigint, 'GroupId'>
|
||||
|
||||
export function GroupId(value: string | bigint | number) {
|
||||
if (!/^\d+$/.test(value.toString())) {
|
||||
throw new Error('Invalid group id')
|
||||
}
|
||||
|
||||
return BigInt(value) as GroupId
|
||||
}
|
||||
11
messenger-server/src/common/types/session-id.type.ts
Normal file
11
messenger-server/src/common/types/session-id.type.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Brand } from "./brand"
|
||||
|
||||
export type SessionId = Brand<number, 'UserId'>
|
||||
|
||||
export function SessionId(value: number) {
|
||||
if (!/^\d+$/.test(value.toString())) {
|
||||
throw new Error('Invalid session id')
|
||||
}
|
||||
|
||||
return Number(value) as SessionId
|
||||
}
|
||||
5
messenger-server/src/common/types/token-payload.type.ts
Normal file
5
messenger-server/src/common/types/token-payload.type.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { UserId } from "./user-id.type"
|
||||
|
||||
export type TokenPayload = {
|
||||
userId: UserId
|
||||
}
|
||||
11
messenger-server/src/common/types/user-id.type.ts
Normal file
11
messenger-server/src/common/types/user-id.type.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Brand } from './brand'
|
||||
|
||||
export type UserId = Brand<bigint, 'UserId'>
|
||||
|
||||
export function UserId(value: string | bigint | number) {
|
||||
if (!/^\d+$/.test(value.toString())) {
|
||||
throw new Error('Invalid user id')
|
||||
}
|
||||
|
||||
return BigInt(value) as UserId
|
||||
}
|
||||
21
messenger-server/src/common/utils/detect-chat-type.util.ts
Normal file
21
messenger-server/src/common/utils/detect-chat-type.util.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ChatType } from "../enums/chat-type.enum"
|
||||
import { ChannelId } from "../types/channel-id.type"
|
||||
import { ChatId } from "../types/chat-id.type"
|
||||
import { GroupId } from "../types/group-id.type"
|
||||
import { UserId } from "../types/user-id.type"
|
||||
|
||||
export function detectChatType(id: UserId | ChannelId | GroupId | ChatId): ChatType {
|
||||
const idString = id.toString()
|
||||
const firstDigit = Number(idString[0])
|
||||
|
||||
switch (firstDigit) {
|
||||
case 1:
|
||||
return ChatType.PRIVATE
|
||||
case 2:
|
||||
return ChatType.CHANNEL
|
||||
case 3:
|
||||
return ChatType.GROUP
|
||||
default:
|
||||
return ChatType.UNKNOWN
|
||||
}
|
||||
}
|
||||
36
messenger-server/src/common/utils/id-generator.util.ts
Normal file
36
messenger-server/src/common/utils/id-generator.util.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { randomInt } from 'crypto'
|
||||
import { ChatType } from '../enums/chat-type.enum'
|
||||
import { ChannelId } from '../types/channel-id.type'
|
||||
import { GroupId } from '../types/group-id.type'
|
||||
import { UserId } from '../types/user-id.type'
|
||||
|
||||
const prefixes = {
|
||||
user: 1,
|
||||
channel: 2,
|
||||
group: 3,
|
||||
invite: 4
|
||||
}
|
||||
|
||||
export function generateUserId(): UserId {
|
||||
return generateUniqueId<UserId>(prefixes.user)
|
||||
}
|
||||
|
||||
export function generateChannelId(): ChannelId {
|
||||
return generateUniqueId<ChannelId>(prefixes.channel)
|
||||
}
|
||||
|
||||
export function generateGroupId(): GroupId {
|
||||
return generateUniqueId<GroupId>(prefixes.group)
|
||||
}
|
||||
|
||||
export function generateInviteLinkId(): bigint {
|
||||
return generateUniqueId<bigint>(prefixes.invite)
|
||||
}
|
||||
|
||||
function generateUniqueId<T>(prefix: number): T {
|
||||
const timestamp = Date.now().toString()
|
||||
|
||||
const randomNumber = randomInt(10000, 99999).toString()
|
||||
|
||||
return BigInt(`${prefix}${timestamp}${randomNumber}`) as T
|
||||
}
|
||||
14
messenger-server/src/common/utils/password.util.ts
Normal file
14
messenger-server/src/common/utils/password.util.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as bcrypt from 'bcrypt'
|
||||
|
||||
const SALT_ROUNDS = 12
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return bcrypt.hash(password, SALT_ROUNDS)
|
||||
}
|
||||
|
||||
export async function verifyPassword(
|
||||
plainPassword: string,
|
||||
passwordHash: string
|
||||
): Promise<boolean> {
|
||||
return bcrypt.compare(plainPassword, passwordHash)
|
||||
}
|
||||
40
messenger-server/src/main.ts
Normal file
40
messenger-server/src/main.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { HttpAdapterHost, NestFactory, Reflector } from '@nestjs/core'
|
||||
import { AppModule } from './app.module'
|
||||
import { ClassSerializerInterceptor, Logger, ValidationPipe } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { BigIntInterceptor } from './common/interceptors/big-int.interceptor'
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap')
|
||||
const app = await NestFactory.create(AppModule)
|
||||
const configService = app.get(ConfigService)
|
||||
const httpAdapter = app.get(HttpAdapterHost)
|
||||
|
||||
app.setGlobalPrefix('api')
|
||||
app.useGlobalInterceptors(new BigIntInterceptor())
|
||||
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))
|
||||
app.useGlobalFilters(new AllExceptionsFilter(httpAdapter))
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
transformOptions: {
|
||||
enableImplicitConversion: true
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
process.on('uncaughtException', (err) => {
|
||||
logger.error(`Critical Uncaught Exception: ${err.message}`, err.stack)
|
||||
})
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
logger.error(`Unhandled Rejection: ${reason instanceof Error ? reason.message : reason}`)
|
||||
})
|
||||
|
||||
const port = configService.get<number>('SERVER_PORT')
|
||||
await app.listen(port)
|
||||
}
|
||||
bootstrap()
|
||||
42
messenger-server/src/modules/auth/auth.controller.ts
Normal file
42
messenger-server/src/modules/auth/auth.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common'
|
||||
import { AuthService } from './auth.service'
|
||||
import { SigninDto } from './dto/signin.dto'
|
||||
import { SessionsService } from '../sessions/sessions.service'
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard'
|
||||
import { SignupDto } from './dto/signup.dto'
|
||||
import { CurrentUserId } from 'src/common/decorators/user-id.decorator'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { CurrentUserToken } from 'src/common/decorators/user-token.decorator'
|
||||
import { SessionOwnerGuard } from 'src/common/guards/session-owner.guard'
|
||||
import { ThrottlerGuard } from '@nestjs/throttler'
|
||||
|
||||
@Controller('auth')
|
||||
@UseGuards(ThrottlerGuard)
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly sessionService: SessionsService
|
||||
) { }
|
||||
|
||||
@Get('check/:login')
|
||||
isLoginAvailable(@Param('login') login: string) {
|
||||
return this.authService.isLoginAvailable(login)
|
||||
}
|
||||
|
||||
|
||||
@Post('signin')
|
||||
signin(@Body() dto: SigninDto) {
|
||||
return this.authService.signin(dto)
|
||||
}
|
||||
|
||||
@Post('signup')
|
||||
signup(@Body() dto: SignupDto) {
|
||||
return this.authService.signup(dto)
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@UseGuards(AuthGuard, SessionOwnerGuard)
|
||||
logout(@CurrentUserToken() token: string) {
|
||||
return this.sessionService.deleteByToken(token)
|
||||
}
|
||||
}
|
||||
12
messenger-server/src/modules/auth/auth.module.ts
Normal file
12
messenger-server/src/modules/auth/auth.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { AuthService } from './auth.service'
|
||||
import { AuthController } from './auth.controller'
|
||||
import { JwtAuthModule } from 'src/modules/security/jwt.module'
|
||||
import { SessionsModule } from '../sessions/sessions.module'
|
||||
|
||||
@Module({
|
||||
imports: [JwtAuthModule, SessionsModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService]
|
||||
})
|
||||
export class AuthModule { }
|
||||
99
messenger-server/src/modules/auth/auth.service.ts
Normal file
99
messenger-server/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common'
|
||||
import { SigninDto } from './dto/signin.dto'
|
||||
import { SignupDto } from './dto/signup.dto'
|
||||
import { generateUserId } from 'src/common/utils/id-generator.util'
|
||||
import { SessionsService } from '../sessions/sessions.service'
|
||||
import { Prisma, SenderType } from '../../../generated/prisma/client'
|
||||
import { hashPassword, verifyPassword } from 'src/common/utils/password.util'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { AuthResponseDto } from './dto/auth-response.dto'
|
||||
import { JwtAuthService } from 'src/modules/security/jwt.service'
|
||||
import { plainToInstance } from 'class-transformer'
|
||||
import { LoginAvailableDto } from './dto/check-login.dto'
|
||||
import { PrismaService } from 'src/providers/prisma/prisma.service'
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly sessionService: SessionsService,
|
||||
private readonly jwtAuth: JwtAuthService
|
||||
) { }
|
||||
|
||||
async isLoginAvailable(login: string): Promise<LoginAvailableDto> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { login: login }
|
||||
})
|
||||
|
||||
return plainToInstance(LoginAvailableDto, { available: !user })
|
||||
}
|
||||
|
||||
async signin(dto: SigninDto): Promise<AuthResponseDto> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { login: dto.login }
|
||||
})
|
||||
|
||||
if (!user) throw new UnauthorizedException('Invalid credentials')
|
||||
|
||||
const isValid = await verifyPassword(dto.password, user.password)
|
||||
if (!isValid) throw new UnauthorizedException('Invalid credentials')
|
||||
|
||||
const userId = UserId(user.id)
|
||||
|
||||
// Generate both access and refresh tokens
|
||||
const tokens = this.jwtAuth.generateTokenPair(userId)
|
||||
|
||||
await this.sessionService.create({
|
||||
userId: userId,
|
||||
token: tokens.accessToken,
|
||||
deviceModel: dto.deviceModel,
|
||||
osVersion: dto.osVersion,
|
||||
osName: dto.osName
|
||||
})
|
||||
|
||||
return plainToInstance(AuthResponseDto, { token: tokens.accessToken, userId: userId })
|
||||
}
|
||||
|
||||
async signup(dto: SignupDto): Promise<void> {
|
||||
const userId = generateUserId()
|
||||
const passwordHash = await hashPassword(dto.password)
|
||||
|
||||
try {
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
login: dto.login,
|
||||
password: passwordHash,
|
||||
firstName: dto.firstName,
|
||||
lastName: dto.lastName || null
|
||||
}
|
||||
}),
|
||||
this.prisma.sender.create({
|
||||
data: {
|
||||
id: userId,
|
||||
type: SenderType.USER,
|
||||
userId: userId
|
||||
}
|
||||
}),
|
||||
this.prisma.privacySettings.create({
|
||||
data: {
|
||||
userId: userId,
|
||||
lastSeen: 0,
|
||||
messages: 0,
|
||||
bio: 0,
|
||||
dateOfBirth: 0,
|
||||
invites: 0
|
||||
}
|
||||
})
|
||||
])
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === 'P2002') {
|
||||
throw new ConflictException('User with this login already exists')
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Transform } from 'class-transformer'
|
||||
import { IsNotEmpty, IsString, Matches, MaxLength, MinLength } from 'class-validator'
|
||||
import { Trim } from 'src/common/decorators/trim.decorator'
|
||||
|
||||
export class AuthCredentialsDto {
|
||||
@IsString()
|
||||
@Trim()
|
||||
@IsNotEmpty()
|
||||
@MinLength(5)
|
||||
@MaxLength(32)
|
||||
@Matches(/^\S+$/, {
|
||||
message: 'login must not contain spaces'
|
||||
})
|
||||
login: string
|
||||
|
||||
@IsString()
|
||||
@Trim()
|
||||
@IsNotEmpty()
|
||||
@MinLength(5)
|
||||
@MaxLength(32)
|
||||
@Matches(/^\S+$/, {
|
||||
message: 'password must not contain spaces'
|
||||
})
|
||||
password: string
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export class AuthResponseDto {
|
||||
userId: string
|
||||
token: string
|
||||
}
|
||||
3
messenger-server/src/modules/auth/dto/check-login.dto.ts
Normal file
3
messenger-server/src/modules/auth/dto/check-login.dto.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export class LoginAvailableDto {
|
||||
available: boolean
|
||||
}
|
||||
20
messenger-server/src/modules/auth/dto/signin.dto.ts
Normal file
20
messenger-server/src/modules/auth/dto/signin.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator'
|
||||
import { AuthCredentialsDto } from './auth-credentials.dto'
|
||||
import { Trim } from 'src/common/decorators/trim.decorator'
|
||||
|
||||
export class SigninDto extends AuthCredentialsDto {
|
||||
@IsString()
|
||||
@Trim()
|
||||
@IsNotEmpty()
|
||||
deviceModel: string
|
||||
|
||||
@IsString()
|
||||
@Trim()
|
||||
@IsNotEmpty()
|
||||
osVersion: string
|
||||
|
||||
@IsString()
|
||||
@Trim()
|
||||
@IsNotEmpty()
|
||||
osName: string
|
||||
}
|
||||
17
messenger-server/src/modules/auth/dto/signup.dto.ts
Normal file
17
messenger-server/src/modules/auth/dto/signup.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { AuthCredentialsDto } from './auth-credentials.dto'
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'
|
||||
import { Trim } from 'src/common/decorators/trim.decorator'
|
||||
|
||||
export class SignupDto extends AuthCredentialsDto {
|
||||
@IsString()
|
||||
@Trim()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(32)
|
||||
firstName: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Trim()
|
||||
@MaxLength(32)
|
||||
lastName?: string
|
||||
}
|
||||
96
messenger-server/src/modules/channels/channels.controller.ts
Normal file
96
messenger-server/src/modules/channels/channels.controller.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'
|
||||
import { ChannelsService } from './channels.service'
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard'
|
||||
import { ParseChannelIdPipe } from 'src/common/pipes/parse-channel-id.pipe'
|
||||
import { ChannelId } from 'src/common/types/channel-id.type'
|
||||
import { CreateChannelDto } from './dto/create-channel.dto'
|
||||
import { CurrentUserId } from 'src/common/decorators/user-id.decorator'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { ChannelOwnerGuard } from 'src/common/guards/channel-owner.guard'
|
||||
import { ChannelExistsGuard } from 'src/common/guards/channel-exists.guard'
|
||||
import { UpdateChannelDto } from './dto/update-channel.dto'
|
||||
import { PARAMS } from 'src/common/constants/param.constants'
|
||||
import { ParseUserIdPipe } from 'src/common/pipes/parse-user-id.pipe'
|
||||
|
||||
@Controller('channels')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ChannelsController {
|
||||
constructor(private readonly channelsService: ChannelsService) { }
|
||||
|
||||
@Post()
|
||||
createChannel(@CurrentUserId() userId: UserId, @Body() dto: CreateChannelDto) {
|
||||
return this.channelsService.create(userId, dto)
|
||||
}
|
||||
|
||||
@Get(`:${PARAMS.CHANNEL_ID}`)
|
||||
@UseGuards(ChannelExistsGuard)
|
||||
getChannelById(
|
||||
@CurrentUserId() userId: UserId,
|
||||
@Param(PARAMS.CHANNEL_ID, ParseChannelIdPipe) id: ChannelId
|
||||
) {
|
||||
return this.channelsService.getById(id, userId)
|
||||
}
|
||||
|
||||
@Get(`:${PARAMS.CHANNEL_ID}/subscribers`)
|
||||
@UseGuards(ChannelExistsGuard, ChannelOwnerGuard)
|
||||
getSubscribers(
|
||||
@Param(PARAMS.CHANNEL_ID, ParseChannelIdPipe) id: ChannelId,
|
||||
@Query('skip') skip: string = '0',
|
||||
@Query('take') take: string = '100',
|
||||
@Query('search') search?: string
|
||||
) {
|
||||
return this.channelsService.getSubscribers(id, parseInt(skip), parseInt(take), search)
|
||||
}
|
||||
|
||||
@Patch(`:${PARAMS.CHANNEL_ID}`)
|
||||
@UseGuards(ChannelExistsGuard, ChannelOwnerGuard)
|
||||
updateChannel(
|
||||
@Param(PARAMS.CHANNEL_ID, ParseChannelIdPipe) id: ChannelId,
|
||||
@Body() dto: UpdateChannelDto,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.channelsService.update(id, dto, userId)
|
||||
}
|
||||
|
||||
@HttpCode(204)
|
||||
@Delete(`:${PARAMS.CHANNEL_ID}`)
|
||||
@UseGuards(ChannelExistsGuard, ChannelOwnerGuard)
|
||||
delete(
|
||||
@Param(PARAMS.CHANNEL_ID, ParseChannelIdPipe) id: ChannelId,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.channelsService.delete(id, userId)
|
||||
}
|
||||
|
||||
@Post(`:${PARAMS.CHANNEL_ID}/join`)
|
||||
@UseGuards(ChannelExistsGuard)
|
||||
join(@Param(PARAMS.CHANNEL_ID, ParseChannelIdPipe) id: ChannelId, @CurrentUserId() userId: UserId) {
|
||||
return this.channelsService.join(id, userId)
|
||||
}
|
||||
|
||||
@Delete(`:${PARAMS.CHANNEL_ID}/leave`)
|
||||
@UseGuards(ChannelExistsGuard)
|
||||
leave(@Param(PARAMS.CHANNEL_ID, ParseChannelIdPipe) id: ChannelId, @CurrentUserId() userId: UserId) {
|
||||
return this.channelsService.leave(id, userId)
|
||||
}
|
||||
|
||||
@Post(`:${PARAMS.CHANNEL_ID}/kick/:userId`)
|
||||
@UseGuards(ChannelExistsGuard, ChannelOwnerGuard)
|
||||
kick(
|
||||
@Param(PARAMS.CHANNEL_ID, ParseChannelIdPipe) id: ChannelId,
|
||||
@Param('userId', ParseUserIdPipe) targetUserId: UserId,
|
||||
@CurrentUserId() ownerId: UserId
|
||||
) {
|
||||
return this.channelsService.kick(id, ownerId, targetUserId)
|
||||
}
|
||||
|
||||
@Post(`:${PARAMS.CHANNEL_ID}/ban/:userId`)
|
||||
@UseGuards(ChannelExistsGuard, ChannelOwnerGuard)
|
||||
ban(
|
||||
@Param(PARAMS.CHANNEL_ID, ParseChannelIdPipe) id: ChannelId,
|
||||
@Param('userId', ParseUserIdPipe) targetUserId: UserId,
|
||||
@CurrentUserId() ownerId: UserId
|
||||
) {
|
||||
return this.channelsService.ban(id, ownerId, targetUserId)
|
||||
}
|
||||
}
|
||||
14
messenger-server/src/modules/channels/channels.module.ts
Normal file
14
messenger-server/src/modules/channels/channels.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { ChannelsService } from './channels.service'
|
||||
import { ChannelsController } from './channels.controller'
|
||||
import { JwtAuthModule } from 'src/modules/security/jwt.module'
|
||||
import { SessionsModule } from '../sessions/sessions.module'
|
||||
import { SearchModule } from '../search/search.module'
|
||||
import { ChatsModule } from '../chats/chats.module'
|
||||
|
||||
@Module({
|
||||
imports: [JwtAuthModule, SessionsModule, SearchModule, ChatsModule],
|
||||
controllers: [ChannelsController],
|
||||
providers: [ChannelsService]
|
||||
})
|
||||
export class ChannelsModule { }
|
||||
326
messenger-server/src/modules/channels/channels.service.ts
Normal file
326
messenger-server/src/modules/channels/channels.service.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { plainToInstance } from 'class-transformer'
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { ChannelId } from 'src/common/types/channel-id.type'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { CreateChannelDto } from './dto/create-channel.dto'
|
||||
import { generateChannelId } from 'src/common/utils/id-generator.util'
|
||||
import { ChannelResponseDto } from './dto/channel.dto'
|
||||
import { SenderType } from 'generated/prisma/enums'
|
||||
import { UpdateChannelDto } from './dto/update-channel.dto'
|
||||
import { ChannelType, ConversationRole, ConversationType, Prisma } from 'generated/prisma/client'
|
||||
import { ChatsService } from '../chats/chats.service'
|
||||
import { PrismaService } from 'src/providers/prisma/prisma.service'
|
||||
import { RealtimeGateway } from '../realtime/realtime.gateway'
|
||||
import { SocketEvent } from 'src/common/socket/socket-events'
|
||||
import { ChatResponseDto } from '../chats/dto/chat-response.dto'
|
||||
import { MessageResponseDto } from '../messages/dto/message-response.dto'
|
||||
import { SearchService } from '../search/search.service'
|
||||
import { InviteLinksService } from '../chats/invite-links.service'
|
||||
import { UserResponseDto } from '../users/dto/user-response.dto'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
|
||||
@Injectable()
|
||||
export class ChannelsService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly chatsService: ChatsService,
|
||||
private readonly realtimeGateway: RealtimeGateway,
|
||||
private readonly searchService: SearchService,
|
||||
private readonly inviteLinksService: InviteLinksService
|
||||
) { }
|
||||
|
||||
async create(ownerId: UserId, dto: CreateChannelDto): Promise<ChannelResponseDto> {
|
||||
if (dto.username && dto.channelType === ChannelType.PUBLIC) {
|
||||
const isAvailable = await this.searchService.isUsernameAvailable(dto.username)
|
||||
if (!isAvailable) throw new ConflictException('Username is already taken')
|
||||
}
|
||||
|
||||
const channelId = generateChannelId()
|
||||
|
||||
try {
|
||||
const channel = await this.prisma.$transaction(async tx => {
|
||||
const channel = await tx.channel.create({
|
||||
data: {
|
||||
id: channelId,
|
||||
name: dto.name,
|
||||
bio: dto.bio,
|
||||
ownerId: ownerId,
|
||||
channelType: dto.channelType,
|
||||
username: dto.channelType === ChannelType.PUBLIC ? dto.username : null
|
||||
}
|
||||
})
|
||||
|
||||
await tx.sender.create({
|
||||
data: {
|
||||
id: channel.id,
|
||||
type: SenderType.CHANNEL,
|
||||
channelId: channel.id
|
||||
}
|
||||
})
|
||||
|
||||
await tx.channelSubscriber.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
channelId: channel.id
|
||||
}
|
||||
})
|
||||
|
||||
const conversation = await tx.conversation.create({
|
||||
data: {
|
||||
type: ConversationType.CHANNEL,
|
||||
channelId: channel.id,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
await tx.conversationMember.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
userId: ownerId,
|
||||
role: ConversationRole.OWNER,
|
||||
joinedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
await this.chatsService.create(tx, ownerId, conversation.id)
|
||||
|
||||
if (dto.channelType === ChannelType.PRIVATE) {
|
||||
await this.inviteLinksService.create(ownerId, { channelId: channel.id.toString() })
|
||||
}
|
||||
|
||||
return channel
|
||||
})
|
||||
|
||||
return this.getById(ChannelId(channel.id), ownerId)
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (e.code === 'P2002') {
|
||||
throw new ConflictException('Username already exists')
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: ChannelId, dto: UpdateChannelDto, userId: UserId): Promise<ChannelResponseDto> {
|
||||
const existingChannel = await this.prisma.channel.findUnique({ where: { id } })
|
||||
|
||||
if (dto.username && dto.username !== existingChannel!.username) {
|
||||
const isAvailable = await this.searchService.isUsernameAvailable(dto.username)
|
||||
if (!isAvailable) throw new ConflictException('Username is already taken')
|
||||
}
|
||||
|
||||
const channelType = dto.channelType ?? existingChannel!.channelType
|
||||
const username = channelType === ChannelType.PRIVATE ? null : (dto.username ?? existingChannel!.username)
|
||||
|
||||
await this.prisma.channel.update({
|
||||
where: { id: id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
bio: dto.bio,
|
||||
channelType: channelType,
|
||||
username: username
|
||||
}
|
||||
})
|
||||
return this.getById(id, userId)
|
||||
}
|
||||
|
||||
async join(channelId: ChannelId, userId: UserId): Promise<void> {
|
||||
const channel = await this.prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { conversations: true }
|
||||
})
|
||||
|
||||
if (channel!.channelType !== ChannelType.PUBLIC) {
|
||||
throw new BadRequestException('This channel is private. Use invite link to join.')
|
||||
}
|
||||
|
||||
const isBanned = await this.prisma.channelBlackList.count({
|
||||
where: { channelId, userId }
|
||||
})
|
||||
if (isBanned) throw new BadRequestException('You are banned from this channel')
|
||||
|
||||
const conversation = channel!.conversations[0]
|
||||
if (!conversation) throw new NotFoundException('Channel conversation not found')
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
const existingMember = await tx.channelSubscriber.findUnique({
|
||||
where: { userId_channelId: { userId, channelId } }
|
||||
})
|
||||
if (existingMember) return
|
||||
|
||||
await tx.channelSubscriber.create({
|
||||
data: { channelId, userId }
|
||||
})
|
||||
|
||||
await tx.conversationMember.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
userId,
|
||||
joinedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
await this.chatsService.create(tx, userId, conversation.id)
|
||||
})
|
||||
|
||||
const lastMessage = await this.prisma.message.findFirst({
|
||||
where: { conversationId: conversation.id },
|
||||
orderBy: { sendTime: 'desc' }
|
||||
})
|
||||
|
||||
const chatPayload = plainToInstance(ChatResponseDto, {
|
||||
id: channel!.id.toString(),
|
||||
name: channel!.name,
|
||||
isPinned: false,
|
||||
lastMessage: lastMessage ? plainToInstance(MessageResponseDto, {
|
||||
...lastMessage,
|
||||
chatId: channel!.id.toString()
|
||||
}) : null
|
||||
})
|
||||
|
||||
this.realtimeGateway.sendToUser(userId, SocketEvent.CHAT_NEW, chatPayload)
|
||||
}
|
||||
|
||||
async leave(channelId: ChannelId, userId: UserId): Promise<void> {
|
||||
const channel = await this.prisma.channel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { conversations: true }
|
||||
})
|
||||
if (channel!.ownerId === userId) throw new BadRequestException('Owner cannot unsubscribe. Delete it instead.')
|
||||
|
||||
const conversation = channel!.conversations[0]
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
await tx.channelSubscriber.delete({
|
||||
where: { userId_channelId: { userId, channelId } }
|
||||
}).catch(() => { })
|
||||
|
||||
if (conversation) {
|
||||
await tx.conversationMember.delete({
|
||||
where: { conversationId_userId: { conversationId: conversation.id, userId } }
|
||||
}).catch(() => { })
|
||||
|
||||
await tx.chat.deleteMany({
|
||||
where: { userId, conversationId: conversation.id }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async kick(id: ChannelId, ownerId: UserId, targetUserId: UserId): Promise<void> {
|
||||
if (targetUserId === ownerId) throw new BadRequestException('Cannot kick yourself')
|
||||
|
||||
await this.leave(id, targetUserId)
|
||||
}
|
||||
|
||||
async ban(id: ChannelId, ownerId: UserId, targetUserId: UserId): Promise<void> {
|
||||
if (targetUserId === ownerId) throw new BadRequestException('Cannot ban yourself')
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
await this.kick(id, ownerId, targetUserId)
|
||||
await tx.channelBlackList.upsert({
|
||||
where: { userId_channelId: { userId: targetUserId, channelId: id } },
|
||||
create: { userId: targetUserId, channelId: id },
|
||||
update: {}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getById(channelId: ChannelId, userId: UserId): Promise<ChannelResponseDto> {
|
||||
const channel = await this.prisma.channel.findUnique({
|
||||
where: {
|
||||
id: channelId
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
subscribers: true,
|
||||
blockedUsers: true
|
||||
}
|
||||
},
|
||||
subscribers: {
|
||||
where: { userId },
|
||||
select: { userId: true }
|
||||
},
|
||||
conversations: {
|
||||
include: {
|
||||
inviteLinks: {
|
||||
take: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isOwner = channel!.ownerId == userId
|
||||
let inviteLinkCode = channel!.conversations[0]?.inviteLinks[0]?.code
|
||||
|
||||
if (channel!.channelType === ChannelType.PRIVATE && !inviteLinkCode && isOwner) {
|
||||
const newLink = await this.inviteLinksService.create(userId, { channelId: channelId.toString() })
|
||||
inviteLinkCode = newLink.code
|
||||
}
|
||||
|
||||
const inviteLink = inviteLinkCode ? `https://${this.config.get("SHORT_URL_DOMAIN")}/+${inviteLinkCode}` : null
|
||||
|
||||
return plainToInstance(ChannelResponseDto, {
|
||||
...channel,
|
||||
id: channel!.id.toString(),
|
||||
isSubscribed: channel!.subscribers.length > 0,
|
||||
isOwner,
|
||||
subscribers: channel!._count.subscribers.toString(),
|
||||
removedUser: channel!._count.blockedUsers.toString(),
|
||||
inviteLink
|
||||
})
|
||||
}
|
||||
|
||||
async getSubscribers(channelId: ChannelId, skip: number, take: number, search?: string): Promise<UserResponseDto[]> {
|
||||
const where: Prisma.ChannelSubscriberWhereInput = {
|
||||
channelId,
|
||||
user: search ? {
|
||||
OR: [
|
||||
{ firstName: { contains: search } },
|
||||
{ lastName: { contains: search } },
|
||||
{ username: { contains: search } }
|
||||
]
|
||||
} : undefined
|
||||
}
|
||||
|
||||
const subscribers = await this.prisma.channelSubscriber.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
user: true
|
||||
},
|
||||
orderBy: {
|
||||
user: {
|
||||
firstName: 'asc'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return plainToInstance(UserResponseDto, subscribers.map(s => s.user))
|
||||
}
|
||||
|
||||
async delete(id: ChannelId, userId: UserId): Promise<void> {
|
||||
const channel = await this.prisma.channel.findUnique({ where: { id } })
|
||||
if (!channel) throw new NotFoundException('Channel not found')
|
||||
if (channel.ownerId !== userId) throw new ForbiddenException('Only owner can delete channel')
|
||||
|
||||
await this.prisma.channel.delete({ where: { id: id } })
|
||||
}
|
||||
|
||||
async isExists(id: ChannelId): Promise<boolean> {
|
||||
return await this.prisma.channel.count({ where: { id } }) > 0
|
||||
}
|
||||
|
||||
async isOwner(channelId: ChannelId, userId: UserId): Promise<boolean> {
|
||||
const count = await this.prisma.channel.count({
|
||||
where: { id: channelId, ownerId: userId }
|
||||
})
|
||||
|
||||
return count > 0
|
||||
}
|
||||
}
|
||||
45
messenger-server/src/modules/channels/dto/channel.dto.ts
Normal file
45
messenger-server/src/modules/channels/dto/channel.dto.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Exclude, Expose } from 'class-transformer'
|
||||
import { ChannelType } from 'generated/prisma/enums'
|
||||
import { OmitNull } from 'src/common/decorators/omit-null.decorator'
|
||||
|
||||
@Exclude()
|
||||
export class ChannelResponseDto {
|
||||
@Expose()
|
||||
id: string
|
||||
|
||||
@Expose()
|
||||
name: string
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
username?: string
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
bio?: string
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
ownerId?: string
|
||||
|
||||
@Expose()
|
||||
channelType: ChannelType
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
subscribers: string
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
removedUser?: string
|
||||
|
||||
@Expose()
|
||||
isSubscribed: boolean
|
||||
|
||||
@Expose()
|
||||
isOwner?: boolean
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
inviteLink?: string
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { IsEnum, IsOptional, IsString, Matches, MaxLength, MinLength, ValidateIf } from 'class-validator'
|
||||
import { ChannelType } from '../../../../generated/prisma/client'
|
||||
import { Trim } from 'src/common/decorators/trim.decorator'
|
||||
|
||||
export class CreateChannelDto {
|
||||
@IsString()
|
||||
@Trim()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Trim()
|
||||
@MaxLength(100)
|
||||
bio?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ChannelType)
|
||||
channelType: ChannelType
|
||||
|
||||
@ValidateIf((o) => o.channelType === ChannelType.PUBLIC)
|
||||
@IsString()
|
||||
@Trim()
|
||||
@MinLength(3, { message: 'Минимальная длинна 3 символа' })
|
||||
@MaxLength(32)
|
||||
@Matches(/^[a-zA-Z0-9_]+$/, { message: 'Username can only contain letters, numbers and underscores' })
|
||||
username?: string
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { CreateChannelDto } from './create-channel.dto'
|
||||
import { PartialType } from '@nestjs/mapped-types'
|
||||
|
||||
export class UpdateChannelDto extends PartialType(CreateChannelDto) { }
|
||||
60
messenger-server/src/modules/chats/chats.controller.ts
Normal file
60
messenger-server/src/modules/chats/chats.controller.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common'
|
||||
import { ChatsService } from './chats.service'
|
||||
import { CurrentUserId } from 'src/common/decorators/user-id.decorator'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard'
|
||||
import { InviteLinksService } from './invite-links.service'
|
||||
import { CreateInviteLinkDto } from './dto/create-invite-link.dto'
|
||||
import { plainToInstance } from 'class-transformer'
|
||||
import { InviteLinkResponseDto } from './dto/invite-link-response.dto'
|
||||
import { ChatId } from 'src/common/types/chat-id.type'
|
||||
import { CanReadChatGuard } from 'src/common/guards/can-read-chat.guard'
|
||||
|
||||
@Controller('chats')
|
||||
@UseGuards(AuthGuard)
|
||||
export class ChatsController {
|
||||
constructor(
|
||||
private readonly chatsService: ChatsService,
|
||||
private readonly inviteLinksService: InviteLinksService
|
||||
) { }
|
||||
|
||||
@Get()
|
||||
getAll(@CurrentUserId() userId: UserId) {
|
||||
return this.chatsService.getAll(userId)
|
||||
}
|
||||
|
||||
@Get(':chatId')
|
||||
@UseGuards(CanReadChatGuard)
|
||||
getById(
|
||||
@CurrentUserId() userId: UserId,
|
||||
@Param('chatId') chatId: string
|
||||
) {
|
||||
return this.chatsService.getById(userId, ChatId(chatId))
|
||||
}
|
||||
|
||||
@Delete(':chatId')
|
||||
async deleteChat(
|
||||
@CurrentUserId() userId: UserId,
|
||||
@Param('chatId') chatId: string,
|
||||
@Query('deleteForReceiver') deleteForReceiver?: string
|
||||
) {
|
||||
return this.chatsService.deleteForUser(userId, ChatId(chatId), deleteForReceiver === 'true')
|
||||
}
|
||||
|
||||
@Post('invite-links')
|
||||
async createInviteLink(
|
||||
@CurrentUserId() userId: UserId,
|
||||
@Body() dto: CreateInviteLinkDto
|
||||
) {
|
||||
const link = await this.inviteLinksService.create(userId, dto)
|
||||
return plainToInstance(InviteLinkResponseDto, link)
|
||||
}
|
||||
|
||||
@Get('join/:code')
|
||||
joinViaLink(
|
||||
@CurrentUserId() userId: UserId,
|
||||
@Param('code') code: string
|
||||
) {
|
||||
return this.inviteLinksService.join(userId, code)
|
||||
}
|
||||
}
|
||||
14
messenger-server/src/modules/chats/chats.module.ts
Normal file
14
messenger-server/src/modules/chats/chats.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module, forwardRef } from '@nestjs/common'
|
||||
import { ChatsService } from './chats.service'
|
||||
import { ChatsController } from './chats.controller'
|
||||
import { JwtAuthModule } from '../security/jwt.module'
|
||||
import { SessionsModule } from '../sessions/sessions.module'
|
||||
import { InviteLinksService } from './invite-links.service'
|
||||
|
||||
@Module({
|
||||
imports: [JwtAuthModule, forwardRef(() => SessionsModule)],
|
||||
controllers: [ChatsController],
|
||||
providers: [ChatsService, InviteLinksService],
|
||||
exports: [ChatsService, InviteLinksService]
|
||||
})
|
||||
export class ChatsModule { }
|
||||
614
messenger-server/src/modules/chats/chats.service.ts
Normal file
614
messenger-server/src/modules/chats/chats.service.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { ChatResponseDto } from './dto/chat-response.dto'
|
||||
import { plainToInstance } from 'class-transformer'
|
||||
import { ChatId } from 'src/common/types/chat-id.type'
|
||||
import { ConversationType, Prisma } from 'generated/prisma/client'
|
||||
import { ChatType } from 'src/common/enums/chat-type.enum'
|
||||
import { detectChatType } from 'src/common/utils/detect-chat-type.util'
|
||||
import { MessageResponseDto } from '../messages/dto/message-response.dto'
|
||||
import { PrismaService } from 'src/providers/prisma/prisma.service'
|
||||
|
||||
@Injectable()
|
||||
export class ChatsService {
|
||||
constructor(private readonly prisma: PrismaService) { }
|
||||
|
||||
async deleteForUser(userId: UserId, chatId: ChatId, deleteForAll: boolean = false): Promise<void> {
|
||||
const conversation = await this.findConversationByChatId(chatId, userId)
|
||||
|
||||
if (deleteForAll && conversation.type === ConversationType.DIRECT) {
|
||||
await this.prisma.chat.deleteMany({
|
||||
where: { conversationId: conversation.id }
|
||||
})
|
||||
} else {
|
||||
await this.prisma.chat.deleteMany({
|
||||
where: { userId, conversationId: conversation.id }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async getAll(userId: UserId): Promise<ChatResponseDto[]> {
|
||||
// Optimized: Include members in the initial query to avoid N+1 for direct chats
|
||||
const chats = await this.prisma.chat.findMany({
|
||||
where: { userId: userId },
|
||||
include: {
|
||||
conversation: {
|
||||
include: {
|
||||
group: true,
|
||||
channel: true,
|
||||
members: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (chats.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Batch fetch last messages for all conversations in parallel
|
||||
const conversationIds = chats.map(c => c.conversationId)
|
||||
const lastMessages = await this.prisma.message.findMany({
|
||||
where: { conversationId: { in: conversationIds } },
|
||||
orderBy: { sendTime: 'desc' },
|
||||
distinct: ['conversationId'],
|
||||
select: {
|
||||
id: true,
|
||||
text: true,
|
||||
sendTime: true,
|
||||
senderId: true,
|
||||
conversationId: true,
|
||||
files: true
|
||||
}
|
||||
})
|
||||
|
||||
// Create map for O(1) lookup
|
||||
const lastMessagesMap = new Map(lastMessages.map(m => [m.conversationId, m]))
|
||||
|
||||
const chatsWithTitle = chats.map(chat => {
|
||||
let title = ""
|
||||
let chatId: bigint | null = null
|
||||
|
||||
switch (chat.conversation.type) {
|
||||
case ConversationType.DIRECT: {
|
||||
const otherMember = chat.conversation.members.find(m => m.userId !== userId)
|
||||
if (otherMember) {
|
||||
chatId = otherMember.userId
|
||||
title = `${otherMember.user.firstName ?? ""} ${otherMember.user.lastName ?? ""}`.trim()
|
||||
} else {
|
||||
chatId = userId
|
||||
title = "Saved messages"
|
||||
}
|
||||
break
|
||||
}
|
||||
case ConversationType.CHANNEL: {
|
||||
if (chat.conversation.channel) {
|
||||
chatId = chat.conversation.channelId
|
||||
title = chat.conversation.channel.name
|
||||
}
|
||||
break
|
||||
}
|
||||
case ConversationType.GROUP: {
|
||||
if (chat.conversation.group) {
|
||||
chatId = chat.conversation.groupId
|
||||
title = chat.conversation.group.name
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const lastMessage = lastMessagesMap.get(chat.conversationId)
|
||||
return {
|
||||
id: chatId,
|
||||
name: title,
|
||||
isPinned: chat.isPinned,
|
||||
lastMessage: lastMessage ? {
|
||||
...lastMessage,
|
||||
chatId: chatId?.toString() || '',
|
||||
isRead: true,
|
||||
files: lastMessage.files?.map(f => ({ ...f, size: f.size.toString() })) || []
|
||||
} : undefined
|
||||
}
|
||||
})
|
||||
|
||||
return plainToInstance(ChatResponseDto, chatsWithTitle)
|
||||
}
|
||||
|
||||
async create(tx: Prisma.TransactionClient, userId: UserId, conversationId: number): Promise<ChatResponseDto> {
|
||||
const chat = await tx.chat.upsert({
|
||||
where: {
|
||||
userId_conversationId: { userId, conversationId }
|
||||
},
|
||||
update: {},
|
||||
create: { userId, conversationId }
|
||||
})
|
||||
|
||||
return plainToInstance(ChatResponseDto, chat)
|
||||
}
|
||||
|
||||
async resolveConversation(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: UserId,
|
||||
chatId: ChatId
|
||||
): Promise<{ conversationId: number; conversationType: ConversationType; ownerId: bigint; chatType: ChatType }> {
|
||||
const chatType = detectChatType(chatId)
|
||||
const now = Date.now()
|
||||
|
||||
if (chatType === ChatType.PRIVATE) {
|
||||
const existing = await tx.conversation.findFirst({
|
||||
where: {
|
||||
type: ConversationType.DIRECT,
|
||||
members: {
|
||||
some: { userId: userId },
|
||||
},
|
||||
AND: [
|
||||
{ members: { some: { userId: chatId as bigint } } },
|
||||
{ members: { none: { userId: { notIn: [userId, chatId as bigint] } } } }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
return {
|
||||
conversationId: existing.id,
|
||||
conversationType: existing.type,
|
||||
ownerId: chatId,
|
||||
chatType
|
||||
}
|
||||
}
|
||||
|
||||
if (userId === (chatId as bigint)) {
|
||||
const selfConversation = await tx.conversation.create({
|
||||
data: {
|
||||
type: ConversationType.DIRECT,
|
||||
createdAt: now,
|
||||
members: {
|
||||
create: {
|
||||
userId: userId,
|
||||
joinedAt: now,
|
||||
role: 'MEMBER'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
conversationId: selfConversation.id,
|
||||
conversationType: selfConversation.type,
|
||||
ownerId: chatId,
|
||||
chatType
|
||||
}
|
||||
}
|
||||
|
||||
const created = await tx.conversation.create({
|
||||
data: {
|
||||
type: ConversationType.DIRECT,
|
||||
createdAt: now,
|
||||
members: {
|
||||
createMany: {
|
||||
data: [
|
||||
{ userId: userId, joinedAt: now, role: 'MEMBER' },
|
||||
{ userId: chatId, joinedAt: now, role: 'MEMBER' }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
conversationId: created.id,
|
||||
conversationType: created.type,
|
||||
ownerId: chatId,
|
||||
chatType
|
||||
}
|
||||
}
|
||||
|
||||
if (chatType === ChatType.GROUP) {
|
||||
let conversation = await tx.conversation.findUnique({
|
||||
where: { groupId: chatId },
|
||||
include: { group: { select: { ownerId: true } } }
|
||||
})
|
||||
|
||||
if (!conversation) {
|
||||
const group = await tx.group.findUnique({ where: { id: chatId } })
|
||||
if (!group) throw new NotFoundException('Group not found')
|
||||
|
||||
conversation = await tx.conversation.create({
|
||||
data: {
|
||||
type: ConversationType.GROUP,
|
||||
groupId: chatId,
|
||||
createdAt: now
|
||||
},
|
||||
include: { group: { select: { ownerId: true } } }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
conversationType: conversation.type,
|
||||
ownerId: conversation.group?.ownerId || BigInt(0),
|
||||
chatType
|
||||
}
|
||||
}
|
||||
|
||||
if (chatType === ChatType.CHANNEL) {
|
||||
let conversation = await tx.conversation.findUnique({
|
||||
where: { channelId: chatId },
|
||||
include: { channel: { select: { ownerId: true } } }
|
||||
})
|
||||
|
||||
if (!conversation) {
|
||||
const channel = await tx.channel.findUnique({ where: { id: chatId } })
|
||||
if (!channel) throw new NotFoundException('Channel not found')
|
||||
|
||||
conversation = await tx.conversation.create({
|
||||
data: {
|
||||
type: ConversationType.CHANNEL,
|
||||
channelId: chatId,
|
||||
createdAt: now
|
||||
},
|
||||
include: { channel: { select: { ownerId: true } } }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
conversationId: conversation.id,
|
||||
conversationType: conversation.type,
|
||||
ownerId: conversation.channel?.ownerId || BigInt(0),
|
||||
chatType
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Unsupported chat type')
|
||||
}
|
||||
|
||||
async findConversationByChatId(chatId: ChatId, userId: UserId) {
|
||||
const chatType = detectChatType(chatId)
|
||||
|
||||
if (chatType === ChatType.PRIVATE) {
|
||||
const conversation = await this.prisma.conversation.findFirst({
|
||||
where: {
|
||||
type: ConversationType.DIRECT,
|
||||
members: {
|
||||
some: { userId: userId },
|
||||
},
|
||||
AND: [
|
||||
{ members: { some: { userId: chatId as bigint } } },
|
||||
{ members: { none: { userId: { notIn: [userId, chatId as bigint] } } } }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
if (!conversation) {
|
||||
const target = await this.prisma.user.findUnique({ where: { id: chatId as bigint } })
|
||||
if (!target) throw new NotFoundException('User not found')
|
||||
const resolved = await this.prisma.$transaction(async tx => {
|
||||
const ctx = await this.resolveConversation(tx, userId, chatId)
|
||||
await Promise.all([
|
||||
this.create(tx, userId, ctx.conversationId),
|
||||
this.create(tx, chatId as unknown as UserId, ctx.conversationId)
|
||||
])
|
||||
return ctx
|
||||
})
|
||||
return this.prisma.conversation.findUniqueOrThrow({ where: { id: resolved.conversationId } })
|
||||
}
|
||||
|
||||
return conversation
|
||||
}
|
||||
|
||||
if (chatType === ChatType.GROUP) {
|
||||
const conversation = await this.prisma.conversation.findUnique({
|
||||
where: { groupId: chatId }
|
||||
})
|
||||
|
||||
if (!conversation) {
|
||||
throw new NotFoundException('Conversation not found')
|
||||
}
|
||||
|
||||
return conversation
|
||||
}
|
||||
|
||||
if (chatType === ChatType.CHANNEL) {
|
||||
const conversation = await this.prisma.conversation.findUnique({
|
||||
where: { channelId: chatId }
|
||||
})
|
||||
|
||||
if (!conversation) {
|
||||
throw new NotFoundException('Conversation not found')
|
||||
}
|
||||
|
||||
return conversation
|
||||
}
|
||||
|
||||
throw new NotFoundException('Unsupported chat type')
|
||||
}
|
||||
|
||||
async canReadChat(userId: UserId, chatId: ChatId): Promise<boolean> {
|
||||
const chatType = detectChatType(chatId)
|
||||
|
||||
if (chatType === ChatType.PRIVATE) {
|
||||
const conversation = await this.prisma.conversation.findFirst({
|
||||
where: {
|
||||
type: ConversationType.DIRECT,
|
||||
members: {
|
||||
some: { userId: userId },
|
||||
},
|
||||
AND: [
|
||||
{ members: { some: { userId: chatId as bigint } } },
|
||||
{ members: { none: { userId: { notIn: [userId, chatId as bigint] } } } }
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
if (!conversation) {
|
||||
const target = await this.prisma.user.findUnique({ where: { id: chatId as bigint } })
|
||||
if (!target) throw new ForbiddenException('User not found')
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (chatType === ChatType.GROUP) {
|
||||
const member = await this.prisma.groupMember.findFirst({
|
||||
where: {
|
||||
groupId: chatId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
throw new ForbiddenException('User is not a group member')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (chatType === ChatType.CHANNEL) {
|
||||
const channel = await this.prisma.channel.findUnique({
|
||||
where: { id: chatId },
|
||||
select: { channelType: true, ownerId: true }
|
||||
})
|
||||
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found')
|
||||
}
|
||||
|
||||
if (channel.channelType === 'PUBLIC') {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscriber = await this.prisma.channelSubscriber.findFirst({
|
||||
where: {
|
||||
channelId: chatId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
if (subscriber) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (channel.ownerId !== userId) {
|
||||
throw new ForbiddenException('User is not a channel member')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Unsupported chat type')
|
||||
}
|
||||
|
||||
async canReadMessage(userId: UserId, messageId: number, chatId?: ChatId): Promise<boolean> {
|
||||
const message = await this.prisma.message.findUnique({
|
||||
where: { id: messageId },
|
||||
include: { conversation: true }
|
||||
})
|
||||
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found')
|
||||
}
|
||||
|
||||
const conversation = message.conversation
|
||||
|
||||
if (chatId) {
|
||||
const chatType = detectChatType(chatId)
|
||||
let matches = false
|
||||
|
||||
if (chatType === ChatType.PRIVATE) {
|
||||
const otherMember = await this.prisma.conversationMember.findFirst({
|
||||
where: {
|
||||
conversationId: conversation.id,
|
||||
userId: { not: userId }
|
||||
}
|
||||
})
|
||||
const otherUserId = otherMember?.userId ?? userId
|
||||
matches = otherUserId === ChatId(chatId)
|
||||
} else if (chatType === ChatType.GROUP) {
|
||||
matches = conversation.groupId === (chatId as bigint)
|
||||
} else if (chatType === ChatType.CHANNEL) {
|
||||
matches = conversation.channelId === (chatId as bigint)
|
||||
}
|
||||
|
||||
if (!matches) {
|
||||
throw new ForbiddenException('Message does not belong to the specified chat')
|
||||
}
|
||||
}
|
||||
|
||||
if (conversation.type === ConversationType.DIRECT) {
|
||||
const member = await this.prisma.conversationMember.findFirst({
|
||||
where: {
|
||||
conversationId: conversation.id,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
throw new ForbiddenException('User is not a chat participant')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (conversation.type === ConversationType.GROUP) {
|
||||
const member = await this.prisma.groupMember.findFirst({
|
||||
where: {
|
||||
groupId: conversation.groupId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
if (!member) {
|
||||
throw new ForbiddenException('User is not a group member')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (conversation.type === ConversationType.CHANNEL) {
|
||||
if (!conversation.channelId) {
|
||||
throw new NotFoundException('Channel not found')
|
||||
}
|
||||
|
||||
const channel = await this.prisma.channel.findUnique({
|
||||
where: { id: conversation.channelId },
|
||||
select: { channelType: true, ownerId: true }
|
||||
})
|
||||
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found')
|
||||
}
|
||||
|
||||
if (channel.channelType === 'PUBLIC') {
|
||||
return true
|
||||
}
|
||||
|
||||
const subscriber = await this.prisma.channelSubscriber.findFirst({
|
||||
where: {
|
||||
channelId: conversation.channelId,
|
||||
userId
|
||||
}
|
||||
})
|
||||
|
||||
if (subscriber) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (channel.ownerId !== userId) {
|
||||
throw new ForbiddenException('User is not a channel member')
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
throw new ForbiddenException('Unsupported chat type')
|
||||
}
|
||||
|
||||
async getById(userId: UserId, chatId: ChatId): Promise<ChatResponseDto> {
|
||||
const conversation = await this.findConversationByChatId(chatId, userId)
|
||||
|
||||
const chat = await this.prisma.chat.findUnique({
|
||||
where: {
|
||||
userId_conversationId: {
|
||||
userId: userId,
|
||||
conversationId: conversation.id
|
||||
}
|
||||
},
|
||||
include: {
|
||||
conversation: {
|
||||
include: {
|
||||
group: true,
|
||||
channel: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (!chat) {
|
||||
throw new NotFoundException('Chat not found')
|
||||
}
|
||||
|
||||
let title = ""
|
||||
let resolvedChatId: bigint | null = null
|
||||
|
||||
switch (chat.conversation.type) {
|
||||
case ConversationType.DIRECT: {
|
||||
const otherMember = await this.prisma.conversationMember.findFirst({
|
||||
where: {
|
||||
conversationId: chat.conversationId,
|
||||
userId: { not: userId }
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (otherMember) {
|
||||
resolvedChatId = otherMember.userId
|
||||
title = `${otherMember.user.firstName ?? ""} ${otherMember.user.lastName ?? ""}`.trim()
|
||||
} else {
|
||||
resolvedChatId = userId
|
||||
title = "Saved messages"
|
||||
}
|
||||
break
|
||||
}
|
||||
case ConversationType.CHANNEL: {
|
||||
if (chat.conversation.channel) {
|
||||
resolvedChatId = chat.conversation.channelId
|
||||
title = chat.conversation.channel.name
|
||||
}
|
||||
break
|
||||
}
|
||||
case ConversationType.GROUP: {
|
||||
if (chat.conversation.group) {
|
||||
resolvedChatId = chat.conversation.groupId
|
||||
title = chat.conversation.group.name
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const lastMessage = await this.getLastMessage(chat.conversationId)
|
||||
if (lastMessage && resolvedChatId) {
|
||||
lastMessage.chatId = resolvedChatId.toString()
|
||||
}
|
||||
|
||||
return plainToInstance(ChatResponseDto, {
|
||||
id: resolvedChatId,
|
||||
name: title,
|
||||
isPinned: chat.isPinned,
|
||||
lastMessage: lastMessage
|
||||
})
|
||||
}
|
||||
|
||||
private async getLastMessage(conversationId: number): Promise<MessageResponseDto | null> {
|
||||
const message = await this.prisma.message.findFirst({
|
||||
where: { conversationId },
|
||||
orderBy: { sendTime: 'desc' },
|
||||
include: { files: true }
|
||||
})
|
||||
|
||||
if (!message) return null
|
||||
|
||||
return plainToInstance(MessageResponseDto, {
|
||||
...message,
|
||||
files: message.files.map(f => ({ ...f, size: f.size.toString() }))
|
||||
})
|
||||
}
|
||||
}
|
||||
19
messenger-server/src/modules/chats/dto/chat-response.dto.ts
Normal file
19
messenger-server/src/modules/chats/dto/chat-response.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Exclude, Expose } from "class-transformer"
|
||||
import { OmitNull } from "src/common/decorators/omit-null.decorator"
|
||||
import { MessageResponseDto } from "src/modules/messages/dto/message-response.dto"
|
||||
|
||||
@Exclude()
|
||||
export class ChatResponseDto {
|
||||
@Expose()
|
||||
id: number
|
||||
|
||||
@Expose()
|
||||
name: string
|
||||
|
||||
@Expose()
|
||||
isPinned: string
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
lastMessage?: MessageResponseDto
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { IsOptional, IsString } from 'class-validator'
|
||||
|
||||
export class CreateInviteLinkDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
channelId?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
groupId?: string
|
||||
|
||||
@IsOptional()
|
||||
maxUses?: number
|
||||
|
||||
@IsOptional()
|
||||
expiresInSeconds?: number
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Exclude, Expose, Transform } from 'class-transformer'
|
||||
import { OmitNull } from 'src/common/decorators/omit-null.decorator'
|
||||
|
||||
@Exclude()
|
||||
export class InviteLinkResponseDto {
|
||||
@Expose()
|
||||
@Transform(({ value }) => value.toString())
|
||||
id: string
|
||||
|
||||
@Expose()
|
||||
chatId: string
|
||||
|
||||
@Expose()
|
||||
code: string
|
||||
|
||||
@Expose()
|
||||
link: string
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
@Transform(({ value }) => value?.toString())
|
||||
expiresAt?: string
|
||||
|
||||
@Expose()
|
||||
isPermanent: boolean
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
maxUses?: number
|
||||
|
||||
@Expose()
|
||||
uses: number
|
||||
}
|
||||
162
messenger-server/src/modules/chats/invite-links.service.ts
Normal file
162
messenger-server/src/modules/chats/invite-links.service.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { PrismaService } from 'src/providers/prisma/prisma.service'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { CreateInviteLinkDto } from './dto/create-invite-link.dto'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { generateInviteLinkId } from 'src/common/utils/id-generator.util'
|
||||
import { ConversationType } from 'generated/prisma/client'
|
||||
import { ChatsService } from './chats.service'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
|
||||
export interface InternalInviteLinkResponse {
|
||||
id: bigint
|
||||
chatId: string
|
||||
code: string
|
||||
link: string
|
||||
expiresAt: bigint | null
|
||||
isPermanent: boolean
|
||||
maxUses: number | null
|
||||
uses: number
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InviteLinksService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly chatsService: ChatsService
|
||||
) { }
|
||||
|
||||
async create(creatorId: UserId, dto: CreateInviteLinkDto): Promise<InternalInviteLinkResponse> {
|
||||
const conversation = await this.prisma.conversation.findUnique({
|
||||
where: dto.channelId
|
||||
? { channelId: BigInt(dto.channelId) }
|
||||
: { groupId: BigInt(dto.groupId!) }
|
||||
})
|
||||
|
||||
if (!conversation) {
|
||||
throw new NotFoundException(`${dto.channelId ? 'Channel' : 'Group'} conversation not found`)
|
||||
}
|
||||
|
||||
const conversationId = conversation.id
|
||||
|
||||
await this.prisma.inviteLink.deleteMany({
|
||||
where: { conversationId }
|
||||
})
|
||||
|
||||
const id = generateInviteLinkId()
|
||||
const code = randomBytes(8).toString('hex')
|
||||
|
||||
const expiresAt = dto.expiresInSeconds
|
||||
? BigInt(Date.now() + dto.expiresInSeconds * 1000)
|
||||
: null
|
||||
|
||||
const link = await this.prisma.inviteLink.create({
|
||||
data: {
|
||||
id,
|
||||
code,
|
||||
conversationId,
|
||||
creatorId,
|
||||
maxUses: dto.maxUses,
|
||||
expiresAt
|
||||
}
|
||||
})
|
||||
|
||||
const chatId = (conversation.channelId || conversation.groupId)?.toString() || ""
|
||||
|
||||
return {
|
||||
id: link.id,
|
||||
chatId,
|
||||
code: link.code,
|
||||
link: `https://${this.config.get("SHORT_URL_DOMAIN")}/+${link.code}`,
|
||||
expiresAt: link.expiresAt,
|
||||
isPermanent: link.expiresAt === null && (link.maxUses === null || link.maxUses === 0),
|
||||
maxUses: link.maxUses,
|
||||
uses: link.uses
|
||||
}
|
||||
}
|
||||
|
||||
async join(userId: UserId, code: string) {
|
||||
const link = await this.prisma.inviteLink.findUnique({
|
||||
where: { code },
|
||||
include: { conversation: true }
|
||||
})
|
||||
|
||||
if (!link) {
|
||||
throw new NotFoundException('Invite link not found or expired')
|
||||
}
|
||||
|
||||
if (link.expiresAt && link.expiresAt < BigInt(Date.now())) {
|
||||
await this.prisma.inviteLink.delete({ where: { id: link.id } })
|
||||
throw new BadRequestException('Invite link expired')
|
||||
}
|
||||
|
||||
if (link.maxUses && link.uses >= link.maxUses) {
|
||||
await this.prisma.inviteLink.delete({ where: { id: link.id } })
|
||||
throw new BadRequestException('Invite link limit reached')
|
||||
}
|
||||
|
||||
const { conversation } = link
|
||||
|
||||
if (conversation.type === ConversationType.GROUP && conversation.groupId) {
|
||||
const isBanned = await this.prisma.groupBlackList.count({
|
||||
where: { groupId: conversation.groupId, userId }
|
||||
})
|
||||
if (isBanned) throw new BadRequestException('You are banned from this group')
|
||||
} else if (conversation.type === ConversationType.CHANNEL && conversation.channelId) {
|
||||
const isBanned = await this.prisma.channelBlackList.count({
|
||||
where: { channelId: conversation.channelId, userId }
|
||||
})
|
||||
if (isBanned) throw new BadRequestException('You are banned from this channel')
|
||||
}
|
||||
|
||||
const existingMember = await this.prisma.conversationMember.findUnique({
|
||||
where: { conversationId_userId: { conversationId: conversation.id, userId } }
|
||||
})
|
||||
|
||||
if (existingMember) return conversation
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
if (conversation.type === ConversationType.GROUP && conversation.groupId) {
|
||||
await tx.groupMember.create({
|
||||
data: { groupId: conversation.groupId, userId }
|
||||
})
|
||||
} else if (conversation.type === ConversationType.CHANNEL && conversation.channelId) {
|
||||
await tx.channelSubscriber.create({
|
||||
data: { channelId: conversation.channelId, userId }
|
||||
})
|
||||
}
|
||||
|
||||
await tx.conversationMember.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
userId,
|
||||
joinedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
await this.chatsService.create(tx, userId, conversation.id)
|
||||
|
||||
const updatedLink = await tx.inviteLink.update({
|
||||
where: { id: link.id },
|
||||
data: { uses: { increment: 1 } }
|
||||
})
|
||||
|
||||
if (updatedLink.maxUses && updatedLink.uses >= updatedLink.maxUses) {
|
||||
await tx.inviteLink.delete({ where: { id: link.id } })
|
||||
}
|
||||
})
|
||||
|
||||
return conversation
|
||||
}
|
||||
|
||||
async getByConversation(conversationId: number) {
|
||||
return await this.prisma.inviteLink.findMany({
|
||||
where: { conversationId }
|
||||
})
|
||||
}
|
||||
|
||||
async delete(id: bigint) {
|
||||
await this.prisma.inviteLink.delete({ where: { id } })
|
||||
}
|
||||
}
|
||||
29
messenger-server/src/modules/groups/dto/create-group.dto.ts
Normal file
29
messenger-server/src/modules/groups/dto/create-group.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { IsEnum, IsOptional, IsString, Matches, MaxLength, MinLength } from 'class-validator'
|
||||
import { Trim } from 'src/common/decorators/trim.decorator'
|
||||
import { GroupType } from 'generated/prisma/client'
|
||||
|
||||
export class CreateGroupDto {
|
||||
@IsString()
|
||||
@Trim()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Trim()
|
||||
@MaxLength(100)
|
||||
bio?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Trim()
|
||||
@MinLength(3)
|
||||
@MaxLength(32)
|
||||
@Matches(/^[a-zA-Z0-9_]+$/, { message: 'Username can only contain letters, numbers and underscores' })
|
||||
username?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(GroupType)
|
||||
groupType?: GroupType
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Exclude, Expose } from 'class-transformer'
|
||||
import { OmitNull } from 'src/common/decorators/omit-null.decorator'
|
||||
import { GroupType } from 'generated/prisma/client'
|
||||
|
||||
@Exclude()
|
||||
export class GroupResponseDto {
|
||||
@Expose()
|
||||
id: string
|
||||
|
||||
@Expose()
|
||||
name: string
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
username?: string
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
bio?: string
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
groupType?: GroupType
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
ownerId?: string
|
||||
|
||||
@Expose()
|
||||
@OmitNull()
|
||||
membersCount?: number
|
||||
|
||||
@Expose()
|
||||
isMember?: boolean
|
||||
|
||||
@Expose()
|
||||
isOwner?: boolean
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { CreateGroupDto } from './create-group.dto'
|
||||
import { PartialType } from '@nestjs/mapped-types'
|
||||
|
||||
export class UpdateGroupDto extends PartialType(CreateGroupDto) { }
|
||||
117
messenger-server/src/modules/groups/groups.controller.ts
Normal file
117
messenger-server/src/modules/groups/groups.controller.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'
|
||||
import { GroupsService } from './groups.service'
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard'
|
||||
import { CreateGroupDto } from './dto/create-group.dto'
|
||||
import { CurrentUserId } from 'src/common/decorators/user-id.decorator'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { GroupId } from 'src/common/types/group-id.type'
|
||||
import { ParseGroupIdPipe } from 'src/common/pipes/parse-group-id.pipe'
|
||||
import { GroupOwnerGuard } from 'src/common/guards/group-owner.guard'
|
||||
import { GroupExistsGuard } from 'src/common/guards/group-exists.guard'
|
||||
import { PARAMS } from 'src/common/constants/param.constants'
|
||||
import { UpdateGroupDto } from './dto/update-group.dto'
|
||||
import { ParseUserIdPipe } from 'src/common/pipes/parse-user-id.pipe'
|
||||
import { CanReadChatGuard } from 'src/common/guards/can-read-chat.guard'
|
||||
|
||||
@Controller('groups')
|
||||
@UseGuards(AuthGuard)
|
||||
export class GroupsController {
|
||||
constructor(private readonly groupsService: GroupsService) { }
|
||||
|
||||
@Post()
|
||||
createGroup(@CurrentUserId() userId: UserId, @Body() dto: CreateGroupDto) {
|
||||
return this.groupsService.create(userId, dto)
|
||||
}
|
||||
|
||||
@Get(`:${PARAMS.GROUP_ID}`)
|
||||
@UseGuards(GroupExistsGuard)
|
||||
getById(
|
||||
@Param(PARAMS.GROUP_ID, ParseGroupIdPipe) id: GroupId,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.groupsService.getById(id, userId)
|
||||
}
|
||||
|
||||
@Get(`:${PARAMS.GROUP_ID}/members`)
|
||||
@UseGuards(GroupExistsGuard, CanReadChatGuard)
|
||||
getMembers(
|
||||
@Param(PARAMS.GROUP_ID, ParseGroupIdPipe) id: GroupId,
|
||||
@Query('skip') skip: number,
|
||||
@Query('take') take: number,
|
||||
@Query('search') search?: string
|
||||
) {
|
||||
return this.groupsService.getMembers(id, Number(skip) || 0, Number(take) || 100, search)
|
||||
}
|
||||
|
||||
@Patch(`:${PARAMS.GROUP_ID}`)
|
||||
@UseGuards(GroupExistsGuard, GroupOwnerGuard)
|
||||
update(
|
||||
@Param(PARAMS.GROUP_ID, ParseGroupIdPipe) id: GroupId,
|
||||
@Body() dto: UpdateGroupDto,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.groupsService.update(id, dto, userId)
|
||||
}
|
||||
|
||||
@HttpCode(204)
|
||||
@Delete(`:${PARAMS.GROUP_ID}`)
|
||||
@UseGuards(GroupExistsGuard, GroupOwnerGuard)
|
||||
delete(
|
||||
@Param(PARAMS.GROUP_ID, ParseGroupIdPipe) id: GroupId,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.groupsService.delete(id, userId)
|
||||
}
|
||||
|
||||
@Post(`:${PARAMS.GROUP_ID}/join`)
|
||||
@UseGuards(GroupExistsGuard)
|
||||
join(@Param(PARAMS.GROUP_ID, ParseGroupIdPipe) id: GroupId, @CurrentUserId() userId: UserId) {
|
||||
return this.groupsService.join(id, userId)
|
||||
}
|
||||
|
||||
@Post(`:${PARAMS.GROUP_ID}/leave`)
|
||||
@UseGuards(GroupExistsGuard)
|
||||
leave(@Param(PARAMS.GROUP_ID, ParseGroupIdPipe) id: GroupId, @CurrentUserId() userId: UserId) {
|
||||
return this.groupsService.leave(id, userId)
|
||||
}
|
||||
|
||||
@Post(`:${PARAMS.GROUP_ID}/members/:userId`)
|
||||
@UseGuards(GroupExistsGuard, GroupOwnerGuard)
|
||||
addMember(
|
||||
@Param(PARAMS.GROUP_ID, ParseGroupIdPipe) id: GroupId,
|
||||
@Param('userId', ParseUserIdPipe) targetUserId: UserId,
|
||||
@CurrentUserId() ownerId: UserId
|
||||
) {
|
||||
return this.groupsService.addMember(id, targetUserId)
|
||||
}
|
||||
|
||||
@Delete(`:${PARAMS.GROUP_ID}/members/:userId`)
|
||||
@UseGuards(GroupExistsGuard, GroupOwnerGuard)
|
||||
removeMember(
|
||||
@Param(PARAMS.GROUP_ID, ParseGroupIdPipe) id: GroupId,
|
||||
@Param('userId', ParseUserIdPipe) targetUserId: UserId,
|
||||
@CurrentUserId() ownerId: UserId
|
||||
) {
|
||||
return this.groupsService.kick(id, ownerId, targetUserId)
|
||||
}
|
||||
|
||||
@Post(`:${PARAMS.GROUP_ID}/kick/:userId`)
|
||||
@UseGuards(GroupExistsGuard, GroupOwnerGuard)
|
||||
kick(
|
||||
@Param(PARAMS.GROUP_ID, ParseGroupIdPipe) id: GroupId,
|
||||
@Param('userId', ParseUserIdPipe) targetUserId: UserId,
|
||||
@CurrentUserId() ownerId: UserId
|
||||
) {
|
||||
return this.groupsService.kick(id, ownerId, targetUserId)
|
||||
}
|
||||
|
||||
@Post(`:${PARAMS.GROUP_ID}/ban/:userId`)
|
||||
@UseGuards(GroupExistsGuard, GroupOwnerGuard)
|
||||
ban(
|
||||
@Param(PARAMS.GROUP_ID, ParseGroupIdPipe) id: GroupId,
|
||||
@Param('userId', ParseUserIdPipe) targetUserId: UserId,
|
||||
@CurrentUserId() ownerId: UserId
|
||||
) {
|
||||
return this.groupsService.ban(id, ownerId, targetUserId)
|
||||
}
|
||||
}
|
||||
14
messenger-server/src/modules/groups/groups.module.ts
Normal file
14
messenger-server/src/modules/groups/groups.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { GroupsService } from './groups.service'
|
||||
import { GroupsController } from './groups.controller'
|
||||
import { JwtAuthModule } from 'src/modules/security/jwt.module'
|
||||
import { SessionsModule } from '../sessions/sessions.module'
|
||||
import { SearchModule } from '../search/search.module'
|
||||
import { ChatsModule } from '../chats/chats.module'
|
||||
|
||||
@Module({
|
||||
imports: [JwtAuthModule, SessionsModule, SearchModule, ChatsModule],
|
||||
controllers: [GroupsController],
|
||||
providers: [GroupsService]
|
||||
})
|
||||
export class GroupsModule { }
|
||||
278
messenger-server/src/modules/groups/groups.service.ts
Normal file
278
messenger-server/src/modules/groups/groups.service.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { plainToInstance } from 'class-transformer'
|
||||
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { GroupId } from 'src/common/types/group-id.type'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { CreateGroupDto } from './dto/create-group.dto'
|
||||
import { generateGroupId } from 'src/common/utils/id-generator.util'
|
||||
import { GroupResponseDto } from './dto/group-response.dto'
|
||||
import { UpdateGroupDto } from './dto/update-group.dto'
|
||||
import { ChatsService } from '../chats/chats.service'
|
||||
import { PrismaService } from 'src/providers/prisma/prisma.service'
|
||||
import { ConversationRole, ConversationType, GroupType, Prisma } from 'generated/prisma/client'
|
||||
import { SearchService } from '../search/search.service'
|
||||
import { UserResponseDto } from '../users/dto/user-response.dto'
|
||||
|
||||
@Injectable()
|
||||
export class GroupsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly chatsService: ChatsService,
|
||||
private readonly searchService: SearchService
|
||||
) { }
|
||||
|
||||
async create(ownerId: UserId, dto: CreateGroupDto): Promise<GroupResponseDto> {
|
||||
if (dto.username) {
|
||||
const isAvailable = await this.searchService.isUsernameAvailable(dto.username)
|
||||
if (!isAvailable) throw new ConflictException('Username is already taken')
|
||||
}
|
||||
|
||||
const groupId = generateGroupId()
|
||||
|
||||
const group = await this.prisma.$transaction(async tx => {
|
||||
const group = await tx.group.create({
|
||||
data: {
|
||||
id: groupId,
|
||||
name: dto.name,
|
||||
username: dto.username,
|
||||
ownerId: ownerId,
|
||||
bio: dto.bio,
|
||||
groupType: dto.groupType || GroupType.PRIVATE
|
||||
}
|
||||
})
|
||||
|
||||
await tx.groupMember.create({
|
||||
data: {
|
||||
userId: ownerId,
|
||||
groupId: group.id
|
||||
}
|
||||
})
|
||||
|
||||
const conversation = await tx.conversation.create({
|
||||
data: {
|
||||
type: ConversationType.GROUP,
|
||||
groupId: group.id,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
await tx.conversationMember.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
userId: ownerId,
|
||||
role: ConversationRole.OWNER,
|
||||
joinedAt: Date.now()
|
||||
}
|
||||
})
|
||||
|
||||
await this.chatsService.create(tx, ownerId, conversation.id)
|
||||
|
||||
return group
|
||||
})
|
||||
|
||||
return plainToInstance(GroupResponseDto, { ...group, isMember: true, isOwner: true, membersCount: 1 })
|
||||
}
|
||||
|
||||
async update(id: GroupId, dto: UpdateGroupDto, userId: UserId): Promise<GroupResponseDto> {
|
||||
const existingGroup = await this.prisma.group.findUnique({ where: { id } })
|
||||
|
||||
if (dto.username && dto.username !== existingGroup!.username) {
|
||||
const isAvailable = await this.searchService.isUsernameAvailable(dto.username)
|
||||
if (!isAvailable) throw new ConflictException('Username is already taken')
|
||||
}
|
||||
|
||||
const group = await this.prisma.group.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: dto.name,
|
||||
bio: dto.bio,
|
||||
username: dto.username,
|
||||
groupType: dto.groupType
|
||||
}
|
||||
})
|
||||
return plainToInstance(GroupResponseDto, group)
|
||||
}
|
||||
|
||||
async join(id: GroupId, userId: UserId): Promise<void> {
|
||||
const group = await this.prisma.group.findUnique({
|
||||
where: { id },
|
||||
include: { conversations: true }
|
||||
})
|
||||
|
||||
if (group!.groupType !== GroupType.PUBLIC) {
|
||||
throw new BadRequestException('This group is private. Use invite link to join.')
|
||||
}
|
||||
|
||||
const isBanned = await this.prisma.groupBlackList.count({
|
||||
where: { groupId: id, userId }
|
||||
})
|
||||
if (isBanned) throw new BadRequestException('You are banned from this group')
|
||||
|
||||
const conversation = group!.conversations[0]
|
||||
if (!conversation) throw new NotFoundException('Group conversation not found')
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
const existingMember = await tx.groupMember.findUnique({
|
||||
where: { groupId_userId: { groupId: id, userId } }
|
||||
})
|
||||
if (existingMember) return
|
||||
|
||||
await tx.groupMember.create({ data: { groupId: id, userId } })
|
||||
await tx.conversationMember.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
userId,
|
||||
joinedAt: Date.now()
|
||||
}
|
||||
})
|
||||
await this.chatsService.create(tx, userId, conversation.id)
|
||||
})
|
||||
}
|
||||
|
||||
async leave(id: GroupId, userId: UserId): Promise<void> {
|
||||
const group = await this.prisma.group.findUnique({
|
||||
where: { id },
|
||||
include: { conversations: true }
|
||||
})
|
||||
if (group!.ownerId === userId) throw new BadRequestException('Owner cannot leave group. Delete it instead.')
|
||||
|
||||
const conversation = group!.conversations[0]
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
await tx.groupMember.delete({
|
||||
where: { groupId_userId: { groupId: id, userId } }
|
||||
}).catch(() => { })
|
||||
|
||||
if (conversation) {
|
||||
await tx.conversationMember.delete({
|
||||
where: { conversationId_userId: { conversationId: conversation.id, userId } }
|
||||
}).catch(() => { })
|
||||
|
||||
await tx.chat.deleteMany({
|
||||
where: { userId, conversationId: conversation.id }
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async addMember(id: GroupId, targetUserId: UserId): Promise<void> {
|
||||
const group = await this.prisma.group.findUnique({
|
||||
where: { id },
|
||||
include: { conversations: true }
|
||||
})
|
||||
if (!group) throw new NotFoundException('Group not found')
|
||||
|
||||
const isBanned = await this.prisma.groupBlackList.count({
|
||||
where: { groupId: id, userId: targetUserId }
|
||||
})
|
||||
if (isBanned) throw new BadRequestException('User is banned from this group')
|
||||
|
||||
const conversation = group.conversations[0]
|
||||
if (!conversation) throw new NotFoundException('Group conversation not found')
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
const existingMember = await tx.groupMember.findUnique({
|
||||
where: { groupId_userId: { groupId: id, userId: targetUserId } }
|
||||
})
|
||||
if (existingMember) return
|
||||
|
||||
await tx.groupMember.create({ data: { groupId: id, userId: targetUserId } })
|
||||
await tx.conversationMember.create({
|
||||
data: {
|
||||
conversationId: conversation.id,
|
||||
userId: targetUserId,
|
||||
joinedAt: Date.now()
|
||||
}
|
||||
})
|
||||
await this.chatsService.create(tx, targetUserId, conversation.id)
|
||||
})
|
||||
}
|
||||
|
||||
async kick(id: GroupId, ownerId: UserId, targetUserId: UserId): Promise<void> {
|
||||
if (targetUserId === ownerId) throw new BadRequestException('Cannot kick yourself')
|
||||
|
||||
await this.leave(id, targetUserId)
|
||||
}
|
||||
|
||||
async ban(id: GroupId, ownerId: UserId, targetUserId: UserId): Promise<void> {
|
||||
if (targetUserId === ownerId) throw new BadRequestException('Cannot ban yourself')
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
await this.kick(id, ownerId, targetUserId)
|
||||
await tx.groupBlackList.upsert({
|
||||
where: { userId_groupId: { userId: targetUserId, groupId: id } },
|
||||
create: { userId: targetUserId, groupId: id },
|
||||
update: {}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async getById(id: GroupId, userId?: UserId): Promise<GroupResponseDto> {
|
||||
const group = await this.prisma.group.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { members: true }
|
||||
},
|
||||
members: userId ? {
|
||||
where: { userId },
|
||||
select: { userId: true }
|
||||
} : undefined
|
||||
}
|
||||
})
|
||||
|
||||
const isMember = userId ? group!.members.length > 0 : false
|
||||
const isOwner = userId ? group!.ownerId === userId : false
|
||||
|
||||
return plainToInstance(GroupResponseDto, {
|
||||
...group,
|
||||
membersCount: group!._count.members,
|
||||
isMember,
|
||||
isOwner
|
||||
})
|
||||
}
|
||||
|
||||
async getMembers(id: GroupId, skip: number, take: number, search?: string): Promise<UserResponseDto[]> {
|
||||
const where: Prisma.GroupMemberWhereInput = {
|
||||
groupId: id,
|
||||
user: search ? {
|
||||
OR: [
|
||||
{ firstName: { contains: search } },
|
||||
{ lastName: { contains: search } },
|
||||
{ username: { contains: search } }
|
||||
]
|
||||
} : undefined
|
||||
}
|
||||
|
||||
const members = await this.prisma.groupMember.findMany({
|
||||
where,
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
user: true
|
||||
},
|
||||
orderBy: {
|
||||
user: {
|
||||
firstName: 'asc'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return plainToInstance(UserResponseDto, members.map(m => m.user))
|
||||
}
|
||||
|
||||
async delete(id: GroupId, userId: UserId): Promise<void> {
|
||||
await this.prisma.group.delete({ where: { id } })
|
||||
}
|
||||
|
||||
async isExists(id: GroupId): Promise<boolean> {
|
||||
return await this.prisma.group.count({ where: { id } }) > 0
|
||||
}
|
||||
|
||||
async isOwner(groupId: GroupId, userId: UserId): Promise<boolean> {
|
||||
const count = await this.prisma.group.count({
|
||||
where: { id: groupId, ownerId: userId }
|
||||
})
|
||||
|
||||
return count > 0
|
||||
}
|
||||
}
|
||||
51
messenger-server/src/modules/kanban/kanban.controller.ts
Normal file
51
messenger-server/src/modules/kanban/kanban.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from '@nestjs/common'
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard'
|
||||
import { CurrentUserId } from 'src/common/decorators/user-id.decorator'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { KanbanService } from './kanban.service'
|
||||
|
||||
@Controller('kanban')
|
||||
@UseGuards(AuthGuard)
|
||||
export class KanbanController {
|
||||
constructor(private readonly kanban: KanbanService) { }
|
||||
|
||||
@Get()
|
||||
getBoards(@CurrentUserId() userId: UserId) {
|
||||
return this.kanban.getBoards(userId)
|
||||
}
|
||||
|
||||
@Post()
|
||||
createBoard(@CurrentUserId() userId: UserId, @Body('title') title: string) {
|
||||
return this.kanban.createBoard(userId, title)
|
||||
}
|
||||
|
||||
@Patch(':boardId')
|
||||
updateBoard(@CurrentUserId() userId: UserId, @Param('boardId', ParseIntPipe) boardId: number, @Body('title') title: string) {
|
||||
return this.kanban.updateBoard(userId, boardId, title)
|
||||
}
|
||||
|
||||
@Delete(':boardId')
|
||||
deleteBoard(@CurrentUserId() userId: UserId, @Param('boardId', ParseIntPipe) boardId: number) {
|
||||
return this.kanban.deleteBoard(userId, boardId)
|
||||
}
|
||||
|
||||
@Post(':boardId/columns')
|
||||
createColumn(@CurrentUserId() userId: UserId, @Param('boardId', ParseIntPipe) boardId: number, @Body('title') title: string) {
|
||||
return this.kanban.createColumn(userId, boardId, title)
|
||||
}
|
||||
|
||||
@Post('columns/:columnId/tasks')
|
||||
createTask(@CurrentUserId() userId: UserId, @Param('columnId', ParseIntPipe) columnId: number, @Body() body: { title: string; description?: string }) {
|
||||
return this.kanban.createTask(userId, columnId, body.title, body.description)
|
||||
}
|
||||
|
||||
@Patch('tasks/:taskId')
|
||||
updateTask(@CurrentUserId() userId: UserId, @Param('taskId', ParseIntPipe) taskId: number, @Body() body: { title?: string; description?: string; columnId?: number; position?: number }) {
|
||||
return this.kanban.updateTask(userId, taskId, body)
|
||||
}
|
||||
|
||||
@Delete('tasks/:taskId')
|
||||
deleteTask(@CurrentUserId() userId: UserId, @Param('taskId', ParseIntPipe) taskId: number) {
|
||||
return this.kanban.deleteTask(userId, taskId)
|
||||
}
|
||||
}
|
||||
12
messenger-server/src/modules/kanban/kanban.module.ts
Normal file
12
messenger-server/src/modules/kanban/kanban.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { KanbanController } from './kanban.controller'
|
||||
import { KanbanService } from './kanban.service'
|
||||
import { SessionsModule } from '../sessions/sessions.module'
|
||||
import { JwtAuthModule } from '../security/jwt.module'
|
||||
|
||||
@Module({
|
||||
imports: [SessionsModule, JwtAuthModule],
|
||||
controllers: [KanbanController],
|
||||
providers: [KanbanService]
|
||||
})
|
||||
export class KanbanModule { }
|
||||
188
messenger-server/src/modules/kanban/kanban.service.ts
Normal file
188
messenger-server/src/modules/kanban/kanban.service.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { PrismaService } from 'src/providers/prisma/prisma.service'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { RealtimeGateway } from '../realtime/realtime.gateway'
|
||||
import { SocketEvent } from 'src/common/socket/socket-events'
|
||||
|
||||
@Injectable()
|
||||
export class KanbanService {
|
||||
constructor(private readonly prisma: PrismaService, private readonly realtime: RealtimeGateway) { }
|
||||
|
||||
getBoards(userId: UserId) {
|
||||
return this.prisma.kanbanBoard.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{
|
||||
messages: {
|
||||
some: {
|
||||
conversation: {
|
||||
members: { some: { userId } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
columns: {
|
||||
some: {
|
||||
tasks: {
|
||||
some: {
|
||||
messages: {
|
||||
some: {
|
||||
conversation: {
|
||||
members: { some: { userId } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
include: { columns: { include: { tasks: { orderBy: { position: 'asc' } } }, orderBy: { position: 'asc' } } },
|
||||
orderBy: { updatedAt: 'desc' }
|
||||
})
|
||||
}
|
||||
|
||||
async createBoard(userId: UserId, title: string) {
|
||||
this.checkTitle(title)
|
||||
return this.prisma.kanbanBoard.create({
|
||||
data: {
|
||||
ownerId: userId, title: title.trim(), createdAt: Date.now(), updatedAt: Date.now(),
|
||||
columns: { create: [{ title: 'Нужно сделать', position: 0 }, { title: 'В работе', position: 1 }, { title: 'Готово', position: 2 }] }
|
||||
},
|
||||
include: { columns: { include: { tasks: true }, orderBy: { position: 'asc' } } }
|
||||
})
|
||||
}
|
||||
|
||||
async updateBoard(userId: UserId, boardId: number, title: string) {
|
||||
await this.requireBoardAccess(userId, boardId)
|
||||
this.checkTitle(title)
|
||||
await this.prisma.kanbanBoard.update({ where: { id: boardId }, data: { title: title.trim(), updatedAt: Date.now() } })
|
||||
return this.emitBoard(boardId)
|
||||
}
|
||||
|
||||
async deleteBoard(userId: UserId, boardId: number) {
|
||||
await this.requireBoardAccess(userId, boardId)
|
||||
await this.prisma.kanbanBoard.delete({ where: { id: boardId } })
|
||||
}
|
||||
|
||||
async createColumn(userId: UserId, boardId: number, title: string) {
|
||||
await this.requireBoardAccess(userId, boardId)
|
||||
this.checkTitle(title)
|
||||
const position = await this.prisma.kanbanColumn.count({ where: { boardId } })
|
||||
await this.prisma.kanbanColumn.create({ data: { boardId, title: title.trim(), position } })
|
||||
return this.touchAndEmit(boardId)
|
||||
}
|
||||
|
||||
async createTask(userId: UserId, columnId: number, title: string, description?: string) {
|
||||
const column = await this.requireColumnAccess(userId, columnId)
|
||||
this.checkTitle(title)
|
||||
const position = await this.prisma.kanbanTask.count({ where: { columnId } })
|
||||
await this.prisma.kanbanTask.create({ data: { columnId, title: title.trim(), description: description?.trim(), position, createdAt: Date.now(), updatedAt: Date.now() } })
|
||||
return this.touchAndEmit(column.boardId)
|
||||
}
|
||||
|
||||
async updateTask(userId: UserId, taskId: number, data: { title?: string; description?: string; columnId?: number; position?: number }) {
|
||||
const task = await this.requireTaskAccess(userId, taskId)
|
||||
if (data.title !== undefined) this.checkTitle(data.title)
|
||||
if (data.position !== undefined && data.position < 0) throw new BadRequestException('Invalid position')
|
||||
if (data.columnId !== undefined) {
|
||||
const target = await this.requireColumnAccess(userId, data.columnId)
|
||||
if (target.boardId !== task.column.boardId) throw new BadRequestException('Cannot move a task to another board')
|
||||
}
|
||||
await this.prisma.kanbanTask.update({
|
||||
where: { id: taskId },
|
||||
data: { title: data.title?.trim(), description: data.description?.trim(), columnId: data.columnId, position: data.position, updatedAt: Date.now() }
|
||||
})
|
||||
return this.touchAndEmit(task.column.boardId)
|
||||
}
|
||||
|
||||
async deleteTask(userId: UserId, taskId: number) {
|
||||
const task = await this.requireTaskAccess(userId, taskId)
|
||||
await this.prisma.kanbanTask.delete({ where: { id: taskId } })
|
||||
return this.touchAndEmit(task.column.boardId)
|
||||
}
|
||||
|
||||
private async touchAndEmit(boardId: number) {
|
||||
await this.prisma.kanbanBoard.update({ where: { id: boardId }, data: { updatedAt: Date.now() } })
|
||||
return this.emitBoard(boardId)
|
||||
}
|
||||
|
||||
private async emitBoard(boardId: number) {
|
||||
const board = await this.prisma.kanbanBoard.findUnique({
|
||||
where: { id: boardId },
|
||||
include: { columns: { include: { tasks: { orderBy: { position: 'asc' } } }, orderBy: { position: 'asc' } } }
|
||||
})
|
||||
if (!board) throw new NotFoundException('Board not found')
|
||||
const members = await this.prisma.conversationMember.findMany({
|
||||
where: { conversation: { messages: { some: { OR: [{ kanbanBoardId: boardId }, { kanbanTask: { column: { boardId } } }] } } } },
|
||||
select: { userId: true },
|
||||
distinct: ['userId']
|
||||
})
|
||||
const recipients = new Set<bigint>([board.ownerId, ...members.map(member => member.userId)])
|
||||
recipients.forEach(id => this.realtime.sendToUser(UserId(id), SocketEvent.KANBAN_UPDATE, board))
|
||||
return board
|
||||
}
|
||||
|
||||
private async requireBoard(userId: UserId, boardId: number) {
|
||||
const board = await this.prisma.kanbanBoard.findUnique({ where: { id: boardId } })
|
||||
if (!board) throw new NotFoundException('Board not found')
|
||||
if (board.ownerId !== userId) throw new ForbiddenException()
|
||||
return board
|
||||
}
|
||||
|
||||
private async requireColumn(userId: UserId, columnId: number) {
|
||||
const column = await this.prisma.kanbanColumn.findUnique({ where: { id: columnId }, include: { board: true } })
|
||||
if (!column) throw new NotFoundException('Column not found')
|
||||
if (column.board.ownerId !== userId) throw new ForbiddenException()
|
||||
return column
|
||||
}
|
||||
|
||||
private async requireBoardAccess(userId: UserId, boardId: number) {
|
||||
const board = await this.prisma.kanbanBoard.findFirst({
|
||||
where: {
|
||||
id: boardId,
|
||||
OR: [
|
||||
{ ownerId: userId },
|
||||
{ messages: { some: { conversation: { members: { some: { userId } } } } } },
|
||||
{
|
||||
columns: {
|
||||
some: {
|
||||
tasks: {
|
||||
some: {
|
||||
messages: {
|
||||
some: { conversation: { members: { some: { userId } } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
if (!board) throw new ForbiddenException('Kanban board is not available')
|
||||
return board
|
||||
}
|
||||
|
||||
private async requireColumnAccess(userId: UserId, columnId: number) {
|
||||
const column = await this.prisma.kanbanColumn.findUnique({ where: { id: columnId } })
|
||||
if (!column) throw new NotFoundException('Column not found')
|
||||
await this.requireBoardAccess(userId, column.boardId)
|
||||
return column
|
||||
}
|
||||
|
||||
private async requireTaskAccess(userId: UserId, taskId: number) {
|
||||
const task = await this.prisma.kanbanTask.findUnique({ where: { id: taskId }, include: { column: { include: { board: true } } } })
|
||||
if (!task) throw new NotFoundException('Task not found')
|
||||
await this.requireBoardAccess(userId, task.column.boardId)
|
||||
return task
|
||||
}
|
||||
|
||||
private checkTitle(title: string) {
|
||||
if (!title?.trim() || title.trim().length > 120) throw new BadRequestException('Invalid title')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, IsOptional } from 'class-validator'
|
||||
|
||||
export class FileConfirmDto {
|
||||
@IsString()
|
||||
fileId: string
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
text?: string
|
||||
}
|
||||
12
messenger-server/src/modules/messages/dto/file-init.dto.ts
Normal file
12
messenger-server/src/modules/messages/dto/file-init.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsString, IsNumber, IsMimeType } from 'class-validator'
|
||||
|
||||
export class FileInitDto {
|
||||
@IsString()
|
||||
name: string
|
||||
|
||||
@IsNumber()
|
||||
size: number
|
||||
|
||||
@IsMimeType()
|
||||
mimeType: string
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsString, IsOptional, IsArray } from 'class-validator'
|
||||
|
||||
export class MediaMessageDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
text?: string
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
fileIds: string[]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export class MessageFileDto {
|
||||
id: string
|
||||
name: string
|
||||
size: string
|
||||
mimeType: string
|
||||
status: string
|
||||
}
|
||||
|
||||
export class MessageResponseDto {
|
||||
id: number
|
||||
senderId: string
|
||||
chatId: string
|
||||
text: string
|
||||
sendTime: number
|
||||
editedAt?: number
|
||||
isRead?: boolean
|
||||
files: MessageFileDto[]
|
||||
kanbanBoard?: unknown
|
||||
kanbanTask?: unknown
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'
|
||||
import { Trim } from 'src/common/decorators/trim.decorator'
|
||||
|
||||
export class TextMessageDto {
|
||||
@IsString()
|
||||
@Trim()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(5000)
|
||||
text: string
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
kanbanBoardId?: number
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
kanbanTaskId?: number
|
||||
}
|
||||
127
messenger-server/src/modules/messages/messages.controller.ts
Normal file
127
messenger-server/src/modules/messages/messages.controller.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards, ParseIntPipe, Query, Delete } from '@nestjs/common'
|
||||
import { MessagesService } from './messages.service'
|
||||
import { TextMessageDto } from './dto/text-message.dto'
|
||||
import { MediaMessageDto } from './dto/media-message.dto'
|
||||
import { ParseChatIdPipe } from 'src/common/pipes/parse-chat-id.pipe'
|
||||
import { ChatId } from 'src/common/types/chat-id.type'
|
||||
import { AuthGuard } from 'src/common/guards/auth.guard'
|
||||
import { CurrentUserId } from 'src/common/decorators/user-id.decorator'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { CanSendMessageGuard } from 'src/common/guards/can-send-message.guard'
|
||||
import { CanReadChatGuard } from 'src/common/guards/can-read-chat.guard'
|
||||
import { CanDeleteMessageGuard } from 'src/common/guards/can-delete-message.guard'
|
||||
import { FileInitDto } from './dto/file-init.dto'
|
||||
import { FileConfirmDto } from './dto/file-confirm.dto'
|
||||
|
||||
@Controller('chats/:chatId/messages')
|
||||
@UseGuards(AuthGuard)
|
||||
export class MessagesController {
|
||||
constructor(private readonly messagesService: MessagesService) { }
|
||||
|
||||
@Post()
|
||||
@UseGuards(CanSendMessageGuard)
|
||||
sendMessage(
|
||||
@Param('chatId', ParseChatIdPipe) chatId: ChatId,
|
||||
@Body() dto: TextMessageDto,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.messagesService.sendTextMessage(userId, chatId, dto)
|
||||
}
|
||||
|
||||
@Post('media')
|
||||
@UseGuards(CanSendMessageGuard)
|
||||
sendMediaMessage(
|
||||
@Param('chatId', ParseChatIdPipe) chatId: ChatId,
|
||||
@Body() dto: MediaMessageDto,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.messagesService.sendMediaMessage(userId, chatId, dto)
|
||||
}
|
||||
|
||||
@Post('files/init')
|
||||
@UseGuards(CanSendMessageGuard)
|
||||
initFileUpload(
|
||||
@Param('chatId', ParseChatIdPipe) chatId: ChatId,
|
||||
@Body() dto: FileInitDto,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.messagesService.initFileUpload(userId, chatId, dto)
|
||||
}
|
||||
|
||||
@Post('files/confirm')
|
||||
@UseGuards(CanSendMessageGuard)
|
||||
confirmFileUpload(
|
||||
@Param('chatId', ParseChatIdPipe) chatId: ChatId,
|
||||
@Body() dto: FileConfirmDto,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.messagesService.confirmFileUpload(userId, chatId, dto)
|
||||
}
|
||||
|
||||
@Get(':messageId/files/:fileId/download')
|
||||
@UseGuards(CanReadChatGuard)
|
||||
getFileDownloadUrl(
|
||||
@Param('chatId', ParseChatIdPipe) chatId: ChatId,
|
||||
@Param('messageId', ParseIntPipe) messageId: number,
|
||||
@Param('fileId') fileId: string,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.messagesService.getFileDownloadUrl(userId, chatId, messageId, fileId)
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(CanReadChatGuard)
|
||||
getMessages(
|
||||
@Param('chatId', ParseChatIdPipe) chatId: ChatId,
|
||||
@CurrentUserId() userId: UserId,
|
||||
@Query('limit', new ParseIntPipe({ optional: true })) limit?: number,
|
||||
@Query('offset', new ParseIntPipe({ optional: true })) offset?: number
|
||||
) {
|
||||
return this.messagesService.getAll(userId, chatId, limit, offset)
|
||||
}
|
||||
|
||||
@Post(':messageId/read')
|
||||
@UseGuards(CanReadChatGuard)
|
||||
markRead(
|
||||
@Param('messageId', ParseIntPipe) messageId: number,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.messagesService.markRead(userId, messageId)
|
||||
}
|
||||
|
||||
@Post('read')
|
||||
@UseGuards(CanReadChatGuard)
|
||||
markAllRead(
|
||||
@Param('chatId', ParseChatIdPipe) chatId: ChatId,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.messagesService.markAllRead(userId, chatId)
|
||||
}
|
||||
|
||||
@Delete(':messageId')
|
||||
@UseGuards(CanDeleteMessageGuard)
|
||||
deleteMessage(
|
||||
@Param('chatId', ParseChatIdPipe) chatId: ChatId,
|
||||
@Param('messageId', ParseIntPipe) messageId: number,
|
||||
@CurrentUserId() userId: UserId,
|
||||
@Query('forEveryone') forEveryone?: string
|
||||
) {
|
||||
return this.messagesService.deleteMessage(userId, chatId, messageId, forEveryone === 'true')
|
||||
}
|
||||
|
||||
@Delete()
|
||||
@UseGuards(CanReadChatGuard)
|
||||
clearHistory(
|
||||
@Param('chatId', ParseChatIdPipe) chatId: ChatId,
|
||||
@CurrentUserId() userId: UserId
|
||||
) {
|
||||
return this.messagesService.clearHistory(userId, chatId)
|
||||
}
|
||||
|
||||
@Post('voice')
|
||||
sendVoiceMessage() { }
|
||||
|
||||
@Post('reaction')
|
||||
sendReaction() { }
|
||||
}
|
||||
|
||||
16
messenger-server/src/modules/messages/messages.module.ts
Normal file
16
messenger-server/src/modules/messages/messages.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { MessagesService } from './messages.service'
|
||||
import { MessagesController } from './messages.controller'
|
||||
import { JwtAuthModule } from '../security/jwt.module'
|
||||
import { SessionsModule } from '../sessions/sessions.module'
|
||||
import { PushModule } from '../push/push.module'
|
||||
import { ChatsModule } from '../chats/chats.module'
|
||||
import { StorageModule } from '../storage/storage.module'
|
||||
|
||||
@Module({
|
||||
imports: [JwtAuthModule, SessionsModule, PushModule, ChatsModule, StorageModule],
|
||||
controllers: [MessagesController],
|
||||
providers: [MessagesService],
|
||||
exports: [MessagesService]
|
||||
})
|
||||
export class MessagesModule { }
|
||||
489
messenger-server/src/modules/messages/messages.service.ts
Normal file
489
messenger-server/src/modules/messages/messages.service.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { TextMessageDto } from './dto/text-message.dto'
|
||||
import { MediaMessageDto } from './dto/media-message.dto'
|
||||
import { ChatId } from 'src/common/types/chat-id.type'
|
||||
import { plainToInstance } from 'class-transformer'
|
||||
import { ChatsService } from '../chats/chats.service'
|
||||
import { ConversationType, Prisma, FileStatus } from 'generated/prisma/client'
|
||||
import { MessageResponseDto } from './dto/message-response.dto'
|
||||
import { PrismaService } from 'src/providers/prisma/prisma.service'
|
||||
import { ChatType } from 'src/common/enums/chat-type.enum'
|
||||
import { PushService } from '../push/push.service'
|
||||
import { RealtimeGateway } from '../realtime/realtime.gateway'
|
||||
import { SocketEvent } from 'src/common/socket/socket-events'
|
||||
import { StorageService } from '../storage/storage.service'
|
||||
import { FileInitDto } from './dto/file-init.dto'
|
||||
import { FileConfirmDto } from './dto/file-confirm.dto'
|
||||
import { detectChatType } from 'src/common/utils/detect-chat-type.util'
|
||||
|
||||
@Injectable()
|
||||
export class MessagesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly chatsService: ChatsService,
|
||||
private readonly pushService: PushService,
|
||||
private readonly realtimeGateway: RealtimeGateway,
|
||||
private readonly storageService: StorageService
|
||||
) { }
|
||||
|
||||
async sendTextMessage(senderId: UserId, chatId: ChatId, dto: TextMessageDto): Promise<MessageResponseDto> {
|
||||
if (dto.kanbanBoardId) {
|
||||
const board = await this.prisma.kanbanBoard.findUnique({ where: { id: dto.kanbanBoardId } })
|
||||
if (!board || board.ownerId !== senderId) throw new ForbiddenException('Kanban board not found')
|
||||
}
|
||||
if (dto.kanbanTaskId) {
|
||||
const task = await this.prisma.kanbanTask.findUnique({ where: { id: dto.kanbanTaskId }, include: { column: { include: { board: true } } } })
|
||||
if (!task) throw new ForbiddenException('Kanban task not found')
|
||||
}
|
||||
return this.withChat<MessageResponseDto>(senderId, chatId, async (tx, ctx) => {
|
||||
const sequenceId = await tx.message.count({ where: { conversationId: ctx.conversationId } })
|
||||
|
||||
const actualSenderId = ctx.conversationType === ConversationType.CHANNEL ? ctx.ownerId : senderId
|
||||
const isSelfChat = ctx.chatType === ChatType.PRIVATE && senderId === (chatId as bigint)
|
||||
|
||||
const message = await tx.message.create({
|
||||
data: {
|
||||
sequenceId: sequenceId + 1,
|
||||
conversationId: ctx.conversationId,
|
||||
text: dto.text,
|
||||
sendTime: Date.now(),
|
||||
senderId: actualSenderId,
|
||||
isRead: isSelfChat,
|
||||
kanbanBoardId: dto.kanbanBoardId,
|
||||
kanbanTaskId: dto.kanbanTaskId
|
||||
},
|
||||
include: {
|
||||
files: true,
|
||||
kanbanBoard: { include: { columns: { include: { tasks: true }, orderBy: { position: 'asc' } } } },
|
||||
kanbanTask: { include: { column: { include: { board: true } } } }
|
||||
}
|
||||
})
|
||||
|
||||
const messageInstance = plainToInstance(MessageResponseDto, {
|
||||
...message,
|
||||
chatId: chatId.toString(),
|
||||
isRead: true,
|
||||
files: message.files.map(f => ({ ...f, size: f.size.toString() }))
|
||||
})
|
||||
|
||||
await this.notifyRecipients(senderId, chatId, ctx, messageInstance)
|
||||
|
||||
return messageInstance
|
||||
})
|
||||
}
|
||||
|
||||
async sendMediaMessage(senderId: UserId, chatId: ChatId, dto: MediaMessageDto): Promise<MessageResponseDto[]> {
|
||||
return this.withChat<MessageResponseDto[]>(senderId, chatId, async (tx, ctx) => {
|
||||
const files = await tx.file.findMany({
|
||||
where: { id: { in: dto.fileIds }, status: FileStatus.COMPLETED }
|
||||
})
|
||||
|
||||
if (files.length !== dto.fileIds.length) {
|
||||
throw new NotFoundException('Some files were not found or not uploaded completely')
|
||||
}
|
||||
|
||||
const actualSenderId = ctx.conversationType === ConversationType.CHANNEL ? ctx.ownerId : senderId
|
||||
const isSelfChat = ctx.chatType === ChatType.PRIVATE && senderId === (chatId as bigint)
|
||||
|
||||
const results: MessageResponseDto[] = []
|
||||
|
||||
// Optimize: Get starting sequenceId once
|
||||
const lastMessage = await tx.message.findFirst({
|
||||
where: { conversationId: ctx.conversationId },
|
||||
orderBy: { sequenceId: 'desc' },
|
||||
select: { sequenceId: true }
|
||||
})
|
||||
let nextSequenceId = (lastMessage?.sequenceId ?? 0n) + 1n
|
||||
|
||||
for (const fileId of dto.fileIds) {
|
||||
const message = await tx.message.create({
|
||||
data: {
|
||||
sequenceId: nextSequenceId++,
|
||||
conversationId: ctx.conversationId,
|
||||
text: results.length === 0 ? dto.text : null,
|
||||
sendTime: Date.now(),
|
||||
senderId: actualSenderId,
|
||||
isRead: isSelfChat,
|
||||
files: {
|
||||
connect: { id: fileId }
|
||||
}
|
||||
},
|
||||
include: { files: true }
|
||||
})
|
||||
|
||||
const messageInstance = plainToInstance(MessageResponseDto, {
|
||||
...message,
|
||||
chatId: chatId.toString(),
|
||||
isRead: true,
|
||||
files: message.files.map(f => ({ ...f, size: f.size.toString() }))
|
||||
})
|
||||
|
||||
await this.notifyRecipients(senderId, chatId, ctx, messageInstance)
|
||||
results.push(messageInstance)
|
||||
}
|
||||
|
||||
return results
|
||||
})
|
||||
}
|
||||
|
||||
async initFileUpload(userId: UserId, chatId: ChatId, dto: FileInitDto) {
|
||||
return this.withChat(userId, chatId, async (tx, ctx) => {
|
||||
return this.storageService.initUpload(dto.name, dto.size, dto.mimeType)
|
||||
})
|
||||
}
|
||||
|
||||
async confirmFileUpload(userId: UserId, chatId: ChatId, dto: FileConfirmDto): Promise<MessageResponseDto> {
|
||||
return this.withChat<MessageResponseDto>(userId, chatId, async (tx, ctx) => {
|
||||
await this.storageService.confirmUpload(dto.fileId)
|
||||
|
||||
const sequenceId = await tx.message.count({ where: { conversationId: ctx.conversationId } })
|
||||
|
||||
const actualSenderId = ctx.conversationType === ConversationType.CHANNEL ? ctx.ownerId : userId
|
||||
const isSelfChat = ctx.chatType === ChatType.PRIVATE && userId === (chatId as bigint)
|
||||
|
||||
const message = await tx.message.create({
|
||||
data: {
|
||||
sequenceId: sequenceId + 1,
|
||||
conversationId: ctx.conversationId,
|
||||
text: dto.text,
|
||||
sendTime: Date.now(),
|
||||
senderId: actualSenderId,
|
||||
isRead: isSelfChat,
|
||||
files: {
|
||||
connect: { id: dto.fileId }
|
||||
}
|
||||
},
|
||||
include: { files: true }
|
||||
})
|
||||
|
||||
const messageInstance = plainToInstance(MessageResponseDto, {
|
||||
...message,
|
||||
chatId: chatId.toString(),
|
||||
isRead: true,
|
||||
files: message.files.map(f => ({ ...f, size: f.size.toString() }))
|
||||
})
|
||||
|
||||
await this.notifyRecipients(userId, chatId, ctx, messageInstance)
|
||||
|
||||
return messageInstance
|
||||
})
|
||||
}
|
||||
|
||||
async getFileDownloadUrl(userId: UserId, chatId: ChatId, messageId: number, fileId: string) {
|
||||
return this.withChat(userId, chatId, async (tx, ctx) => {
|
||||
const message = await tx.message.findFirst({
|
||||
where: { id: messageId, conversationId: ctx.conversationId },
|
||||
include: { files: true }
|
||||
})
|
||||
|
||||
if (!message) throw new NotFoundException('Message not found')
|
||||
|
||||
const file = message.files.find(f => f.id === fileId)
|
||||
if (!file) throw new NotFoundException('File not found in this message')
|
||||
|
||||
return this.storageService.getDownloadUrl(fileId)
|
||||
})
|
||||
}
|
||||
|
||||
async getAll(userId: UserId, chatId: ChatId, limit: number = 50, offset: number = 0): Promise<MessageResponseDto[]> {
|
||||
const conversation = await this.chatsService.findConversationByChatId(chatId, userId)
|
||||
|
||||
const messages = await this.prisma.message.findMany({
|
||||
where: {
|
||||
conversationId: conversation.id,
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{ senderId: { not: userId } },
|
||||
{ deletedBySender: false }
|
||||
]
|
||||
},
|
||||
{
|
||||
OR: [
|
||||
{ senderId: userId },
|
||||
{ deletedByReceiver: false }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
include: {
|
||||
readReceipts: {
|
||||
where: { userId },
|
||||
select: { userId: true }
|
||||
},
|
||||
files: true,
|
||||
kanbanBoard: { include: { columns: { include: { tasks: true }, orderBy: { position: 'asc' } } } },
|
||||
kanbanTask: { include: { column: { include: { board: true } } } }
|
||||
},
|
||||
orderBy: { sendTime: 'desc' },
|
||||
take: limit,
|
||||
skip: offset
|
||||
})
|
||||
|
||||
const messagesEntity = messages.map(message => {
|
||||
const isRead = message.isRead || message.readReceipts.length > 0
|
||||
return plainToInstance(MessageResponseDto, {
|
||||
...message,
|
||||
chatId: chatId.toString(),
|
||||
isRead,
|
||||
files: message.files.map(f => ({ ...f, size: f.size.toString() }))
|
||||
})
|
||||
})
|
||||
|
||||
return messagesEntity.reverse()
|
||||
}
|
||||
|
||||
async markRead(userId: UserId, messageId: number): Promise<void> {
|
||||
const message = await this.prisma.message.findUnique({
|
||||
where: { id: messageId },
|
||||
include: { conversation: true }
|
||||
})
|
||||
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found')
|
||||
}
|
||||
|
||||
const existing = await this.prisma.messageRead.findFirst({
|
||||
where: { messageId, userId }
|
||||
})
|
||||
|
||||
if (!existing) {
|
||||
await this.prisma.messageRead.create({
|
||||
data: {
|
||||
messageId,
|
||||
userId,
|
||||
readAt: Date.now()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (message.conversation.type === ConversationType.DIRECT) {
|
||||
await this.prisma.message.update({
|
||||
where: { id: messageId },
|
||||
data: { isRead: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async markAllRead(userId: UserId, chatId: ChatId): Promise<void> {
|
||||
const conversation = await this.chatsService.findConversationByChatId(chatId, userId)
|
||||
|
||||
const unread = await this.prisma.message.findMany({
|
||||
where: {
|
||||
conversationId: conversation.id,
|
||||
readReceipts: { none: { userId } }
|
||||
},
|
||||
select: { id: true }
|
||||
})
|
||||
|
||||
if (unread.length === 0) return
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
await this.prisma.messageRead.createMany({
|
||||
data: unread.map(m => ({
|
||||
messageId: m.id,
|
||||
userId,
|
||||
readAt: now
|
||||
}))
|
||||
})
|
||||
|
||||
if (conversation.type === ConversationType.DIRECT) {
|
||||
await this.prisma.message.updateMany({
|
||||
where: { id: { in: unread.map(m => m.id) } },
|
||||
data: { isRead: true }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMessage(userId: UserId, chatId: ChatId, messageId: number, forEveryone: boolean = false): Promise<void> {
|
||||
const conversation = await this.chatsService.findConversationByChatId(chatId, userId)
|
||||
|
||||
const message = await this.prisma.message.findFirst({
|
||||
where: { id: messageId, conversationId: conversation.id }
|
||||
})
|
||||
|
||||
if (!message) {
|
||||
throw new NotFoundException('Message not found')
|
||||
}
|
||||
|
||||
const isDirect = conversation.type === ConversationType.DIRECT
|
||||
|
||||
if (!isDirect || forEveryone) {
|
||||
// Delete files associated with this message
|
||||
const files = await this.prisma.file.findMany({
|
||||
where: { messageId: messageId }
|
||||
})
|
||||
|
||||
for (const file of files) {
|
||||
await this.storageService.deleteFile(file.id)
|
||||
}
|
||||
|
||||
await this.prisma.message.delete({
|
||||
where: { id: messageId }
|
||||
})
|
||||
} else {
|
||||
// "Delete for me" in DIRECT chat
|
||||
const isSender = message.senderId === userId
|
||||
const updateData = isSender ? { deletedBySender: true } : { deletedByReceiver: true }
|
||||
|
||||
const updated = await this.prisma.message.update({
|
||||
where: { id: messageId },
|
||||
data: updateData
|
||||
})
|
||||
|
||||
// If both participants deleted it, remove from DB
|
||||
if (updated.deletedBySender && updated.deletedByReceiver) {
|
||||
// Delete files associated with this message
|
||||
const files = await this.prisma.file.findMany({
|
||||
where: { messageId: messageId }
|
||||
})
|
||||
|
||||
for (const file of files) {
|
||||
await this.storageService.deleteFile(file.id)
|
||||
}
|
||||
|
||||
await this.prisma.message.delete({
|
||||
where: { id: messageId }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const ctx = await this.chatsService.resolveConversation(this.prisma, userId, chatId)
|
||||
const recipients = await this.getRecipients(userId, chatId, ctx.chatType)
|
||||
|
||||
// Notify sender always
|
||||
const senderPayload = { chatId: chatId.toString(), messageId }
|
||||
this.realtimeGateway.sendToUser(userId, SocketEvent.MESSAGE_DELETE, senderPayload)
|
||||
|
||||
// Notify recipient only if it was deleted for everyone
|
||||
if (!isDirect || forEveryone) {
|
||||
const recipientPayload = { chatId: userId.toString(), messageId }
|
||||
for (const recipientId of recipients) {
|
||||
this.realtimeGateway.sendToUser(recipientId, SocketEvent.MESSAGE_DELETE, recipientPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearHistory(userId: UserId, chatId: ChatId): Promise<void> {
|
||||
const conversation = await this.chatsService.findConversationByChatId(chatId, userId)
|
||||
const chatType = detectChatType(chatId)
|
||||
|
||||
// Fetch all messages to delete their files
|
||||
const messages = await this.prisma.message.findMany({
|
||||
where: { conversationId: conversation.id },
|
||||
include: { files: true }
|
||||
})
|
||||
|
||||
for (const message of messages) {
|
||||
for (const file of message.files) {
|
||||
await this.storageService.deleteFile(file.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete all messages
|
||||
await this.prisma.message.deleteMany({
|
||||
where: { conversationId: conversation.id }
|
||||
})
|
||||
|
||||
// Notify participants
|
||||
const ctx = await this.chatsService.resolveConversation(this.prisma, userId, chatId)
|
||||
const recipients = await this.getRecipients(userId, chatId, ctx.chatType)
|
||||
const targets = Array.from(new Set([...recipients, userId]))
|
||||
|
||||
const payload = { chatId: chatId.toString() }
|
||||
|
||||
// We should add a new SocketEvent for HISTORY_CLEAR or reuse MESSAGE_DELETE with some flag
|
||||
// Let's assume there is a SocketEvent.HISTORY_CLEAR or similar.
|
||||
// If not, I'll check socket-events.ts
|
||||
this.realtimeGateway.sendToChat(chatId, SocketEvent.HISTORY_CLEAR, payload)
|
||||
this.realtimeGateway.sendToUsersExceptChat(targets, chatId, SocketEvent.HISTORY_CLEAR, payload)
|
||||
}
|
||||
|
||||
private async withChat<T>(
|
||||
userId: UserId,
|
||||
chatId: ChatId,
|
||||
fn: (tx: Prisma.TransactionClient, ctx: { conversationId: number; conversationType: ConversationType; ownerId: bigint; chatType: ChatType }) => Promise<T>
|
||||
): Promise<T> {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
const ctx = await this.chatsService.resolveConversation(tx, userId, chatId)
|
||||
|
||||
await this.chatsService.create(tx, userId, ctx.conversationId)
|
||||
|
||||
if (ctx.chatType === ChatType.PRIVATE) {
|
||||
await this.chatsService.create(tx, UserId(chatId), ctx.conversationId)
|
||||
}
|
||||
|
||||
return fn(tx, ctx)
|
||||
})
|
||||
}
|
||||
|
||||
private async notifyRecipients(
|
||||
senderUserId: UserId,
|
||||
chatId: ChatId,
|
||||
ctx: { conversationId: number; conversationType: ConversationType; ownerId: bigint; chatType: ChatType },
|
||||
message: MessageResponseDto
|
||||
): Promise<void> {
|
||||
const recipients = await this.getRecipients(senderUserId, chatId, ctx.chatType)
|
||||
const wsTargets = Array.from(new Set([...recipients, senderUserId]))
|
||||
|
||||
const online: UserId[] = []
|
||||
const offline: UserId[] = []
|
||||
|
||||
for (const userId of wsTargets) {
|
||||
if (this.realtimeGateway.isUserOnline(userId)) {
|
||||
online.push(userId)
|
||||
} else if (userId !== senderUserId) {
|
||||
offline.push(userId)
|
||||
}
|
||||
}
|
||||
|
||||
if (ChatId(senderUserId) == chatId) {
|
||||
this.realtimeGateway.sendToUser(senderUserId, SocketEvent.MESSAGE_NEW, message)
|
||||
} else {
|
||||
this.realtimeGateway.sendToChat(chatId, SocketEvent.MESSAGE_NEW, message)
|
||||
}
|
||||
|
||||
if (online.length > 0) {
|
||||
this.realtimeGateway.sendToUsersExceptChat(online, chatId, SocketEvent.MESSAGE_NEW, message)
|
||||
}
|
||||
|
||||
if (offline.length > 0) {
|
||||
await this.pushService.sendToUsers(offline, {
|
||||
title: 'Новое сообщение',
|
||||
body: message.text || 'Вложение',
|
||||
data: {
|
||||
type: 'message',
|
||||
chatId: message.chatId,
|
||||
messageId: message.id.toString()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async getRecipients(senderUserId: UserId, chatId: ChatId, chatType: ChatType): Promise<UserId[]> {
|
||||
if (chatType === ChatType.PRIVATE) {
|
||||
const recipient = UserId(chatId)
|
||||
return recipient === senderUserId ? [] : [recipient]
|
||||
}
|
||||
|
||||
if (chatType === ChatType.GROUP) {
|
||||
const members = await this.prisma.groupMember.findMany({
|
||||
where: { groupId: chatId },
|
||||
select: { userId: true }
|
||||
})
|
||||
return members.map(m => UserId(m.userId)).filter(id => id !== senderUserId)
|
||||
}
|
||||
|
||||
if (chatType === ChatType.CHANNEL) {
|
||||
const subs = await this.prisma.channelSubscriber.findMany({
|
||||
where: { channelId: chatId },
|
||||
select: { userId: true }
|
||||
})
|
||||
return subs.map(s => UserId(s.userId)).filter(id => id !== senderUserId)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Injectable, Logger } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { PushPayload, PushProvider } from '../push.types'
|
||||
import * as admin from 'firebase-admin'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
@Injectable()
|
||||
export class FirebasePushProvider implements PushProvider {
|
||||
private readonly logger = new Logger(FirebasePushProvider.name)
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
this.ensureInitialized()
|
||||
}
|
||||
|
||||
async sendToTokens(tokens: string[], payload: PushPayload): Promise<void> {
|
||||
if (tokens.length === 0) return
|
||||
if (admin.apps.length === 0) return
|
||||
|
||||
try {
|
||||
const message: admin.messaging.MulticastMessage = {
|
||||
tokens,
|
||||
notification: {
|
||||
title: payload.title,
|
||||
body: payload.body
|
||||
},
|
||||
data: payload.data
|
||||
}
|
||||
|
||||
const res = await admin.messaging().sendEachForMulticast(message)
|
||||
|
||||
if (res.failureCount > 0) {
|
||||
const failed = res.responses
|
||||
.map((r, i) => (r.success ? null : tokens[i]))
|
||||
.filter((t): t is string => !!t)
|
||||
this.logger.warn(`Push failed for ${failed.length} tokens`)
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Push send failed', err as Error)
|
||||
}
|
||||
}
|
||||
|
||||
private ensureInitialized(): void {
|
||||
if (admin.apps.length > 0) return
|
||||
|
||||
const serviceAccountPath = this.config.get<string>('FIREBASE_SERVICE_ACCOUNT_PATH')
|
||||
if (!serviceAccountPath) {
|
||||
this.logger.warn('Firebase service account is not configured; push notifications are disabled')
|
||||
return
|
||||
}
|
||||
|
||||
const fullPath = path.resolve(process.cwd(), serviceAccountPath)
|
||||
if (!fs.existsSync(fullPath)) {
|
||||
this.logger.warn(`Firebase service account not found at ${fullPath}; push notifications are disabled`)
|
||||
return
|
||||
}
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(fullPath)
|
||||
})
|
||||
}
|
||||
}
|
||||
16
messenger-server/src/modules/push/push.module.ts
Normal file
16
messenger-server/src/modules/push/push.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { ConfigModule } from '@nestjs/config'
|
||||
import { PushService } from './push.service'
|
||||
import { FirebasePushProvider } from './providers/firebase-push.provider'
|
||||
import { PUSH_PROVIDER } from './push.types'
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
PushService,
|
||||
FirebasePushProvider,
|
||||
{ provide: PUSH_PROVIDER, useExisting: FirebasePushProvider }
|
||||
],
|
||||
exports: [PushService]
|
||||
})
|
||||
export class PushModule { }
|
||||
32
messenger-server/src/modules/push/push.service.ts
Normal file
32
messenger-server/src/modules/push/push.service.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Inject, Injectable } from '@nestjs/common'
|
||||
import { PrismaService } from 'src/providers/prisma/prisma.service'
|
||||
import { UserId } from 'src/common/types/user-id.type'
|
||||
import { PUSH_PROVIDER, PushPayload, PushProvider } from './push.types'
|
||||
|
||||
@Injectable()
|
||||
export class PushService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(PUSH_PROVIDER) private readonly provider: PushProvider
|
||||
) { }
|
||||
|
||||
async sendToUsers(userIds: UserId[], payload: PushPayload): Promise<void> {
|
||||
if (userIds.length === 0) return
|
||||
|
||||
const tokens = await this.prisma.session.findMany({
|
||||
where: {
|
||||
userId: { in: userIds },
|
||||
fcmToken: { not: null }
|
||||
},
|
||||
select: { fcmToken: true }
|
||||
})
|
||||
|
||||
const uniqueTokens = Array.from(
|
||||
new Set(tokens.map(t => t.fcmToken).filter((t): t is string => !!t))
|
||||
)
|
||||
|
||||
if (uniqueTokens.length === 0) return
|
||||
|
||||
await this.provider.sendToTokens(uniqueTokens, payload)
|
||||
}
|
||||
}
|
||||
11
messenger-server/src/modules/push/push.types.ts
Normal file
11
messenger-server/src/modules/push/push.types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type PushPayload = {
|
||||
title: string
|
||||
body: string
|
||||
data?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface PushProvider {
|
||||
sendToTokens(tokens: string[], payload: PushPayload): Promise<void>
|
||||
}
|
||||
|
||||
export const PUSH_PROVIDER = Symbol('PUSH_PROVIDER')
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user