@venizia/ignis-docs 0.0.2 → 0.0.4-0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +4 -2
- package/wiki/best-practices/api-usage-examples.md +591 -0
- package/wiki/best-practices/architectural-patterns.md +415 -0
- package/wiki/best-practices/architecture-decisions.md +488 -0
- package/wiki/{get-started/best-practices → best-practices}/code-style-standards.md +647 -182
- package/wiki/{get-started/best-practices → best-practices}/common-pitfalls.md +109 -4
- package/wiki/{get-started/best-practices → best-practices}/contribution-workflow.md +34 -7
- package/wiki/best-practices/data-modeling.md +376 -0
- package/wiki/best-practices/deployment-strategies.md +698 -0
- package/wiki/best-practices/index.md +27 -0
- package/wiki/best-practices/performance-optimization.md +196 -0
- package/wiki/best-practices/security-guidelines.md +218 -0
- package/wiki/{get-started/best-practices → best-practices}/troubleshooting-tips.md +97 -1
- package/wiki/changelogs/2025-12-16-initial-architecture.md +1 -1
- package/wiki/changelogs/2025-12-16-model-repo-datasource-refactor.md +1 -1
- package/wiki/changelogs/2025-12-17-refactor.md +1 -1
- package/wiki/changelogs/2025-12-18-performance-optimizations.md +5 -5
- package/wiki/changelogs/2025-12-18-repository-validation-security.md +13 -7
- package/wiki/changelogs/2025-12-26-nested-relations-and-generics.md +86 -0
- package/wiki/changelogs/2025-12-26-transaction-support.md +57 -0
- package/wiki/changelogs/2025-12-29-dynamic-binding-registration.md +104 -0
- package/wiki/changelogs/2025-12-29-snowflake-uid-helper.md +100 -0
- package/wiki/changelogs/2025-12-30-repository-enhancements.md +214 -0
- package/wiki/changelogs/2025-12-31-json-path-filtering-array-operators.md +214 -0
- package/wiki/changelogs/2025-12-31-string-id-custom-generator.md +137 -0
- package/wiki/changelogs/2026-01-02-default-filter-and-repository-mixins.md +418 -0
- package/wiki/changelogs/index.md +8 -1
- package/wiki/changelogs/planned-schema-migrator.md +2 -10
- package/wiki/{get-started/core-concepts → guides/core-concepts/application}/bootstrapping.md +18 -5
- package/wiki/{get-started/core-concepts/application.md → guides/core-concepts/application/index.md} +47 -104
- package/wiki/guides/core-concepts/components-guide.md +509 -0
- package/wiki/guides/core-concepts/components.md +122 -0
- package/wiki/{get-started → guides}/core-concepts/controllers.md +30 -13
- package/wiki/{get-started → guides}/core-concepts/dependency-injection.md +97 -0
- package/wiki/guides/core-concepts/persistent/datasources.md +179 -0
- package/wiki/guides/core-concepts/persistent/index.md +119 -0
- package/wiki/guides/core-concepts/persistent/models.md +241 -0
- package/wiki/guides/core-concepts/persistent/repositories.md +219 -0
- package/wiki/guides/core-concepts/persistent/transactions.md +170 -0
- package/wiki/{get-started → guides}/core-concepts/services.md +26 -3
- package/wiki/{get-started → guides/get-started}/5-minute-quickstart.md +59 -14
- package/wiki/guides/get-started/philosophy.md +682 -0
- package/wiki/guides/get-started/setup.md +157 -0
- package/wiki/guides/index.md +89 -0
- package/wiki/guides/reference/glossary.md +243 -0
- package/wiki/{get-started → guides/reference}/mcp-docs-server.md +0 -10
- package/wiki/{get-started → guides/tutorials}/building-a-crud-api.md +134 -132
- package/wiki/{get-started/quickstart.md → guides/tutorials/complete-installation.md} +107 -71
- package/wiki/guides/tutorials/ecommerce-api.md +1399 -0
- package/wiki/guides/tutorials/realtime-chat.md +1261 -0
- package/wiki/guides/tutorials/testing.md +723 -0
- package/wiki/index.md +176 -37
- package/wiki/references/base/application.md +27 -0
- package/wiki/references/base/bootstrapping.md +30 -26
- package/wiki/references/base/components.md +532 -31
- package/wiki/references/base/controllers.md +136 -38
- package/wiki/references/base/datasources.md +108 -5
- package/wiki/references/base/dependency-injection.md +39 -3
- package/wiki/references/base/filter-system/application-usage.md +224 -0
- package/wiki/references/base/filter-system/array-operators.md +132 -0
- package/wiki/references/base/filter-system/comparison-operators.md +109 -0
- package/wiki/references/base/filter-system/default-filter.md +428 -0
- package/wiki/references/base/filter-system/fields-order-pagination.md +155 -0
- package/wiki/references/base/filter-system/index.md +127 -0
- package/wiki/references/base/filter-system/json-filtering.md +197 -0
- package/wiki/references/base/filter-system/list-operators.md +71 -0
- package/wiki/references/base/filter-system/logical-operators.md +156 -0
- package/wiki/references/base/filter-system/null-operators.md +58 -0
- package/wiki/references/base/filter-system/pattern-matching.md +108 -0
- package/wiki/references/base/filter-system/quick-reference.md +431 -0
- package/wiki/references/base/filter-system/range-operators.md +63 -0
- package/wiki/references/base/filter-system/tips.md +190 -0
- package/wiki/references/base/filter-system/use-cases.md +452 -0
- package/wiki/references/base/index.md +90 -0
- package/wiki/references/base/middlewares.md +602 -0
- package/wiki/references/base/models.md +215 -23
- package/wiki/references/base/providers.md +732 -0
- package/wiki/references/base/repositories/advanced.md +555 -0
- package/wiki/references/base/repositories/index.md +228 -0
- package/wiki/references/base/repositories/mixins.md +331 -0
- package/wiki/references/base/repositories/relations.md +486 -0
- package/wiki/references/base/repositories.md +40 -549
- package/wiki/references/base/services.md +28 -4
- package/wiki/references/components/authentication.md +22 -2
- package/wiki/references/components/health-check.md +12 -0
- package/wiki/references/components/index.md +23 -0
- package/wiki/references/components/mail.md +687 -0
- package/wiki/references/components/request-tracker.md +16 -0
- package/wiki/references/components/socket-io.md +18 -0
- package/wiki/references/components/static-asset.md +14 -26
- package/wiki/references/components/swagger.md +17 -0
- package/wiki/references/configuration/environment-variables.md +427 -0
- package/wiki/references/configuration/index.md +73 -0
- package/wiki/references/helpers/cron.md +14 -0
- package/wiki/references/helpers/crypto.md +15 -0
- package/wiki/references/helpers/env.md +16 -0
- package/wiki/references/helpers/error.md +17 -0
- package/wiki/references/helpers/index.md +15 -0
- package/wiki/references/helpers/inversion.md +24 -4
- package/wiki/references/helpers/logger.md +19 -0
- package/wiki/references/helpers/network.md +11 -0
- package/wiki/references/helpers/queue.md +19 -0
- package/wiki/references/helpers/redis.md +21 -0
- package/wiki/references/helpers/socket-io.md +24 -5
- package/wiki/references/helpers/storage.md +18 -10
- package/wiki/references/helpers/testing.md +18 -0
- package/wiki/references/helpers/types.md +167 -0
- package/wiki/references/helpers/uid.md +167 -0
- package/wiki/references/helpers/worker-thread.md +16 -0
- package/wiki/references/index.md +177 -0
- package/wiki/references/quick-reference.md +634 -0
- package/wiki/references/src-details/boot.md +3 -3
- package/wiki/references/src-details/dev-configs.md +0 -4
- package/wiki/references/src-details/docs.md +2 -2
- package/wiki/references/src-details/index.md +86 -0
- package/wiki/references/src-details/inversion.md +1 -6
- package/wiki/references/src-details/mcp-server.md +3 -15
- package/wiki/references/utilities/index.md +86 -10
- package/wiki/references/utilities/jsx.md +577 -0
- package/wiki/references/utilities/request.md +0 -2
- package/wiki/references/utilities/statuses.md +740 -0
- package/wiki/changelogs/planned-transaction-support.md +0 -216
- package/wiki/get-started/best-practices/api-usage-examples.md +0 -266
- package/wiki/get-started/best-practices/architectural-patterns.md +0 -170
- package/wiki/get-started/best-practices/data-modeling.md +0 -177
- package/wiki/get-started/best-practices/deployment-strategies.md +0 -121
- package/wiki/get-started/best-practices/performance-optimization.md +0 -88
- package/wiki/get-started/best-practices/security-guidelines.md +0 -99
- package/wiki/get-started/core-concepts/components.md +0 -98
- package/wiki/get-started/core-concepts/persistent.md +0 -543
- package/wiki/get-started/index.md +0 -65
- package/wiki/get-started/philosophy.md +0 -296
- package/wiki/get-started/prerequisites.md +0 -113
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
# Building a Real-Time Chat Application
|
|
2
|
+
|
|
3
|
+
This tutorial shows you how to build a real-time chat application with rooms, direct messages, typing indicators, and presence using Socket.IO.
|
|
4
|
+
|
|
5
|
+
**⏱️ Time to Complete:** ~75 minutes
|
|
6
|
+
|
|
7
|
+
## What You'll Build
|
|
8
|
+
|
|
9
|
+
- Chat rooms (channels)
|
|
10
|
+
- Direct messages between users
|
|
11
|
+
- Typing indicators
|
|
12
|
+
- Online/offline presence
|
|
13
|
+
- Message history with pagination
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
- Completed [Building a CRUD API](./building-a-crud-api.md)
|
|
18
|
+
- Understanding of [Socket.IO Component](/references/components/socket-io)
|
|
19
|
+
- Redis for pub/sub (optional but recommended for scaling)
|
|
20
|
+
|
|
21
|
+
## 1. Project Setup
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
mkdir chat-api
|
|
25
|
+
cd chat-api
|
|
26
|
+
bun init -y
|
|
27
|
+
|
|
28
|
+
# Install dependencies
|
|
29
|
+
bun add hono @hono/zod-openapi @venizia/ignis dotenv-flow
|
|
30
|
+
bun add drizzle-orm drizzle-zod pg socket.io
|
|
31
|
+
bun add -d typescript @types/bun @venizia/dev-configs drizzle-kit @types/pg
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 2. Database Models
|
|
35
|
+
|
|
36
|
+
Models in IGNIS combine Drizzle ORM schemas with Entity classes.
|
|
37
|
+
|
|
38
|
+
### User Model
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// src/models/user.model.ts
|
|
42
|
+
import {
|
|
43
|
+
BaseEntity,
|
|
44
|
+
createRelations,
|
|
45
|
+
generateIdColumnDefs,
|
|
46
|
+
generateTzColumnDefs,
|
|
47
|
+
model,
|
|
48
|
+
TTableObject,
|
|
49
|
+
} from '@venizia/ignis';
|
|
50
|
+
import { pgTable, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core';
|
|
51
|
+
|
|
52
|
+
export const userTable = pgTable('User', {
|
|
53
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
54
|
+
...generateTzColumnDefs(),
|
|
55
|
+
username: varchar('username', { length: 50 }).unique().notNull(),
|
|
56
|
+
displayName: varchar('display_name', { length: 100 }),
|
|
57
|
+
avatar: text('avatar'),
|
|
58
|
+
isOnline: boolean('is_online').default(false).notNull(),
|
|
59
|
+
lastSeenAt: timestamp('last_seen_at'),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export const userRelations = createRelations({
|
|
63
|
+
source: userTable,
|
|
64
|
+
relations: [],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export type TUserSchema = typeof userTable;
|
|
68
|
+
export type TUser = TTableObject<TUserSchema>;
|
|
69
|
+
|
|
70
|
+
@model({ type: 'entity' })
|
|
71
|
+
export class User extends BaseEntity<typeof User.schema> {
|
|
72
|
+
static override schema = userTable;
|
|
73
|
+
static override relations = () => userRelations.definitions;
|
|
74
|
+
static override TABLE_NAME = 'User';
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Room Model
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
// src/models/room.model.ts
|
|
82
|
+
import {
|
|
83
|
+
BaseEntity,
|
|
84
|
+
createRelations,
|
|
85
|
+
generateIdColumnDefs,
|
|
86
|
+
generateTzColumnDefs,
|
|
87
|
+
model,
|
|
88
|
+
TTableObject,
|
|
89
|
+
} from '@venizia/ignis';
|
|
90
|
+
import { pgTable, varchar, text, timestamp, boolean } from 'drizzle-orm/pg-core';
|
|
91
|
+
import { userTable } from './user.model';
|
|
92
|
+
|
|
93
|
+
export const roomTable = pgTable('Room', {
|
|
94
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
95
|
+
...generateTzColumnDefs(),
|
|
96
|
+
name: varchar('name', { length: 100 }).notNull(),
|
|
97
|
+
description: text('description'),
|
|
98
|
+
isPrivate: boolean('is_private').default(false).notNull(),
|
|
99
|
+
createdBy: text('created_by').notNull(),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
export const roomMemberTable = pgTable('RoomMember', {
|
|
103
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
104
|
+
roomId: text('room_id').notNull(),
|
|
105
|
+
userId: text('user_id').notNull(),
|
|
106
|
+
role: varchar('role', { length: 20 }).default('member').notNull(),
|
|
107
|
+
joinedAt: timestamp('joined_at').defaultNow().notNull(),
|
|
108
|
+
lastReadAt: timestamp('last_read_at'),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const roomRelations = createRelations({
|
|
112
|
+
source: roomTable,
|
|
113
|
+
relations: [
|
|
114
|
+
{ type: 'one', name: 'creator', target: () => userTable, fields: ['createdBy'], references: ['id'] },
|
|
115
|
+
{ type: 'many', name: 'members', target: () => roomMemberTable, fields: ['id'], references: ['roomId'] },
|
|
116
|
+
],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const roomMemberRelations = createRelations({
|
|
120
|
+
source: roomMemberTable,
|
|
121
|
+
relations: [
|
|
122
|
+
{ type: 'one', name: 'room', target: () => roomTable, fields: ['roomId'], references: ['id'] },
|
|
123
|
+
{ type: 'one', name: 'user', target: () => userTable, fields: ['userId'], references: ['id'] },
|
|
124
|
+
],
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
export type TRoomSchema = typeof roomTable;
|
|
128
|
+
export type TRoom = TTableObject<TRoomSchema>;
|
|
129
|
+
export type TRoomMemberSchema = typeof roomMemberTable;
|
|
130
|
+
export type TRoomMember = TTableObject<TRoomMemberSchema>;
|
|
131
|
+
|
|
132
|
+
@model({ type: 'entity' })
|
|
133
|
+
export class Room extends BaseEntity<typeof Room.schema> {
|
|
134
|
+
static override schema = roomTable;
|
|
135
|
+
static override relations = () => roomRelations.definitions;
|
|
136
|
+
static override TABLE_NAME = 'Room';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
@model({ type: 'entity' })
|
|
140
|
+
export class RoomMember extends BaseEntity<typeof RoomMember.schema> {
|
|
141
|
+
static override schema = roomMemberTable;
|
|
142
|
+
static override relations = () => roomMemberRelations.definitions;
|
|
143
|
+
static override TABLE_NAME = 'RoomMember';
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Message Model
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
// src/models/message.model.ts
|
|
151
|
+
import {
|
|
152
|
+
BaseEntity,
|
|
153
|
+
createRelations,
|
|
154
|
+
generateIdColumnDefs,
|
|
155
|
+
generateTzColumnDefs,
|
|
156
|
+
model,
|
|
157
|
+
TTableObject,
|
|
158
|
+
} from '@venizia/ignis';
|
|
159
|
+
import { pgTable, text, timestamp, varchar } from 'drizzle-orm/pg-core';
|
|
160
|
+
import { userTable } from './user.model';
|
|
161
|
+
import { roomTable } from './room.model';
|
|
162
|
+
|
|
163
|
+
export const messageTable = pgTable('Message', {
|
|
164
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
165
|
+
...generateTzColumnDefs(),
|
|
166
|
+
roomId: text('room_id').notNull(),
|
|
167
|
+
senderId: text('sender_id').notNull(),
|
|
168
|
+
content: text('content').notNull(),
|
|
169
|
+
type: varchar('type', { length: 20 }).default('text').notNull(),
|
|
170
|
+
metadata: text('metadata'),
|
|
171
|
+
editedAt: timestamp('edited_at'),
|
|
172
|
+
deletedAt: timestamp('deleted_at'),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
export const directMessageTable = pgTable('DirectMessage', {
|
|
176
|
+
...generateIdColumnDefs({ id: { dataType: 'string' } }),
|
|
177
|
+
...generateTzColumnDefs(),
|
|
178
|
+
senderId: text('sender_id').notNull(),
|
|
179
|
+
receiverId: text('receiver_id').notNull(),
|
|
180
|
+
content: text('content').notNull(),
|
|
181
|
+
type: varchar('type', { length: 20 }).default('text').notNull(),
|
|
182
|
+
metadata: text('metadata'),
|
|
183
|
+
readAt: timestamp('read_at'),
|
|
184
|
+
deletedAt: timestamp('deleted_at'),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
export const messageRelations = createRelations({
|
|
188
|
+
source: messageTable,
|
|
189
|
+
relations: [
|
|
190
|
+
{ type: 'one', name: 'room', target: () => roomTable, fields: ['roomId'], references: ['id'] },
|
|
191
|
+
{ type: 'one', name: 'sender', target: () => userTable, fields: ['senderId'], references: ['id'] },
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
export const directMessageRelations = createRelations({
|
|
196
|
+
source: directMessageTable,
|
|
197
|
+
relations: [
|
|
198
|
+
{ type: 'one', name: 'sender', target: () => userTable, fields: ['senderId'], references: ['id'] },
|
|
199
|
+
{ type: 'one', name: 'receiver', target: () => userTable, fields: ['receiverId'], references: ['id'] },
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
export type TMessageSchema = typeof messageTable;
|
|
204
|
+
export type TMessage = TTableObject<TMessageSchema>;
|
|
205
|
+
export type TDirectMessageSchema = typeof directMessageTable;
|
|
206
|
+
export type TDirectMessage = TTableObject<TDirectMessageSchema>;
|
|
207
|
+
|
|
208
|
+
@model({ type: 'entity' })
|
|
209
|
+
export class Message extends BaseEntity<typeof Message.schema> {
|
|
210
|
+
static override schema = messageTable;
|
|
211
|
+
static override relations = () => messageRelations.definitions;
|
|
212
|
+
static override TABLE_NAME = 'Message';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@model({ type: 'entity' })
|
|
216
|
+
export class DirectMessage extends BaseEntity<typeof DirectMessage.schema> {
|
|
217
|
+
static override schema = directMessageTable;
|
|
218
|
+
static override relations = () => directMessageRelations.definitions;
|
|
219
|
+
static override TABLE_NAME = 'DirectMessage';
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
### Models Index
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
// src/models/index.ts
|
|
227
|
+
export * from './user.model';
|
|
228
|
+
export * from './room.model';
|
|
229
|
+
export * from './message.model';
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
## 3. DataSource
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
// src/datasources/postgres.datasource.ts
|
|
236
|
+
import {
|
|
237
|
+
BaseDataSource,
|
|
238
|
+
datasource,
|
|
239
|
+
TNodePostgresConnector,
|
|
240
|
+
ValueOrPromise,
|
|
241
|
+
} from '@venizia/ignis';
|
|
242
|
+
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
243
|
+
import { Pool } from 'pg';
|
|
244
|
+
|
|
245
|
+
interface IDSConfigs {
|
|
246
|
+
host: string;
|
|
247
|
+
port: number;
|
|
248
|
+
database: string;
|
|
249
|
+
user: string;
|
|
250
|
+
password: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@datasource({ driver: 'node-postgres' })
|
|
254
|
+
export class PostgresDataSource extends BaseDataSource<TNodePostgresConnector, IDSConfigs> {
|
|
255
|
+
constructor() {
|
|
256
|
+
super({
|
|
257
|
+
name: PostgresDataSource.name,
|
|
258
|
+
config: {
|
|
259
|
+
host: process.env.APP_ENV_POSTGRES_HOST ?? 'localhost',
|
|
260
|
+
port: +(process.env.APP_ENV_POSTGRES_PORT ?? 5432),
|
|
261
|
+
database: process.env.APP_ENV_POSTGRES_DATABASE ?? 'chat_db',
|
|
262
|
+
user: process.env.APP_ENV_POSTGRES_USERNAME ?? 'postgres',
|
|
263
|
+
password: process.env.APP_ENV_POSTGRES_PASSWORD ?? '',
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
override configure(): ValueOrPromise<void> {
|
|
269
|
+
const schema = this.getSchema();
|
|
270
|
+
|
|
271
|
+
this.logger.debug(
|
|
272
|
+
'[configure] Auto-discovered schema | Schema + Relations (%s): %o',
|
|
273
|
+
Object.keys(schema).length,
|
|
274
|
+
Object.keys(schema),
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const client = new Pool(this.settings);
|
|
278
|
+
this.connector = drizzle({ client, schema });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
## 4. Repositories
|
|
284
|
+
|
|
285
|
+
### User Repository
|
|
286
|
+
|
|
287
|
+
```typescript
|
|
288
|
+
// src/repositories/user.repository.ts
|
|
289
|
+
import { User } from '@/models/user.model';
|
|
290
|
+
import { PostgresDataSource } from '@/datasources/postgres.datasource';
|
|
291
|
+
import { DefaultCRUDRepository, repository } from '@venizia/ignis';
|
|
292
|
+
|
|
293
|
+
@repository({ model: User, dataSource: PostgresDataSource })
|
|
294
|
+
export class UserRepository extends DefaultCRUDRepository<typeof User.schema> {}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Room Repository
|
|
298
|
+
|
|
299
|
+
```typescript
|
|
300
|
+
// src/repositories/room.repository.ts
|
|
301
|
+
import { Room, RoomMember } from '@/models/room.model';
|
|
302
|
+
import { PostgresDataSource } from '@/datasources/postgres.datasource';
|
|
303
|
+
import { DefaultCRUDRepository, repository, inject } from '@venizia/ignis';
|
|
304
|
+
|
|
305
|
+
@repository({ model: RoomMember, dataSource: PostgresDataSource })
|
|
306
|
+
export class RoomMemberRepository extends DefaultCRUDRepository<typeof RoomMember.schema> {}
|
|
307
|
+
|
|
308
|
+
@repository({ model: Room, dataSource: PostgresDataSource })
|
|
309
|
+
export class RoomRepository extends DefaultCRUDRepository<typeof Room.schema> {
|
|
310
|
+
constructor(
|
|
311
|
+
// First parameter MUST be DataSource injection
|
|
312
|
+
@inject({ key: 'datasources.PostgresDataSource' })
|
|
313
|
+
dataSource: PostgresDataSource,
|
|
314
|
+
|
|
315
|
+
// From 2nd parameter, inject additional dependencies
|
|
316
|
+
@inject({ key: 'repositories.RoomMemberRepository' })
|
|
317
|
+
private _memberRepo: RoomMemberRepository,
|
|
318
|
+
) {
|
|
319
|
+
super(dataSource);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async findByUser(opts: { userId: string }) {
|
|
323
|
+
const memberships = await this._memberRepo.find({
|
|
324
|
+
where: { userId: opts.userId },
|
|
325
|
+
include: { room: true },
|
|
326
|
+
});
|
|
327
|
+
return memberships.map(m => m.room);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async isMember(opts: { roomId: string; userId: string }): Promise<boolean> {
|
|
331
|
+
const member = await this._memberRepo.findOne({
|
|
332
|
+
where: { roomId: opts.roomId, userId: opts.userId },
|
|
333
|
+
});
|
|
334
|
+
return !!member;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async addMember(opts: { roomId: string; userId: string; role?: string }) {
|
|
338
|
+
return this._memberRepo.create({
|
|
339
|
+
roomId: opts.roomId,
|
|
340
|
+
userId: opts.userId,
|
|
341
|
+
role: opts.role ?? 'member',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async removeMember(opts: { roomId: string; userId: string }) {
|
|
346
|
+
return this._memberRepo.deleteAll({
|
|
347
|
+
where: { roomId: opts.roomId, userId: opts.userId },
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async getMember(opts: { roomId: string; userId: string }) {
|
|
352
|
+
return this._memberRepo.findOne({
|
|
353
|
+
where: { roomId: opts.roomId, userId: opts.userId },
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async getMembers(opts: { roomId: string }) {
|
|
358
|
+
return this._memberRepo.find({
|
|
359
|
+
where: { roomId: opts.roomId },
|
|
360
|
+
include: { user: true },
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Message Repository
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
// src/repositories/message.repository.ts
|
|
370
|
+
import { Message, DirectMessage } from '@/models/message.model';
|
|
371
|
+
import { PostgresDataSource } from '@/datasources/postgres.datasource';
|
|
372
|
+
import { DefaultCRUDRepository, repository, inject } from '@venizia/ignis';
|
|
373
|
+
|
|
374
|
+
@repository({ model: DirectMessage, dataSource: PostgresDataSource })
|
|
375
|
+
export class DirectMessageRepository extends DefaultCRUDRepository<typeof DirectMessage.schema> {}
|
|
376
|
+
|
|
377
|
+
@repository({ model: Message, dataSource: PostgresDataSource })
|
|
378
|
+
export class MessageRepository extends DefaultCRUDRepository<typeof Message.schema> {
|
|
379
|
+
constructor(
|
|
380
|
+
// First parameter MUST be DataSource injection
|
|
381
|
+
@inject({ key: 'datasources.PostgresDataSource' })
|
|
382
|
+
dataSource: PostgresDataSource,
|
|
383
|
+
|
|
384
|
+
// From 2nd parameter, inject additional dependencies
|
|
385
|
+
@inject({ key: 'repositories.DirectMessageRepository' })
|
|
386
|
+
private _dmRepo: DirectMessageRepository,
|
|
387
|
+
) {
|
|
388
|
+
super(dataSource);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async createDirectMessage(opts: { senderId: string; receiverId: string; content: string }) {
|
|
392
|
+
return this._dmRepo.create(opts);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async findDirectMessages(opts: {
|
|
396
|
+
userId1: string;
|
|
397
|
+
userId2: string;
|
|
398
|
+
limit?: number;
|
|
399
|
+
before?: string;
|
|
400
|
+
}) {
|
|
401
|
+
return this._dmRepo.find({
|
|
402
|
+
where: {
|
|
403
|
+
or: [
|
|
404
|
+
{ senderId: opts.userId1, receiverId: opts.userId2 },
|
|
405
|
+
{ senderId: opts.userId2, receiverId: opts.userId1 },
|
|
406
|
+
],
|
|
407
|
+
},
|
|
408
|
+
orderBy: { createdAt: 'desc' },
|
|
409
|
+
limit: opts.limit ?? 50,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async findConversations(opts: { userId: string }) {
|
|
414
|
+
// Get unique conversation partners
|
|
415
|
+
const sent = await this._dmRepo.find({
|
|
416
|
+
where: { senderId: opts.userId },
|
|
417
|
+
include: { receiver: true },
|
|
418
|
+
});
|
|
419
|
+
const received = await this._dmRepo.find({
|
|
420
|
+
where: { receiverId: opts.userId },
|
|
421
|
+
include: { sender: true },
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Combine and deduplicate
|
|
425
|
+
const partners = new Map();
|
|
426
|
+
sent.forEach(m => partners.set(m.receiverId, m.receiver));
|
|
427
|
+
received.forEach(m => partners.set(m.senderId, m.sender));
|
|
428
|
+
|
|
429
|
+
return Array.from(partners.values());
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
## 5. Socket.IO Events
|
|
435
|
+
|
|
436
|
+
Define your event types:
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
// src/types/socket.types.ts
|
|
440
|
+
|
|
441
|
+
// Client -> Server events
|
|
442
|
+
export interface ClientToServerEvents {
|
|
443
|
+
// Rooms
|
|
444
|
+
'room:join': (roomId: string) => void;
|
|
445
|
+
'room:leave': (roomId: string) => void;
|
|
446
|
+
|
|
447
|
+
// Messages
|
|
448
|
+
'message:send': (data: { roomId: string; content: string; type?: string }) => void;
|
|
449
|
+
'message:edit': (data: { messageId: string; content: string }) => void;
|
|
450
|
+
'message:delete': (data: { messageId: string }) => void;
|
|
451
|
+
|
|
452
|
+
// Direct messages
|
|
453
|
+
'dm:send': (data: { receiverId: string; content: string }) => void;
|
|
454
|
+
|
|
455
|
+
// Typing
|
|
456
|
+
'typing:start': (roomId: string) => void;
|
|
457
|
+
'typing:stop': (roomId: string) => void;
|
|
458
|
+
|
|
459
|
+
// Presence
|
|
460
|
+
'presence:update': (status: 'online' | 'away' | 'busy') => void;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Server -> Client events
|
|
464
|
+
export interface ServerToClientEvents {
|
|
465
|
+
// Messages
|
|
466
|
+
'message:new': (message: Message) => void;
|
|
467
|
+
'message:edited': (message: Message) => void;
|
|
468
|
+
'message:deleted': (data: { messageId: string; roomId: string }) => void;
|
|
469
|
+
|
|
470
|
+
// Direct messages
|
|
471
|
+
'dm:new': (message: DirectMessage) => void;
|
|
472
|
+
|
|
473
|
+
// Typing
|
|
474
|
+
'typing:update': (data: { roomId: string; userId: string; isTyping: boolean }) => void;
|
|
475
|
+
|
|
476
|
+
// Presence
|
|
477
|
+
'presence:changed': (data: { userId: string; status: string; lastSeenAt?: Date }) => void;
|
|
478
|
+
|
|
479
|
+
// Room
|
|
480
|
+
'room:user-joined': (data: { roomId: string; user: User }) => void;
|
|
481
|
+
'room:user-left': (data: { roomId: string; userId: string }) => void;
|
|
482
|
+
|
|
483
|
+
// Errors
|
|
484
|
+
'error': (error: { code: string; message: string }) => void;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export interface InterServerEvents {
|
|
488
|
+
ping: () => void;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export interface SocketData {
|
|
492
|
+
userId: string;
|
|
493
|
+
username: string;
|
|
494
|
+
}
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
## 6. Chat Service
|
|
498
|
+
|
|
499
|
+
```typescript
|
|
500
|
+
// src/services/chat.service.ts
|
|
501
|
+
import { injectable, inject } from '@venizia/ignis';
|
|
502
|
+
import { BaseService } from '@venizia/ignis';
|
|
503
|
+
import { MessageRepository } from '../repositories/message.repository';
|
|
504
|
+
import { RoomRepository } from '../repositories/room.repository';
|
|
505
|
+
import { UserRepository } from '../repositories/user.repository';
|
|
506
|
+
import { getError } from '@venizia/ignis-helpers';
|
|
507
|
+
|
|
508
|
+
@injectable()
|
|
509
|
+
export class ChatService extends BaseService {
|
|
510
|
+
constructor(
|
|
511
|
+
@inject('repositories.MessageRepository')
|
|
512
|
+
private _messageRepo: MessageRepository,
|
|
513
|
+
@inject('repositories.RoomRepository')
|
|
514
|
+
private _roomRepo: RoomRepository,
|
|
515
|
+
@inject('repositories.UserRepository')
|
|
516
|
+
private _userRepo: UserRepository,
|
|
517
|
+
) {
|
|
518
|
+
super({ scope: ChatService.name });
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Room operations
|
|
522
|
+
async createRoom(opts: { name: string; description?: string; isPrivate?: boolean; createdBy: string }) {
|
|
523
|
+
const room = await this._roomRepo.create(opts);
|
|
524
|
+
|
|
525
|
+
// Add creator as admin
|
|
526
|
+
await this._roomRepo.addMember({ roomId: room.id, userId: opts.createdBy, role: 'admin' });
|
|
527
|
+
|
|
528
|
+
return room;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async joinRoom(opts: { roomId: string; userId: string }) {
|
|
532
|
+
const room = await this._roomRepo.findById(opts.roomId);
|
|
533
|
+
if (!room) {
|
|
534
|
+
throw getError({ statusCode: 404, message: 'Room not found' });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Check if private room requires invitation
|
|
538
|
+
if (room.isPrivate) {
|
|
539
|
+
const isMember = await this._roomRepo.isMember({ roomId: opts.roomId, userId: opts.userId });
|
|
540
|
+
if (!isMember) {
|
|
541
|
+
throw getError({ statusCode: 403, message: 'Cannot join private room' });
|
|
542
|
+
}
|
|
543
|
+
} else {
|
|
544
|
+
// Auto-join public rooms
|
|
545
|
+
await this._roomRepo.addMember({ roomId: opts.roomId, userId: opts.userId, role: 'member' });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return room;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async leaveRoom(opts: { roomId: string; userId: string }) {
|
|
552
|
+
await this._roomRepo.removeMember({ roomId: opts.roomId, userId: opts.userId });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async getUserRooms(opts: { userId: string }) {
|
|
556
|
+
return this._roomRepo.findByUser({ userId: opts.userId });
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Message operations
|
|
560
|
+
async sendMessage(opts: { roomId: string; senderId: string; content: string; type?: string }) {
|
|
561
|
+
// Verify user is member of room
|
|
562
|
+
const isMember = await this._roomRepo.isMember({ roomId: opts.roomId, userId: opts.senderId });
|
|
563
|
+
if (!isMember) {
|
|
564
|
+
throw getError({ statusCode: 403, message: 'Not a member of this room' });
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const message = await this._messageRepo.create({
|
|
568
|
+
roomId: opts.roomId,
|
|
569
|
+
senderId: opts.senderId,
|
|
570
|
+
content: opts.content,
|
|
571
|
+
type: opts.type ?? 'text',
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
// Get sender info for the response
|
|
575
|
+
const sender = await this._userRepo.findById(opts.senderId);
|
|
576
|
+
|
|
577
|
+
return {
|
|
578
|
+
...message,
|
|
579
|
+
sender: {
|
|
580
|
+
id: sender.id,
|
|
581
|
+
username: sender.username,
|
|
582
|
+
displayName: sender.displayName,
|
|
583
|
+
avatar: sender.avatar,
|
|
584
|
+
},
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async editMessage(opts: { messageId: string; userId: string; content: string }) {
|
|
589
|
+
const message = await this._messageRepo.findById(opts.messageId);
|
|
590
|
+
|
|
591
|
+
if (!message) {
|
|
592
|
+
throw getError({ statusCode: 404, message: 'Message not found' });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (message.senderId !== opts.userId) {
|
|
596
|
+
throw getError({ statusCode: 403, message: 'Cannot edit others messages' });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return this._messageRepo.updateById(opts.messageId, {
|
|
600
|
+
content: opts.content,
|
|
601
|
+
editedAt: new Date(),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
async deleteMessage(opts: { messageId: string; userId: string }) {
|
|
606
|
+
const message = await this._messageRepo.findById(opts.messageId);
|
|
607
|
+
|
|
608
|
+
if (!message) {
|
|
609
|
+
throw getError({ statusCode: 404, message: 'Message not found' });
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (message.senderId !== opts.userId) {
|
|
613
|
+
// Check if user is room admin
|
|
614
|
+
const member = await this._roomRepo.getMember({ roomId: message.roomId, userId: opts.userId });
|
|
615
|
+
if (!member || member.role !== 'admin') {
|
|
616
|
+
throw getError({ statusCode: 403, message: 'Cannot delete this message' });
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return this._messageRepo.updateById(opts.messageId, {
|
|
621
|
+
deletedAt: new Date(),
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async getMessages(opts: { roomId: string; limit?: number; before?: string }) {
|
|
626
|
+
const where: any = {
|
|
627
|
+
roomId: opts.roomId,
|
|
628
|
+
deletedAt: null,
|
|
629
|
+
};
|
|
630
|
+
|
|
631
|
+
if (opts.before) {
|
|
632
|
+
// Cursor-based pagination
|
|
633
|
+
const beforeMessage = await this._messageRepo.findById(opts.before);
|
|
634
|
+
if (beforeMessage) {
|
|
635
|
+
where.createdAt = { lt: beforeMessage.createdAt };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return this._messageRepo.find({
|
|
640
|
+
where,
|
|
641
|
+
orderBy: { createdAt: 'desc' },
|
|
642
|
+
limit: opts.limit ?? 50,
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Direct message operations
|
|
647
|
+
async sendDirectMessage(opts: { senderId: string; receiverId: string; content: string }) {
|
|
648
|
+
return this._messageRepo.createDirectMessage(opts);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async getDirectMessages(opts: { userId1: string; userId2: string; limit?: number; before?: string }) {
|
|
652
|
+
return this._messageRepo.findDirectMessages({
|
|
653
|
+
userId1: opts.userId1,
|
|
654
|
+
userId2: opts.userId2,
|
|
655
|
+
limit: opts.limit,
|
|
656
|
+
before: opts.before,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async getConversations(opts: { userId: string }) {
|
|
661
|
+
return this._messageRepo.findConversations({ userId: opts.userId });
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Presence operations
|
|
665
|
+
async setOnline(opts: { userId: string }) {
|
|
666
|
+
await this._userRepo.updateById(opts.userId, {
|
|
667
|
+
isOnline: true,
|
|
668
|
+
lastSeenAt: new Date(),
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async setOffline(opts: { userId: string }) {
|
|
673
|
+
await this._userRepo.updateById(opts.userId, {
|
|
674
|
+
isOnline: false,
|
|
675
|
+
lastSeenAt: new Date(),
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async getOnlineUsers(opts: { roomId: string }) {
|
|
680
|
+
const members = await this._roomRepo.getMembers({ roomId: opts.roomId });
|
|
681
|
+
return members.filter(m => m.user.isOnline);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
## 7. Socket.IO Handler
|
|
687
|
+
|
|
688
|
+
```typescript
|
|
689
|
+
// src/socket/chat.handler.ts
|
|
690
|
+
import { Server, Socket } from 'socket.io';
|
|
691
|
+
import { ChatService } from '../services/chat.service';
|
|
692
|
+
import { RedisHelper, LoggerFactory } from '@venizia/ignis-helpers';
|
|
693
|
+
import {
|
|
694
|
+
ClientToServerEvents,
|
|
695
|
+
ServerToClientEvents,
|
|
696
|
+
InterServerEvents,
|
|
697
|
+
SocketData,
|
|
698
|
+
} from '../types/socket.types';
|
|
699
|
+
|
|
700
|
+
type ChatSocket = Socket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
|
701
|
+
type ChatServer = Server<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
|
|
702
|
+
|
|
703
|
+
export class ChatSocketHandler {
|
|
704
|
+
private _logger = LoggerFactory.getLogger(['ChatSocketHandler']);
|
|
705
|
+
private _typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
|
706
|
+
|
|
707
|
+
constructor(
|
|
708
|
+
private _io: ChatServer,
|
|
709
|
+
private _chatService: ChatService,
|
|
710
|
+
) {}
|
|
711
|
+
|
|
712
|
+
setupHandlers(socket: ChatSocket) {
|
|
713
|
+
const userId = socket.data.userId;
|
|
714
|
+
const username = socket.data.username;
|
|
715
|
+
|
|
716
|
+
this._logger.info('User connected', { userId, username, socketId: socket.id });
|
|
717
|
+
|
|
718
|
+
// Set user online
|
|
719
|
+
this._chatService.setOnline({ userId });
|
|
720
|
+
this.broadcastPresence({ userId, status: 'online' });
|
|
721
|
+
|
|
722
|
+
// Room handlers
|
|
723
|
+
socket.on('room:join', async (roomId) => {
|
|
724
|
+
try {
|
|
725
|
+
await this._chatService.joinRoom({ roomId, userId });
|
|
726
|
+
socket.join(`room:${roomId}`);
|
|
727
|
+
|
|
728
|
+
// Notify room members
|
|
729
|
+
socket.to(`room:${roomId}`).emit('room:user-joined', {
|
|
730
|
+
roomId,
|
|
731
|
+
user: { id: userId, username },
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
this._logger.info('User joined room', { userId, roomId });
|
|
735
|
+
} catch (error) {
|
|
736
|
+
socket.emit('error', { code: 'JOIN_FAILED', message: error.message });
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
socket.on('room:leave', async (roomId) => {
|
|
741
|
+
socket.leave(`room:${roomId}`);
|
|
742
|
+
socket.to(`room:${roomId}`).emit('room:user-left', { roomId, userId });
|
|
743
|
+
this._logger.info('User left room', { userId, roomId });
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
// Message handlers
|
|
747
|
+
socket.on('message:send', async (data) => {
|
|
748
|
+
try {
|
|
749
|
+
const message = await this._chatService.sendMessage({
|
|
750
|
+
roomId: data.roomId,
|
|
751
|
+
senderId: userId,
|
|
752
|
+
content: data.content,
|
|
753
|
+
type: data.type,
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Broadcast to room
|
|
757
|
+
this._io.to(`room:${data.roomId}`).emit('message:new', message);
|
|
758
|
+
|
|
759
|
+
// Clear typing indicator
|
|
760
|
+
this.clearTyping({ socket, roomId: data.roomId });
|
|
761
|
+
} catch (error) {
|
|
762
|
+
socket.emit('error', { code: 'SEND_FAILED', message: error.message });
|
|
763
|
+
}
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
socket.on('message:edit', async (data) => {
|
|
767
|
+
try {
|
|
768
|
+
const message = await this._chatService.editMessage({
|
|
769
|
+
messageId: data.messageId,
|
|
770
|
+
userId,
|
|
771
|
+
content: data.content,
|
|
772
|
+
});
|
|
773
|
+
this._io.to(`room:${message.roomId}`).emit('message:edited', message);
|
|
774
|
+
} catch (error) {
|
|
775
|
+
socket.emit('error', { code: 'EDIT_FAILED', message: error.message });
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
socket.on('message:delete', async (data) => {
|
|
780
|
+
try {
|
|
781
|
+
const message = await this._chatService.deleteMessage({ messageId: data.messageId, userId });
|
|
782
|
+
this._io.to(`room:${message.roomId}`).emit('message:deleted', {
|
|
783
|
+
messageId: data.messageId,
|
|
784
|
+
roomId: message.roomId,
|
|
785
|
+
});
|
|
786
|
+
} catch (error) {
|
|
787
|
+
socket.emit('error', { code: 'DELETE_FAILED', message: error.message });
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// Direct message handlers
|
|
792
|
+
socket.on('dm:send', async (data) => {
|
|
793
|
+
try {
|
|
794
|
+
const message = await this._chatService.sendDirectMessage({
|
|
795
|
+
senderId: userId,
|
|
796
|
+
receiverId: data.receiverId,
|
|
797
|
+
content: data.content,
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
// Send to receiver (if online)
|
|
801
|
+
this._io.to(`user:${data.receiverId}`).emit('dm:new', message);
|
|
802
|
+
|
|
803
|
+
// Send back to sender
|
|
804
|
+
socket.emit('dm:new', message);
|
|
805
|
+
} catch (error) {
|
|
806
|
+
socket.emit('error', { code: 'DM_FAILED', message: error.message });
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
// Typing handlers
|
|
811
|
+
socket.on('typing:start', (roomId) => {
|
|
812
|
+
this.handleTyping({ socket, roomId, isTyping: true });
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
socket.on('typing:stop', (roomId) => {
|
|
816
|
+
this.handleTyping({ socket, roomId, isTyping: false });
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
// Presence handlers
|
|
820
|
+
socket.on('presence:update', (status) => {
|
|
821
|
+
this.broadcastPresence({ userId, status });
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
// Disconnect
|
|
825
|
+
socket.on('disconnect', () => {
|
|
826
|
+
this._logger.info('User disconnected', { userId, socketId: socket.id });
|
|
827
|
+
this._chatService.setOffline({ userId });
|
|
828
|
+
this.broadcastPresence({ userId, status: 'offline' });
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
// Join user's personal room for DMs
|
|
832
|
+
socket.join(`user:${userId}`);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
private handleTyping(opts: { socket: ChatSocket; roomId: string; isTyping: boolean }) {
|
|
836
|
+
const userId = opts.socket.data.userId;
|
|
837
|
+
const key = `${opts.roomId}:${userId}`;
|
|
838
|
+
|
|
839
|
+
// Clear existing timeout
|
|
840
|
+
const existingTimeout = this._typingTimeouts.get(key);
|
|
841
|
+
if (existingTimeout) {
|
|
842
|
+
clearTimeout(existingTimeout);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Broadcast typing status
|
|
846
|
+
opts.socket.to(`room:${opts.roomId}`).emit('typing:update', {
|
|
847
|
+
roomId: opts.roomId,
|
|
848
|
+
userId,
|
|
849
|
+
isTyping: opts.isTyping,
|
|
850
|
+
});
|
|
851
|
+
|
|
852
|
+
if (opts.isTyping) {
|
|
853
|
+
// Auto-stop typing after 3 seconds
|
|
854
|
+
const timeout = setTimeout(() => {
|
|
855
|
+
opts.socket.to(`room:${opts.roomId}`).emit('typing:update', {
|
|
856
|
+
roomId: opts.roomId,
|
|
857
|
+
userId,
|
|
858
|
+
isTyping: false,
|
|
859
|
+
});
|
|
860
|
+
this._typingTimeouts.delete(key);
|
|
861
|
+
}, 3000);
|
|
862
|
+
|
|
863
|
+
this._typingTimeouts.set(key, timeout);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private clearTyping(opts: { socket: ChatSocket; roomId: string }) {
|
|
868
|
+
const userId = opts.socket.data.userId;
|
|
869
|
+
const key = `${opts.roomId}:${userId}`;
|
|
870
|
+
|
|
871
|
+
const timeout = this._typingTimeouts.get(key);
|
|
872
|
+
if (timeout) {
|
|
873
|
+
clearTimeout(timeout);
|
|
874
|
+
this._typingTimeouts.delete(key);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
opts.socket.to(`room:${opts.roomId}`).emit('typing:update', {
|
|
878
|
+
roomId: opts.roomId,
|
|
879
|
+
userId,
|
|
880
|
+
isTyping: false,
|
|
881
|
+
});
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
private broadcastPresence(opts: { userId: string; status: string }) {
|
|
885
|
+
this._io.emit('presence:changed', {
|
|
886
|
+
userId: opts.userId,
|
|
887
|
+
status: opts.status,
|
|
888
|
+
lastSeenAt: new Date(),
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
## 8. Application Setup
|
|
895
|
+
|
|
896
|
+
```typescript
|
|
897
|
+
// src/application.ts
|
|
898
|
+
import { BaseApplication, IApplicationInfo, SocketIOComponent } from '@venizia/ignis';
|
|
899
|
+
import { EnvHelper } from '@venizia/ignis-helpers';
|
|
900
|
+
import { Server } from 'socket.io';
|
|
901
|
+
import { ChatSocketHandler } from './socket/chat.handler';
|
|
902
|
+
import { ChatService } from './services/chat.service';
|
|
903
|
+
import { verifyToken } from './middleware/auth';
|
|
904
|
+
|
|
905
|
+
export class ChatApp extends BaseApplication {
|
|
906
|
+
private _io: Server;
|
|
907
|
+
private _chatHandler: ChatSocketHandler;
|
|
908
|
+
|
|
909
|
+
getAppInfo(): IApplicationInfo {
|
|
910
|
+
return { name: 'chat-api', version: '1.0.0' };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
staticConfigure() {}
|
|
914
|
+
|
|
915
|
+
preConfigure() {
|
|
916
|
+
// Register services and repositories
|
|
917
|
+
this.service(ChatService);
|
|
918
|
+
// ... other bindings
|
|
919
|
+
|
|
920
|
+
// Add Socket.IO component
|
|
921
|
+
this.component(SocketIOComponent);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
postConfigure() {
|
|
925
|
+
this.setupSocketIO();
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
setupMiddlewares() {}
|
|
929
|
+
|
|
930
|
+
private setupSocketIO() {
|
|
931
|
+
const httpServer = this.getHttpServer();
|
|
932
|
+
|
|
933
|
+
this._io = new Server(httpServer, {
|
|
934
|
+
cors: {
|
|
935
|
+
origin: EnvHelper.get('APP_ENV_CORS_ORIGIN') ?? '*',
|
|
936
|
+
methods: ['GET', 'POST'],
|
|
937
|
+
},
|
|
938
|
+
});
|
|
939
|
+
|
|
940
|
+
// Authentication middleware
|
|
941
|
+
this._io.use(async (socket, next) => {
|
|
942
|
+
try {
|
|
943
|
+
const token = socket.handshake.auth.token;
|
|
944
|
+
if (!token) {
|
|
945
|
+
return next(new Error('Authentication required'));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const user = await verifyToken(token);
|
|
949
|
+
socket.data.userId = user.id;
|
|
950
|
+
socket.data.username = user.username;
|
|
951
|
+
next();
|
|
952
|
+
} catch (error) {
|
|
953
|
+
next(new Error('Invalid token'));
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
// Get chat service from container
|
|
958
|
+
const chatService = this.container.get<ChatService>('services.ChatService');
|
|
959
|
+
this._chatHandler = new ChatSocketHandler(this._io, chatService);
|
|
960
|
+
|
|
961
|
+
// Handle connections
|
|
962
|
+
this._io.on('connection', (socket) => {
|
|
963
|
+
this._chatHandler.setupHandlers(socket);
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
## 9. REST API for Chat History
|
|
970
|
+
|
|
971
|
+
```typescript
|
|
972
|
+
// src/controllers/chat.controller.ts
|
|
973
|
+
import { z } from '@hono/zod-openapi';
|
|
974
|
+
import {
|
|
975
|
+
BaseController,
|
|
976
|
+
controller,
|
|
977
|
+
get,
|
|
978
|
+
post,
|
|
979
|
+
inject,
|
|
980
|
+
HTTP,
|
|
981
|
+
jsonContent,
|
|
982
|
+
TRouteContext,
|
|
983
|
+
} from '@venizia/ignis';
|
|
984
|
+
import { ChatService } from '../services/chat.service';
|
|
985
|
+
|
|
986
|
+
const ChatRoutes = {
|
|
987
|
+
GET_ROOMS: {
|
|
988
|
+
method: HTTP.Methods.GET,
|
|
989
|
+
path: '/rooms',
|
|
990
|
+
responses: {
|
|
991
|
+
[HTTP.ResultCodes.RS_2.Ok]: jsonContent({
|
|
992
|
+
description: 'User rooms',
|
|
993
|
+
schema: z.array(z.any()),
|
|
994
|
+
}),
|
|
995
|
+
},
|
|
996
|
+
},
|
|
997
|
+
CREATE_ROOM: {
|
|
998
|
+
method: HTTP.Methods.POST,
|
|
999
|
+
path: '/rooms',
|
|
1000
|
+
request: {
|
|
1001
|
+
body: jsonContent({
|
|
1002
|
+
schema: z.object({
|
|
1003
|
+
name: z.string().min(1).max(100),
|
|
1004
|
+
description: z.string().optional(),
|
|
1005
|
+
isPrivate: z.boolean().default(false),
|
|
1006
|
+
}),
|
|
1007
|
+
}),
|
|
1008
|
+
},
|
|
1009
|
+
responses: {
|
|
1010
|
+
[HTTP.ResultCodes.RS_2.Created]: jsonContent({
|
|
1011
|
+
description: 'Created room',
|
|
1012
|
+
schema: z.any(),
|
|
1013
|
+
}),
|
|
1014
|
+
},
|
|
1015
|
+
},
|
|
1016
|
+
GET_MESSAGES: {
|
|
1017
|
+
method: HTTP.Methods.GET,
|
|
1018
|
+
path: '/rooms/:roomId/messages',
|
|
1019
|
+
request: {
|
|
1020
|
+
params: z.object({ roomId: z.string().uuid() }),
|
|
1021
|
+
query: z.object({
|
|
1022
|
+
limit: z.string().optional(),
|
|
1023
|
+
before: z.string().optional(),
|
|
1024
|
+
}),
|
|
1025
|
+
},
|
|
1026
|
+
responses: {
|
|
1027
|
+
[HTTP.ResultCodes.RS_2.Ok]: jsonContent({
|
|
1028
|
+
description: 'Room messages',
|
|
1029
|
+
schema: z.array(z.any()),
|
|
1030
|
+
}),
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
GET_CONVERSATIONS: {
|
|
1034
|
+
method: HTTP.Methods.GET,
|
|
1035
|
+
path: '/conversations',
|
|
1036
|
+
responses: {
|
|
1037
|
+
[HTTP.ResultCodes.RS_2.Ok]: jsonContent({
|
|
1038
|
+
description: 'User conversations',
|
|
1039
|
+
schema: z.array(z.any()),
|
|
1040
|
+
}),
|
|
1041
|
+
},
|
|
1042
|
+
},
|
|
1043
|
+
GET_DIRECT_MESSAGES: {
|
|
1044
|
+
method: HTTP.Methods.GET,
|
|
1045
|
+
path: '/dm/:userId',
|
|
1046
|
+
request: {
|
|
1047
|
+
params: z.object({ userId: z.string().uuid() }),
|
|
1048
|
+
query: z.object({
|
|
1049
|
+
limit: z.string().optional(),
|
|
1050
|
+
before: z.string().optional(),
|
|
1051
|
+
}),
|
|
1052
|
+
},
|
|
1053
|
+
responses: {
|
|
1054
|
+
[HTTP.ResultCodes.RS_2.Ok]: jsonContent({
|
|
1055
|
+
description: 'Direct messages',
|
|
1056
|
+
schema: z.array(z.any()),
|
|
1057
|
+
}),
|
|
1058
|
+
},
|
|
1059
|
+
},
|
|
1060
|
+
} as const;
|
|
1061
|
+
|
|
1062
|
+
type ChatRoutes = typeof ChatRoutes;
|
|
1063
|
+
|
|
1064
|
+
@controller({ path: '/chat' })
|
|
1065
|
+
export class ChatController extends BaseController {
|
|
1066
|
+
constructor(
|
|
1067
|
+
@inject('services.ChatService')
|
|
1068
|
+
private _chatService: ChatService,
|
|
1069
|
+
) {
|
|
1070
|
+
super({ scope: ChatController.name, path: '/chat' });
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
override binding() {}
|
|
1074
|
+
|
|
1075
|
+
@get({ configs: ChatRoutes.GET_ROOMS })
|
|
1076
|
+
async getRooms(c: TRouteContext<ChatRoutes['GET_ROOMS']>) {
|
|
1077
|
+
const userId = c.get('userId');
|
|
1078
|
+
const rooms = await this._chatService.getUserRooms({ userId });
|
|
1079
|
+
return c.json(rooms);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
@post({ configs: ChatRoutes.CREATE_ROOM })
|
|
1083
|
+
async createRoom(c: TRouteContext<ChatRoutes['CREATE_ROOM']>) {
|
|
1084
|
+
const userId = c.get('userId');
|
|
1085
|
+
const data = c.req.valid('json');
|
|
1086
|
+
|
|
1087
|
+
const room = await this._chatService.createRoom({
|
|
1088
|
+
...data,
|
|
1089
|
+
createdBy: userId,
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
return c.json(room, HTTP.ResultCodes.RS_2.Created);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
@get({ configs: ChatRoutes.GET_MESSAGES })
|
|
1096
|
+
async getMessages(c: TRouteContext<ChatRoutes['GET_MESSAGES']>) {
|
|
1097
|
+
const { roomId } = c.req.valid('param');
|
|
1098
|
+
const { limit, before } = c.req.valid('query');
|
|
1099
|
+
|
|
1100
|
+
const messages = await this._chatService.getMessages({
|
|
1101
|
+
roomId,
|
|
1102
|
+
limit: limit ? parseInt(limit) : undefined,
|
|
1103
|
+
before,
|
|
1104
|
+
});
|
|
1105
|
+
|
|
1106
|
+
return c.json(messages);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
@get({ configs: ChatRoutes.GET_CONVERSATIONS })
|
|
1110
|
+
async getConversations(c: TRouteContext<ChatRoutes['GET_CONVERSATIONS']>) {
|
|
1111
|
+
const userId = c.get('userId');
|
|
1112
|
+
const conversations = await this._chatService.getConversations({ userId });
|
|
1113
|
+
return c.json(conversations);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
@get({ configs: ChatRoutes.GET_DIRECT_MESSAGES })
|
|
1117
|
+
async getDirectMessages(c: TRouteContext<ChatRoutes['GET_DIRECT_MESSAGES']>) {
|
|
1118
|
+
const currentUserId = c.get('userId');
|
|
1119
|
+
const { userId: otherUserId } = c.req.valid('param');
|
|
1120
|
+
const { limit, before } = c.req.valid('query');
|
|
1121
|
+
|
|
1122
|
+
const messages = await this._chatService.getDirectMessages({
|
|
1123
|
+
userId1: currentUserId,
|
|
1124
|
+
userId2: otherUserId,
|
|
1125
|
+
limit: limit ? parseInt(limit) : undefined,
|
|
1126
|
+
before,
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
return c.json(messages);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
```
|
|
1133
|
+
|
|
1134
|
+
## 10. Client Usage
|
|
1135
|
+
|
|
1136
|
+
### JavaScript Client Example
|
|
1137
|
+
|
|
1138
|
+
```typescript
|
|
1139
|
+
// client/chat-client.ts
|
|
1140
|
+
import { io, Socket } from 'socket.io-client';
|
|
1141
|
+
|
|
1142
|
+
interface Message {
|
|
1143
|
+
id: string;
|
|
1144
|
+
content: string;
|
|
1145
|
+
senderId: string;
|
|
1146
|
+
sender: { username: string; avatar?: string };
|
|
1147
|
+
createdAt: string;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
class ChatClient {
|
|
1151
|
+
private _socket: Socket;
|
|
1152
|
+
|
|
1153
|
+
constructor(opts: { serverUrl: string; token: string }) {
|
|
1154
|
+
this._socket = io(opts.serverUrl, {
|
|
1155
|
+
auth: { token: opts.token },
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
this.setupListeners();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
private setupListeners() {
|
|
1162
|
+
this._socket.on('connect', () => {
|
|
1163
|
+
console.log('Connected to chat server');
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
this._socket.on('message:new', (message: Message) => {
|
|
1167
|
+
console.log('New message:', message);
|
|
1168
|
+
// Update UI
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
this._socket.on('typing:update', (data) => {
|
|
1172
|
+
console.log(`User ${data.userId} is ${data.isTyping ? 'typing' : 'stopped typing'}`);
|
|
1173
|
+
// Show/hide typing indicator
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
this._socket.on('presence:changed', (data) => {
|
|
1177
|
+
console.log(`User ${data.userId} is now ${data.status}`);
|
|
1178
|
+
// Update online status
|
|
1179
|
+
});
|
|
1180
|
+
|
|
1181
|
+
this._socket.on('error', (error) => {
|
|
1182
|
+
console.error('Socket error:', error);
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
joinRoom(opts: { roomId: string }) {
|
|
1187
|
+
this._socket.emit('room:join', opts.roomId);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
leaveRoom(opts: { roomId: string }) {
|
|
1191
|
+
this._socket.emit('room:leave', opts.roomId);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
sendMessage(opts: { roomId: string; content: string }) {
|
|
1195
|
+
this._socket.emit('message:send', { roomId: opts.roomId, content: opts.content });
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
sendDirectMessage(opts: { receiverId: string; content: string }) {
|
|
1199
|
+
this._socket.emit('dm:send', { receiverId: opts.receiverId, content: opts.content });
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
startTyping(opts: { roomId: string }) {
|
|
1203
|
+
this._socket.emit('typing:start', opts.roomId);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
stopTyping(opts: { roomId: string }) {
|
|
1207
|
+
this._socket.emit('typing:stop', opts.roomId);
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
disconnect() {
|
|
1211
|
+
this._socket.disconnect();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Usage
|
|
1216
|
+
const chat = new ChatClient({ serverUrl: 'http://localhost:3000', token: 'your-jwt-token' });
|
|
1217
|
+
|
|
1218
|
+
chat.joinRoom({ roomId: 'room-uuid' });
|
|
1219
|
+
chat.sendMessage({ roomId: 'room-uuid', content: 'Hello everyone!' });
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
## 11. Scaling with Redis
|
|
1223
|
+
|
|
1224
|
+
For production, use Redis for Socket.IO adapter:
|
|
1225
|
+
|
|
1226
|
+
```typescript
|
|
1227
|
+
// src/application.ts
|
|
1228
|
+
import { createAdapter } from '@socket.io/redis-adapter';
|
|
1229
|
+
import { createClient } from 'redis';
|
|
1230
|
+
import { EnvHelper } from '@venizia/ignis-helpers';
|
|
1231
|
+
|
|
1232
|
+
private async setupSocketIO() {
|
|
1233
|
+
const pubClient = createClient({ url: EnvHelper.get('APP_ENV_REDIS_URL') });
|
|
1234
|
+
const subClient = pubClient.duplicate();
|
|
1235
|
+
|
|
1236
|
+
await Promise.all([pubClient.connect(), subClient.connect()]);
|
|
1237
|
+
|
|
1238
|
+
this._io.adapter(createAdapter(pubClient, subClient));
|
|
1239
|
+
|
|
1240
|
+
// ... rest of setup
|
|
1241
|
+
}
|
|
1242
|
+
```
|
|
1243
|
+
|
|
1244
|
+
## Summary
|
|
1245
|
+
|
|
1246
|
+
| Feature | Implementation |
|
|
1247
|
+
|---------|---------------|
|
|
1248
|
+
| Rooms | Socket.IO rooms + database |
|
|
1249
|
+
| Direct Messages | Personal rooms + database |
|
|
1250
|
+
| Typing Indicators | Socket events with auto-timeout |
|
|
1251
|
+
| Presence | Online status tracking |
|
|
1252
|
+
| History | REST API with pagination |
|
|
1253
|
+
| Scaling | Redis adapter for pub/sub |
|
|
1254
|
+
|
|
1255
|
+
## Next Steps
|
|
1256
|
+
|
|
1257
|
+
- Add file/image sharing with [Storage Helper](../../references/helpers/storage.md)
|
|
1258
|
+
- Add push notifications
|
|
1259
|
+
- Implement read receipts
|
|
1260
|
+
- Add message reactions
|
|
1261
|
+
- Deploy with [Deployment Guide](../../best-practices/deployment-strategies.md)
|