Track client and server sources

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 12:04:01 +03:00
parent d9f7603ae8
commit de9dd05308
383 changed files with 44782 additions and 2 deletions

Submodule messenger-server deleted from 6255e4e012

View 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

View File

@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 100,
"useTabs": true,
"trailingComma": "none"
}

View 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.

View 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',
},
]

View 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" }],
},
},
)

View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View 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")
}
})

View File

@@ -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");

View 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)
}

Binary file not shown.

View 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 на&nbsp;Android</h1>
<p class="intro-subtitle">Поддерживаются устройства от&nbsp;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>

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View 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>

View 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;
}
}

View File

@@ -0,0 +1,7 @@
import { Controller } from '@nestjs/common'
import { AppService } from './app.service'
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
}

View 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')
}
}

View File

@@ -0,0 +1,4 @@
import { Injectable } from '@nestjs/common'
@Injectable()
export class AppService {}

View File

@@ -0,0 +1,2 @@
export const MAX_INT64 = 9223372036854775807n
export const MIN_INT64 = 0n

View 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'
}

View File

@@ -0,0 +1,5 @@
import { Transform } from 'class-transformer'
export const OmitNull = () => {
return Transform(({ value }) => value ?? undefined, { toPlainOnly: true })
}

View 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
})
}

View File

@@ -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
})

View File

@@ -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
}
)

View File

@@ -0,0 +1,6 @@
export enum ChatType {
PRIVATE = 0,
GROUP = 1,
CHANNEL = 2,
UNKNOWN = 3
}

View 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);
}
}

View 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
}
}

View File

@@ -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')
}
}

View 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')
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View File

@@ -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
}
}

View 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()
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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]

View File

@@ -0,0 +1 @@
export type Brand<K, T> = K & { __brand: T }

View 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
}

View 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
}

View 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
}

View 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
}

View File

@@ -0,0 +1,5 @@
import { UserId } from "./user-id.type"
export type TokenPayload = {
userId: UserId
}

View 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
}

View 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
}
}

View 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
}

View 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)
}

View 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()

View 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)
}
}

View 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 { }

View 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
}
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,4 @@
export class AuthResponseDto {
userId: string
token: string
}

View File

@@ -0,0 +1,3 @@
export class LoginAvailableDto {
available: boolean
}

View 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
}

View 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
}

View 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)
}
}

View 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 { }

View 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
}
}

View 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
}

View File

@@ -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
}

View File

@@ -0,0 +1,4 @@
import { CreateChannelDto } from './create-channel.dto'
import { PartialType } from '@nestjs/mapped-types'
export class UpdateChannelDto extends PartialType(CreateChannelDto) { }

View 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)
}
}

View 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 { }

View 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() }))
})
}
}

View 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
}

View File

@@ -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
}

View File

@@ -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
}

View 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 } })
}
}

View 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
}

View File

@@ -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
}

View File

@@ -0,0 +1,4 @@
import { CreateGroupDto } from './create-group.dto'
import { PartialType } from '@nestjs/mapped-types'
export class UpdateGroupDto extends PartialType(CreateGroupDto) { }

View 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)
}
}

View 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 { }

View 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
}
}

View 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)
}
}

View 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 { }

View 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')
}
}

View File

@@ -0,0 +1,10 @@
import { IsString, IsOptional } from 'class-validator'
export class FileConfirmDto {
@IsString()
fileId: string
@IsOptional()
@IsString()
text?: string
}

View File

@@ -0,0 +1,12 @@
import { IsString, IsNumber, IsMimeType } from 'class-validator'
export class FileInitDto {
@IsString()
name: string
@IsNumber()
size: number
@IsMimeType()
mimeType: string
}

View File

@@ -0,0 +1,11 @@
import { IsString, IsOptional, IsArray } from 'class-validator'
export class MediaMessageDto {
@IsOptional()
@IsString()
text?: string
@IsArray()
@IsString({ each: true })
fileIds: string[]
}

View File

@@ -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
}

View File

@@ -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
}

View 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() { }
}

View 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 { }

View 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 []
}
}

View File

@@ -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)
})
}
}

View 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 { }

View 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)
}
}

View 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