File

src/messages/messages.service.ts

Index

Methods

Constructor

constructor(prisma: PrismaService, pushService: PushService)
Parameters :
Name Type Optional
prisma PrismaService No
pushService PushService No

Methods

Async create
create(orgId: string, senderId: string, senderRole: string, dto: CreateMessageDto)
Parameters :
Name Type Optional
orgId string No
senderId string No
senderRole string No
dto CreateMessageDto No
Returns : unknown
Async findMyMessages
findMyMessages(orgId: string, userId: string, page: number, limit: number)

Org-scoped + recipient-scoped — defence in depth on top of the JWT.

Parameters :
Name Type Optional Default value
orgId string No
userId string No
page number No 1
limit number No 20
Returns : unknown
Async findSentMessages
findSentMessages(orgId: string, userId: string, page: number, limit: number)
Parameters :
Name Type Optional Default value
orgId string No
userId string No
page number No 1
limit number No 20
Returns : unknown
Async getUnreadCount
getUnreadCount(orgId: string, userId: string)
Parameters :
Name Type Optional
orgId string No
userId string No
Returns : unknown
Async markRead
markRead(orgId: string, userId: string, id: string)
Parameters :
Name Type Optional
orgId string No
userId string No
id string No
Returns : unknown
Async remove
remove(orgId: string, userId: string, id: string)
Parameters :
Name Type Optional
orgId string No
userId string No
id string No
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 };
  }
}

results matching ""

    No results matching ""