src/messages/messages.service.ts
Methods |
|
constructor(prisma: PrismaService, pushService: PushService)
|
|||||||||
|
Defined in src/messages/messages.service.ts:8
|
|||||||||
|
Parameters :
|
| Async create | |||||||||||||||
create(orgId: string, senderId: string, senderRole: string, dto: CreateMessageDto)
|
|||||||||||||||
|
Defined in src/messages/messages.service.ts:15
|
|||||||||||||||
|
Parameters :
Returns :
unknown
|
| Async findMyMessages | ||||||||||||||||||||
findMyMessages(orgId: string, userId: string, page: number, limit: number)
|
||||||||||||||||||||
|
Defined in src/messages/messages.service.ts:145
|
||||||||||||||||||||
|
Org-scoped + recipient-scoped — defence in depth on top of the JWT.
Parameters :
Returns :
unknown
|
| Async findSentMessages | ||||||||||||||||||||
findSentMessages(orgId: string, userId: string, page: number, limit: number)
|
||||||||||||||||||||
|
Defined in src/messages/messages.service.ts:172
|
||||||||||||||||||||
|
Parameters :
Returns :
unknown
|
| Async getUnreadCount |
getUnreadCount(orgId: string, userId: string)
|
|
Defined in src/messages/messages.service.ts:199
|
|
Returns :
unknown
|
| Async markRead |
markRead(orgId: string, userId: string, id: string)
|
|
Defined in src/messages/messages.service.ts:206
|
|
Returns :
unknown
|
| Async remove |
remove(orgId: string, userId: string, id: string)
|
|
Defined in src/messages/messages.service.ts:225
|
|
Returns :
unknown
|
import { Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { PushService } from '../notifications/push.service';
import { CreateMessageDto } from './dto/create-message.dto';
@Injectable()
export class MessagesService {
private readonly logger = new Logger(MessagesService.name);
constructor(
private readonly prisma: PrismaService,
private readonly pushService: PushService,
) {}
async create(orgId: string, senderId: string, senderRole: string, dto: CreateMessageDto) {
// ── REPLY PATH ──────────────────────────────────────────────────────
// When replyToId is set, the recipient/driver are inferred from the parent.
// Drivers are ONLY allowed to send replies — never cold-compose.
if (dto.replyToId) {
const parent = await this.prisma.message.findFirst({
where: { id: dto.replyToId, organizationId: orgId },
select: { id: true, senderId: true, recipientId: true, driverId: true, subject: true },
});
if (!parent) throw new NotFoundException('Original message not found');
// Caller must have been part of the parent thread (sender or recipient)
if (parent.senderId !== senderId && parent.recipientId !== senderId) {
throw new ForbiddenException('You can only reply to your own threads');
}
// Reply goes to the OTHER party
const recipientId = parent.senderId === senderId ? parent.recipientId : parent.senderId;
const created = await this.prisma.message.create({
data: {
organizationId: orgId,
senderId,
recipientId,
driverId: parent.driverId,
replyToId: parent.id,
subject: dto.subject ?? (parent.subject ? `Re: ${parent.subject}` : null),
body: dto.body,
priority: dto.priority ?? 'NORMAL',
},
include: {
sender: { select: { id: true, firstName: true, lastName: true, role: true } },
},
});
// Push notification to the recipient (the dispatcher OR the driver depending on direction)
const senderUser = await this.prisma.user.findUnique({
where: { id: senderId },
select: { firstName: true, lastName: true, role: true },
});
const senderName = senderUser ? `${senderUser.firstName} ${senderUser.lastName}`.trim() : 'Driver';
const pushTitle = senderUser?.role === 'DRIVER'
? `Reply from driver ${senderName}`
: `Reply from ${senderName}`;
const pushBody = dto.body.length > 100 ? `${dto.body.substring(0, 97)}...` : dto.body;
try {
await this.pushService.sendToUser(recipientId, pushTitle, pushBody, {
type: 'message',
messageId: created.id,
});
} catch (err: any) {
this.logger.error(`Push for reply ${created.id} failed: ${err.message}`);
}
return created;
}
// ── COLD COMPOSE PATH (dispatcher → driver only) ────────────────────
if (senderRole === 'DRIVER') {
throw new ForbiddenException('Drivers can only reply to existing threads');
}
if (!dto.driverId) {
throw new NotFoundException('driverId is required when starting a new thread');
}
// 1. Validate driver exists and belongs to same org
const driver = await this.prisma.driver.findFirst({
where: { id: dto.driverId, organizationId: orgId },
select: { id: true, firstName: true, lastName: true, email: true },
});
if (!driver) {
throw new NotFoundException('Driver not found');
}
// 2. Find user account matching driver (mirrors push.service.ts:sendToDriver logic)
const recipientUser = await this.prisma.user.findFirst({
where: {
organizationId: orgId,
role: 'DRIVER',
OR: [
...(driver.email ? [{ email: driver.email }] : []),
{ firstName: driver.firstName, lastName: driver.lastName },
],
},
select: { id: true },
});
if (!recipientUser) {
throw new NotFoundException(
"No user account for this driver — driver hasn't logged in yet",
);
}
// 3. Create message row
const created = await this.prisma.message.create({
data: {
organizationId: orgId,
senderId,
recipientId: recipientUser.id,
driverId: driver.id,
subject: dto.subject,
body: dto.body,
priority: dto.priority ?? 'NORMAL',
},
include: {
sender: {
select: { id: true, firstName: true, lastName: true, role: true },
},
},
});
// 4. Fire FCM push (non-blocking — don't fail request if push fails)
try {
await this.pushService.sendToDriver(
driver.id,
orgId,
dto.subject || 'New message from dispatch',
dto.body.length > 100 ? dto.body.substring(0, 97) + '...' : dto.body,
{ type: 'message', messageId: created.id },
);
} catch (err: any) {
this.logger.error(`Push for message ${created.id} failed: ${err.message}`);
}
return created;
}
/** Org-scoped + recipient-scoped — defence in depth on top of the JWT. */
async findMyMessages(orgId: string, userId: string, page = 1, limit = 20) {
const skip = (page - 1) * limit;
const where = { organizationId: orgId, recipientId: userId };
const [items, total] = await Promise.all([
this.prisma.message.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: {
sender: {
select: { id: true, firstName: true, lastName: true, role: true },
},
replyTo: {
select: { id: true, body: true, subject: true, createdAt: true },
},
},
}),
this.prisma.message.count({ where }),
]);
return {
data: items,
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
}
async findSentMessages(orgId: string, userId: string, page = 1, limit = 20) {
const skip = (page - 1) * limit;
const where = { organizationId: orgId, senderId: userId };
const [items, total] = await Promise.all([
this.prisma.message.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: {
recipient: {
select: { id: true, firstName: true, lastName: true, role: true },
},
driver: {
select: { id: true, firstName: true, lastName: true },
},
},
}),
this.prisma.message.count({ where }),
]);
return {
data: items,
meta: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
}
async getUnreadCount(orgId: string, userId: string) {
const count = await this.prisma.message.count({
where: { organizationId: orgId, recipientId: userId, isRead: false },
});
return { count };
}
async markRead(orgId: string, userId: string, id: string) {
const existing = await this.prisma.message.findFirst({
where: { id, organizationId: orgId, recipientId: userId },
});
if (!existing) {
throw new NotFoundException('Message not found');
}
return this.prisma.message.update({
where: { id },
data: { isRead: true, readAt: new Date() },
include: {
sender: {
select: { id: true, firstName: true, lastName: true, role: true },
},
},
});
}
async remove(orgId: string, userId: string, id: string) {
const msg = await this.prisma.message.findFirst({
where: { id, organizationId: orgId },
});
if (!msg) throw new NotFoundException('Message not found');
if (msg.recipientId !== userId && msg.senderId !== userId) {
throw new ForbiddenException('Not your message');
}
await this.prisma.message.delete({ where: { id } });
return { success: true };
}
}