File

src/users/users.service.ts

Index

Methods

Constructor

constructor(prisma: PrismaService, storage: StorageService, aiEnabledGuard: AiEnabledGuard)
Parameters :
Name Type Optional
prisma PrismaService No
storage StorageService No
aiEnabledGuard AiEnabledGuard No

Methods

Async clearTelematics
clearTelematics(organizationId: string)

Wipe the telematics configuration for this organization. Used by the "Disconnect" button in Settings → Integrations. Returns the updated (now-empty) customization payload.

Parameters :
Name Type Optional
organizationId string No
Returns : unknown
Async create
create(organizationId: string, dto: CreateUserDto)
Parameters :
Name Type Optional
organizationId string No
dto CreateUserDto No
Returns : unknown
Async createInviteCode
createInviteCode(organizationId: string, user: literal type, body: literal type)
Parameters :
Name Type Optional
organizationId string No
user literal type No
body literal type No
Returns : unknown
Async findAll
findAll(organizationId: string, params: PaginationParams)
Parameters :
Name Type Optional
organizationId string No
params PaginationParams No
Returns : unknown
Async findOne
findOne(organizationId: string, id: string)
Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async getCustomization
getCustomization(organizationId: string)
Parameters :
Name Type Optional
organizationId string No
Returns : unknown
Async getMyPreferences
getMyPreferences(userId: string)
Parameters :
Name Type Optional
userId string No
Returns : unknown
Async getRolePermissions
getRolePermissions(organizationId: string)
Parameters :
Name Type Optional
organizationId string No
Returns : unknown
Async listInviteCodes
listInviteCodes(organizationId: string)
Parameters :
Name Type Optional
organizationId string No
Returns : unknown
Async remove
remove(organizationId: string, id: string)
Parameters :
Name Type Optional
organizationId string No
id string No
Returns : unknown
Async removeLogo
removeLogo(organizationId: string)
Parameters :
Name Type Optional
organizationId string No
Returns : unknown
Async revokeInviteCode
revokeInviteCode(id: string)
Parameters :
Name Type Optional
id string No
Returns : unknown
Async testTelematicsConnection
testTelematicsConnection(organizationId: string)

Best-effort connection test against the configured telematics provider. We don't fetch any data — we just attempt the auth handshake using whatever credentials are currently stored, then record the result on the org settings so the UI can show "Last tested: 2 minutes ago — OK".

Currently implements: mix_telematics (real OAuth ping). Other providers return a "not yet implemented" status — the UI surfaces this clearly so admins know which providers are real vs. roadmap.

Parameters :
Name Type Optional
organizationId string No
Returns : unknown
Async update
update(organizationId: string, id: string, dto: UpdateUserDto)
Parameters :
Name Type Optional
organizationId string No
id string No
dto UpdateUserDto No
Returns : unknown
Async updateCustomization
updateCustomization(organizationId: string, body: UpdateCustomizationDto, actor?: literal type)
Parameters :
Name Type Optional
organizationId string No
body UpdateCustomizationDto No
actor literal type Yes
Returns : unknown
Async updateMyPreferences
updateMyPreferences(userId: string, body: Record)
Parameters :
Name Type Optional
userId string No
body Record<string | any> No
Returns : unknown
Async updateRolePermissions
updateRolePermissions(organizationId: string, body: UpdateRolePermissionsDto)
Parameters :
Name Type Optional
organizationId string No
body UpdateRolePermissionsDto No
Returns : unknown
Async uploadLogo
uploadLogo(organizationId: string, file: literal type)

Upload an organization logo. Tries S3 first; if S3 isn't configured we fall back to embedding a data URL directly in organization.logoUrl — acceptable for typical logos (<200 KB) and lets the feature work out-of-the-box on any deployment.

Parameters :
Name Type Optional
organizationId string No
file literal type No
Returns : unknown
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import * as crypto from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
import { StorageService } from '../storage/storage.service';
import { AiEnabledGuard } from '../ai/ai-enabled.guard';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import {
  UpdateRolePermissionsDto,
  ALLOWED_ROLE_KEYS,
  ALLOWED_PAGE_KEYS,
} from './dto/role-permissions.dto';
import { UpdateCustomizationDto } from './dto/customization.dto';
import {
  buildPaginationQuery,
  buildPaginationMeta,
  PaginationParams,
} from '../common/utils/pagination.util';

@Injectable()
export class UsersService {
  constructor(
    private prisma: PrismaService,
    private storage: StorageService,
    private aiEnabledGuard: AiEnabledGuard,
  ) {}

  async create(organizationId: string, dto: CreateUserDto) {
    const existing = await this.prisma.user.findUnique({
      where: { email: dto.email },
    });
    if (existing) {
      throw new ConflictException('Email already exists');
    }

    // ── Two paths ───────────────────────────────────────────────────────
    // 1. Admin omitted the password → we generate a one-time invite link.
    //    The user clicks it, picks their own password, and the admin
    //    never sees an interim credential. This is the recommended path.
    // 2. Admin typed a password explicitly → we hash it AND set
    //    mustChangePassword so the user is forced to rotate it on first
    //    login. The admin still knows the password briefly but not for
    //    long.
    const useInviteLink = !dto.password;

    let passwordHash: string;
    let resetToken: string | null = null;
    let resetTokenHash: string | null = null;
    let resetTokenExp: Date | null = null;

    if (useInviteLink) {
      // Store an unguessable random placeholder so the NOT NULL column
      // is satisfied — but no human knows it. Login is impossible until
      // the user completes the invite flow.
      const placeholder = crypto.randomBytes(48).toString('hex');
      passwordHash = await bcrypt.hash(placeholder, 12);

      resetToken = crypto.randomBytes(32).toString('hex');
      resetTokenHash = crypto
        .createHash('sha256')
        .update(resetToken)
        .digest('hex');
      // 7-day expiry — long enough to email/share, short enough to limit
      // exposure if the link leaks.
      resetTokenExp = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
    } else {
      passwordHash = await bcrypt.hash(dto.password!, 12);
    }

    const created = await this.prisma.user.create({
      data: {
        organizationId,
        email: dto.email,
        passwordHash,
        firstName: dto.firstName,
        lastName: dto.lastName,
        role: dto.role || 'DISPATCHER',
        phone: dto.phone,
        // Force a fresh password choice on first login in BOTH paths.
        mustChangePassword: true,
        ...(resetTokenHash && resetTokenExp
          ? { resetToken: resetTokenHash, resetTokenExp }
          : {}),
      },
      select: {
        id: true,
        email: true,
        firstName: true,
        lastName: true,
        role: true,
        phone: true,
        isActive: true,
        mustChangePassword: true,
        createdAt: true,
      },
    });

    // ── Build & deliver the invite link (if applicable) ─────────────────
    let inviteUrl: string | null = null;
    let emailSent = false;
    if (useInviteLink && resetToken) {
      const webUrl = process.env.WEB_URL || 'http://localhost:3000';
      inviteUrl = `${webUrl}/reset-password?token=${resetToken}&email=${encodeURIComponent(dto.email)}&welcome=1`;

      // Best-effort SMTP delivery. We never fail the create call if email
      // fails — the admin still receives `inviteUrl` in the response and
      // can share it manually.
      try {
        if (process.env.SMTP_HOST) {
          const nodemailer = require('nodemailer');
          const transport = nodemailer.createTransport({
            host: process.env.SMTP_HOST,
            port: parseInt(process.env.SMTP_PORT || '587'),
            auth: {
              user: process.env.SMTP_USER,
              pass: process.env.SMTP_PASS,
            },
          });
          await transport.sendMail({
            from:
              process.env.SMTP_FROM ||
              'FleetCommand <noreply@fleetcommand.io>',
            to: dto.email,
            subject: 'Welcome to FleetCommand — set your password',
            html: `
              <div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto">
                <div style="background:linear-gradient(135deg,#6366f1,#3b82f6);padding:20px 30px;border-radius:12px 12px 0 0">
                  <h2 style="color:white;margin:0">Welcome to FleetCommand</h2>
                </div>
                <div style="background:#f8fafc;padding:30px;border:1px solid #e2e8f0;border-radius:0 0 12px 12px">
                  <p>Hi ${created.firstName},</p>
                  <p>An account has been created for you on FleetCommand TMS as <strong>${created.role.replace(/_/g, ' ')}</strong>.</p>
                  <p>Click the button below to set your password and sign in:</p>
                  <a href="${inviteUrl}" style="display:inline-block;background:#6366f1;color:white;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:bold;margin:16px 0">Set my password</a>
                  <p style="color:#64748b;font-size:14px">This link expires in 7 days. If you weren't expecting this email, you can ignore it.</p>
                  <p style="color:#94a3b8;font-size:12px;margin-top:20px">FleetCommand TMS</p>
                </div>
              </div>
            `,
          });
          emailSent = true;
        } else {
          // SMTP not configured — surface the link in stdout for ops, the
          // admin still gets it back in the API response.
          console.log(`[INVITE] ${dto.email}: ${inviteUrl}`);
        }
      } catch (err) {
        console.error('Failed to send welcome email:', err);
        console.log(`[INVITE FALLBACK] ${dto.email}: ${inviteUrl}`);
      }
    }

    return {
      ...created,
      // Only present when the invite-link path was used:
      inviteUrl,
      emailSent,
    };
  }

  async findAll(organizationId: string, params: PaginationParams) {
    const { skip, take, orderBy, page, limit } = buildPaginationQuery(params);

    const [items, total] = await Promise.all([
      this.prisma.user.findMany({
        where: { organizationId },
        skip,
        take,
        orderBy,
        select: {
          id: true,
          email: true,
          firstName: true,
          lastName: true,
          role: true,
          phone: true,
          isActive: true,
          lastLoginAt: true,
          createdAt: true,
        },
      }),
      this.prisma.user.count({ where: { organizationId } }),
    ]);

    return { data: items, meta: buildPaginationMeta(total, page, limit) };
  }

  async findOne(organizationId: string, id: string) {
    // findFirst (not findUnique) so we can include organizationId in the where
    // — guarantees an admin in org A cannot read a user in org B even if they
    // guess the ID. RLS provides defense-in-depth, this is the explicit gate.
    const user = await this.prisma.user.findFirst({
      where: { id, organizationId },
      select: {
        id: true,
        email: true,
        firstName: true,
        lastName: true,
        role: true,
        phone: true,
        avatarUrl: true,
        isActive: true,
        lastLoginAt: true,
        organizationId: true,
        createdAt: true,
        updatedAt: true,
      },
    });
    if (!user) throw new NotFoundException('User not found');
    return user;
  }

  async update(organizationId: string, id: string, dto: UpdateUserDto) {
    // Validate the target user belongs to the caller's org BEFORE mutating.
    await this.findOne(organizationId, id);

    const data: Record<string, unknown> = {};
    if (dto.firstName !== undefined) data.firstName = dto.firstName;
    if (dto.lastName !== undefined) data.lastName = dto.lastName;
    if (dto.role !== undefined) data.role = dto.role;
    if (dto.phone !== undefined) data.phone = dto.phone;
    if (dto.isActive !== undefined) data.isActive = dto.isActive;
    if (dto.password) {
      // Admin-initiated password reset. Force the target user to choose a
      // new password the next time they sign in — they shouldn't keep
      // using a password their admin knows.
      data.passwordHash = await bcrypt.hash(dto.password, 12);
      data.mustChangePassword = true;
      // Clear failed-login lockout so they can sign in immediately.
      data.failedLogins = 0;
      data.lockedUntil = null;
    }

    return this.prisma.user.update({
      where: { id },
      data,
      select: {
        id: true,
        email: true,
        firstName: true,
        lastName: true,
        role: true,
        phone: true,
        isActive: true,
        mustChangePassword: true,
        updatedAt: true,
      },
    });
  }

  async remove(organizationId: string, id: string) {
    await this.findOne(organizationId, id);
    await this.prisma.user.delete({ where: { id } });
    return { deleted: true };
  }

  // ── Role Permissions ──────────────────────────────

  private readonly DEFAULT_ROLE_PERMISSIONS: Record<string, string[]> = {
    SUPER_ADMIN: ['dashboard', 'orders', 'jobs', 'dispatch', 'loading-bays', 'tracking', 'geofences', 'vehicles', 'drivers', 'clients', 'transporters', 'zones', 'lanes', 'messaging', 'alerts', 'analytics', 'reports', 'users', 'audit-log', 'settings'],
    ADMIN: ['dashboard', 'orders', 'jobs', 'dispatch', 'loading-bays', 'tracking', 'geofences', 'vehicles', 'drivers', 'clients', 'transporters', 'zones', 'lanes', 'messaging', 'alerts', 'analytics', 'reports', 'users', 'audit-log', 'settings'],
    OPERATIONS_MANAGER: ['dashboard', 'orders', 'jobs', 'dispatch', 'loading-bays', 'tracking', 'geofences', 'vehicles', 'drivers', 'clients', 'transporters', 'zones', 'lanes', 'messaging', 'alerts', 'analytics', 'reports', 'settings'],
    PLANNER: ['dashboard', 'orders', 'jobs', 'dispatch', 'loading-bays', 'vehicles', 'drivers', 'clients', 'tracking', 'settings'],
    DISPATCHER: ['dashboard', 'dispatch', 'tracking', 'loading-bays', 'settings'],
    EXPEDITOR: ['dashboard', 'orders', 'tracking', 'alerts', 'settings'],
    CUSTOMER_SERVICE: ['dashboard', 'orders', 'clients', 'alerts', 'messaging', 'settings'],
    CLIENT_USER: ['dashboard', 'orders', 'tracking'],
    DRIVER: ['dashboard'],
  };

  async getRolePermissions(organizationId: string) {
    const org = await this.prisma.organization.findUnique({
      where: { id: organizationId },
      select: { settings: true },
    });
    const settings = (org?.settings as Record<string, any>) || {};
    return {
      permissions: settings.rolePermissions || this.DEFAULT_ROLE_PERMISSIONS,
      isCustom: !!settings.rolePermissions,
    };
  }

  async updateRolePermissions(
    organizationId: string,
    body: UpdateRolePermissionsDto,
  ) {
    if (!body || typeof body.permissions !== 'object' || body.permissions === null) {
      throw new BadRequestException('permissions must be an object');
    }

    const org = await this.prisma.organization.findUnique({
      where: { id: organizationId },
      select: { settings: true },
    });
    const settings = (org?.settings as Record<string, any>) || {};

    // Empty object = "reset to defaults". Clear the override entirely so
    // the org falls back to DEFAULT_ROLE_PERMISSIONS on the next read.
    if (Object.keys(body.permissions).length === 0) {
      delete settings.rolePermissions;
      await this.prisma.organization.update({
        where: { id: organizationId },
        data: { settings },
      });
      return { permissions: this.DEFAULT_ROLE_PERMISSIONS, isCustom: false };
    }

    // Whitelist filter — drop any role/page keys we don't recognise so a
    // fat-fingered admin or a malicious caller can't write arbitrary JSON
    // into organization.settings.
    const allowedRoles = new Set<string>(ALLOWED_ROLE_KEYS);
    const allowedPages = new Set<string>(ALLOWED_PAGE_KEYS);
    const cleaned: Record<string, string[]> = {};

    for (const [role, pages] of Object.entries(body.permissions)) {
      if (!allowedRoles.has(role)) {
        throw new BadRequestException(`Unknown role: ${role}`);
      }
      if (!Array.isArray(pages)) {
        throw new BadRequestException(`Permissions for ${role} must be an array of page keys`);
      }
      const filtered = pages.filter(
        (p): p is string => typeof p === 'string' && allowedPages.has(p),
      );
      cleaned[role] = filtered;
    }

    // SUPER_ADMIN cannot have its access reduced — guarantees there's
    // always at least one role with full access.
    cleaned.SUPER_ADMIN = Array.from(allowedPages);

    settings.rolePermissions = cleaned;
    await this.prisma.organization.update({
      where: { id: organizationId },
      data: { settings },
    });

    return { permissions: cleaned, isCustom: true };
  }

  // ── Customization ──────────────────────────────

  /**
   * Mask sensitive telematics fields before returning to the client.
   * apiKey/clientSecret/password are never sent over the wire — instead
   * we expose boolean `has*` flags so the UI can show "configured" state
   * without leaking the secret. This protects against an admin's screen
   * being shoulder-surfed and against the secret ending up in browser
   * history / extension caches.
   */
  private maskTelematics(t: any) {
    if (!t || typeof t !== 'object') return null;
    return {
      provider: t.provider || null,
      baseUrl: t.baseUrl || null,
      organizationId: t.organizationId || null,
      username: t.username || null,
      account: t.account || null,
      clientId: t.clientId || null,
      hasApiKey: !!t.apiKey,
      hasClientSecret: !!t.clientSecret,
      hasPassword: !!t.password,
      configuredAt: t.configuredAt || null,
      configuredBy: t.configuredBy || null,
      lastTestedAt: t.lastTestedAt || null,
      lastTestStatus: t.lastTestStatus || null,
    };
  }

  /**
   * Resolve effective tracking mode. Default is `demo` unless the org
   * has explicitly opted into `live` AND has a verified telematics
   * provider. We never return `live` if the credentials haven't passed
   * a connection test — that prevents the dispatcher from staring at a
   * blank map because the API key is wrong.
   */
  private resolveTrackingMode(settings: Record<string, any>): {
    mode: 'demo' | 'live';
    canGoLive: boolean;
    reason?: string;
  } {
    const requested = (settings.tracking?.mode || 'demo') as 'demo' | 'live';
    const t = settings.telematics as Record<string, any> | undefined;
    const hasProvider = !!(t?.provider);
    const hasCreds = !!(t?.hasApiKey || t?.hasClientSecret || t?.hasPassword);
    // The mask function uses these flags but the raw settings here have
    // the actual fields, so check against the raw values too:
    const hasRawCreds = !!(t?.apiKey || t?.clientSecret || t?.password);
    const verified = t?.lastTestStatus === 'ok';
    const canGoLive = hasProvider && (hasCreds || hasRawCreds) && verified;

    if (requested === 'live' && !canGoLive) {
      let reason = 'Telematics provider is not configured.';
      if (hasProvider && !verified) {
        reason = 'Telematics provider is configured but the connection has not been verified. Click "Test connection" first.';
      }
      return { mode: 'demo', canGoLive: false, reason };
    }
    return { mode: requested, canGoLive };
  }

  async getCustomization(organizationId: string) {
    const org = await this.prisma.organization.findUnique({
      where: { id: organizationId },
      select: { settings: true },
    });
    const settings = (org?.settings as Record<string, any>) || {};
    const tracking = this.resolveTrackingMode(settings);
    return {
      navLabels: settings.navLabels || {},
      orderColumns: settings.orderColumns || null,
      aiEnabled: settings.aiEnabled !== false,
      // Display timezone — used by client widgets that render times
      // (dispatch Gantt, trip detail, dashboard charts) so they all
      // show the org's local time regardless of where the dispatcher's
      // browser is sitting. Defaults to Africa/Nairobi for FleetCommand
      // since the demo is Kenya-focused; admins can override per-org.
      timezone: settings.timezone || 'Africa/Nairobi',
      currency: settings.currency || 'KES',
      bayTimeout: {
        maxWaitMinutes: settings.bayTimeout?.maxWaitMinutes ?? 120,
        autoReleaseMinutes: settings.bayTimeout?.autoReleaseMinutes ?? 60,
        alertOnLate: settings.bayTimeout?.alertOnLate ?? true,
      },
      telematics: this.maskTelematics(settings.telematics),
      tracking: {
        mode: tracking.mode,
        requestedMode: settings.tracking?.mode || 'demo',
        canGoLive: tracking.canGoLive,
        reason: tracking.reason || null,
      },
    };
  }

  async updateCustomization(
    organizationId: string,
    body: UpdateCustomizationDto,
    actor?: { id?: string; firstName?: string; lastName?: string },
  ) {
    const org = await this.prisma.organization.findUnique({
      where: { id: organizationId },
      select: { settings: true },
    });
    const settings = (org?.settings as Record<string, any>) || {};

    if (body.navLabels !== undefined) {
      // Strip empty / non-string values to keep the stored object clean.
      const cleaned: Record<string, string> = {};
      for (const [k, v] of Object.entries(body.navLabels)) {
        if (typeof v === 'string' && v.trim().length > 0) {
          cleaned[k] = v.trim().slice(0, 64);
        }
      }
      settings.navLabels = cleaned;
    }
    if (body.orderColumns !== undefined) {
      settings.orderColumns = body.orderColumns;
    }
    if (body.aiEnabled !== undefined) {
      settings.aiEnabled = body.aiEnabled;
      // Drop the in-process cache so the new toggle state takes effect
      // immediately on the AiEnabledGuard, not after the 30s TTL.
      this.aiEnabledGuard.invalidate(organizationId);
    }

    if (body.tracking !== undefined) {
      // Track mode lives under settings.tracking. Don't gate on whether
      // a provider exists yet — we let the admin set "I want live" first
      // and then resolveTrackingMode falls back to demo automatically
      // until the credentials are verified. The frontend surfaces the
      // gap with the `canGoLive` + `reason` fields.
      settings.tracking = { ...(settings.tracking || {}), mode: body.tracking.mode };
    }

    if (body.bayTimeout !== undefined) {
      const existing = (settings.bayTimeout as Record<string, any>) || {};
      settings.bayTimeout = {
        maxWaitMinutes: body.bayTimeout.maxWaitMinutes ?? existing.maxWaitMinutes ?? 120,
        autoReleaseMinutes: body.bayTimeout.autoReleaseMinutes ?? existing.autoReleaseMinutes ?? 60,
        alertOnLate: body.bayTimeout.alertOnLate ?? existing.alertOnLate ?? true,
      };
    }

    if (body.timezone !== undefined) {
      // Validate against the runtime's IANA database — if the value
      // can't be used by Intl.DateTimeFormat we reject rather than
      // silently storing a string the frontend can't render.
      try {
        new Intl.DateTimeFormat('en-US', { timeZone: body.timezone });
        settings.timezone = body.timezone;
      } catch {
        throw new BadRequestException(
          `Invalid timezone: ${body.timezone}. Use an IANA name like "Africa/Nairobi" or "Europe/London".`,
        );
      }
    }

    if (body.currency !== undefined) {
      settings.currency = body.currency.toUpperCase().slice(0, 8);
    }

    if (body.telematics !== undefined) {
      // Merge with existing so the admin can update one field at a time
      // without wiping the others. Sensitive fields (apiKey, clientSecret,
      // password) are only overwritten when the body explicitly provides
      // a non-empty value — sending an empty string is treated as "no
      // change" rather than "wipe", which prevents an accidental save
      // from blanking out a working credential.
      const existing = (settings.telematics as Record<string, any>) || {};
      const next: Record<string, any> = { ...existing };

      const t = body.telematics as Record<string, any>;
      const SENSITIVE = new Set(['apiKey', 'clientSecret', 'password']);
      for (const [key, value] of Object.entries(t)) {
        if (value === undefined) continue;
        if (SENSITIVE.has(key)) {
          if (typeof value === 'string' && value.trim().length > 0) {
            next[key] = value;
          }
          // empty string for a sensitive field = no change
        } else {
          next[key] = value;
        }
      }

      // Track who configured this and when, for the audit trail and the
      // "configured" indicator in the UI.
      next.configuredAt = new Date().toISOString();
      if (actor) {
        next.configuredBy = `${actor.firstName || ''} ${actor.lastName || ''}`.trim() || actor.id || null;
      }
      // Save flips this back to "untested" — the admin should re-test
      // after any change to credentials or base URL.
      next.lastTestStatus = null;
      next.lastTestedAt = null;

      settings.telematics = next;
    }

    await this.prisma.organization.update({
      where: { id: organizationId },
      data: { settings },
    });
    const tracking = this.resolveTrackingMode(settings);
    return {
      navLabels: settings.navLabels,
      orderColumns: settings.orderColumns,
      aiEnabled: settings.aiEnabled,
      timezone: settings.timezone || 'Africa/Nairobi',
      currency: settings.currency || 'KES',
      bayTimeout: {
        maxWaitMinutes: settings.bayTimeout?.maxWaitMinutes ?? 120,
        autoReleaseMinutes: settings.bayTimeout?.autoReleaseMinutes ?? 60,
        alertOnLate: settings.bayTimeout?.alertOnLate ?? true,
      },
      telematics: this.maskTelematics(settings.telematics),
      tracking: {
        mode: tracking.mode,
        requestedMode: settings.tracking?.mode || 'demo',
        canGoLive: tracking.canGoLive,
        reason: tracking.reason || null,
      },
    };
  }

  /**
   * Wipe the telematics configuration for this organization. Used by
   * the "Disconnect" button in Settings → Integrations. Returns the
   * updated (now-empty) customization payload.
   */
  async clearTelematics(organizationId: string) {
    const org = await this.prisma.organization.findUnique({
      where: { id: organizationId },
      select: { settings: true },
    });
    const settings = (org?.settings as Record<string, any>) || {};
    delete settings.telematics;
    await this.prisma.organization.update({
      where: { id: organizationId },
      data: { settings },
    });
    return { telematics: null };
  }

  /**
   * Best-effort connection test against the configured telematics
   * provider. We don't fetch any data — we just attempt the auth
   * handshake using whatever credentials are currently stored, then
   * record the result on the org settings so the UI can show "Last
   * tested: 2 minutes ago — OK".
   *
   * Currently implements: mix_telematics (real OAuth ping). Other
   * providers return a "not yet implemented" status — the UI surfaces
   * this clearly so admins know which providers are real vs. roadmap.
   */
  async testTelematicsConnection(organizationId: string) {
    const org = await this.prisma.organization.findUnique({
      where: { id: organizationId },
      select: { settings: true },
    });
    const settings = (org?.settings as Record<string, any>) || {};
    const cfg = settings.telematics as Record<string, any> | undefined;

    if (!cfg || !cfg.provider) {
      throw new BadRequestException(
        'No telematics provider configured. Save credentials first, then test.',
      );
    }

    let status: 'ok' | 'failed' | 'not_implemented' = 'not_implemented';
    let message = '';

    try {
      if (cfg.provider === 'mix_telematics') {
        if (!cfg.clientId || !cfg.clientSecret) {
          status = 'failed';
          message = 'Mix Telematics requires clientId and clientSecret.';
        } else {
          const tokenUrl = 'https://identity.mixtelematics.com/core/connect/token';
          const resp = await fetch(tokenUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: new URLSearchParams({
              grant_type: 'client_credentials',
              client_id: cfg.clientId,
              client_secret: cfg.clientSecret,
              scope: 'offline_access MiX.Integrate',
            }),
          });
          if (resp.ok) {
            status = 'ok';
            message = 'Authenticated with Mix Telematics successfully.';
          } else {
            status = 'failed';
            const body = await resp.text();
            message = `Mix Telematics auth failed (${resp.status}): ${body.slice(0, 200)}`;
          }
        }
      } else {
        message = `Live test not yet implemented for ${cfg.provider}. The credentials are saved and will be used as soon as the connector ships.`;
      }
    } catch (err: any) {
      status = 'failed';
      message = `Connection test threw: ${err?.message || String(err)}`;
    }

    cfg.lastTestedAt = new Date().toISOString();
    cfg.lastTestStatus = status;
    cfg.lastTestMessage = message;
    settings.telematics = cfg;
    await this.prisma.organization.update({
      where: { id: organizationId },
      data: { settings },
    });

    return { status, message, telematics: this.maskTelematics(cfg) };
  }

  // ── Invite Codes ──────────────────────────────

  async listInviteCodes(organizationId: string) {
    return this.prisma.inviteCode.findMany({
      where: { organizationId },
      orderBy: { createdAt: 'desc' },
    });
  }

  async createInviteCode(
    organizationId: string,
    user: { id: string; firstName?: string; lastName?: string },
    body: { role?: string; expiresInDays?: number },
  ) {
    const role = body.role || 'ADMIN';
    if (!['ADMIN', 'SUPER_ADMIN'].includes(role)) {
      throw new BadRequestException('Invite codes can only be created for ADMIN or SUPER_ADMIN roles. Other users must be added via User Management.');
    }

    const code = `FC-${role === 'SUPER_ADMIN' ? 'SA' : 'AD'}-${crypto.randomBytes(4).toString('hex').toUpperCase()}`;

    const expiresAt = body.expiresInDays
      ? new Date(Date.now() + body.expiresInDays * 24 * 60 * 60 * 1000)
      : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // Default 7 days

    return this.prisma.inviteCode.create({
      data: {
        organizationId,
        code,
        role: role as any,
        expiresAt,
        createdById: user.id,
        createdByName: `${user.firstName || ''} ${user.lastName || ''}`.trim(),
      },
    });
  }

  async revokeInviteCode(id: string) {
    const code = await this.prisma.inviteCode.findUnique({ where: { id } });
    if (!code) throw new NotFoundException('Invite code not found');
    await this.prisma.inviteCode.delete({ where: { id } });
    return { deleted: true };
  }

  // ── Logo upload ───────────────────────────────────────────────────────

  /**
   * Upload an organization logo. Tries S3 first; if S3 isn't configured
   * we fall back to embedding a data URL directly in `organization.logoUrl`
   * — acceptable for typical logos (<200 KB) and lets the feature work
   * out-of-the-box on any deployment.
   */
  async uploadLogo(
    organizationId: string,
    file: { buffer: Buffer; originalname: string; mimetype: string; size: number },
  ) {
    if (!file?.buffer) throw new BadRequestException('No file provided');
    if (file.size > 2 * 1024 * 1024) {
      throw new BadRequestException('Logo must be smaller than 2 MB');
    }
    const allowed = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'];
    if (!allowed.includes(file.mimetype)) {
      throw new BadRequestException('Logo must be a PNG, JPEG, SVG, or WebP image');
    }

    let logoUrl: string;
    const uploaded = await this.storage.uploadFile(
      file.buffer,
      file.originalname,
      file.mimetype,
      `logos/${organizationId}`,
    );
    if (uploaded?.url) {
      logoUrl = uploaded.url;
    } else {
      // No S3 — fall back to a base64 data URL.
      logoUrl = `data:${file.mimetype};base64,${file.buffer.toString('base64')}`;
    }

    await this.prisma.organization.update({
      where: { id: organizationId },
      data: { logoUrl },
    });

    return { logoUrl };
  }

  async removeLogo(organizationId: string) {
    await this.prisma.organization.update({
      where: { id: organizationId },
      data: { logoUrl: null },
    });
    return { logoUrl: null };
  }

  // ── Per-user notification + UI preferences ────────────────────────────

  private readonly DEFAULT_PREFERENCES = {
    notifications: {
      newOrders: true,
      orderAssigned: true,
      orderDelivered: true,
      orderException: true,
      driverArrived: true,
      lateDelivery: true,
      systemAlerts: true,
      weeklyDigest: false,
    },
    delivery: {
      email: true,
      push: true,
      sms: false,
      inApp: true,
    },
    ui: {
      darkMode: false,
      density: 'comfortable',
    },
  };

  async getMyPreferences(userId: string) {
    const user = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { preferences: true },
    });
    if (!user) throw new NotFoundException('User not found');
    const stored = (user.preferences as Record<string, any>) || {};
    // Merge stored values over the defaults so newly added preference
    // categories appear with sensible defaults instead of `undefined`.
    return {
      notifications: { ...this.DEFAULT_PREFERENCES.notifications, ...(stored.notifications || {}) },
      delivery: { ...this.DEFAULT_PREFERENCES.delivery, ...(stored.delivery || {}) },
      ui: { ...this.DEFAULT_PREFERENCES.ui, ...(stored.ui || {}) },
    };
  }

  async updateMyPreferences(userId: string, body: Record<string, any>) {
    if (!body || typeof body !== 'object') {
      throw new BadRequestException('preferences must be an object');
    }

    // Whitelist categories — drop anything we don't recognise.
    const cleaned: Record<string, Record<string, unknown>> = {};
    if (body.notifications && typeof body.notifications === 'object') {
      cleaned.notifications = {};
      for (const key of Object.keys(this.DEFAULT_PREFERENCES.notifications)) {
        if (typeof body.notifications[key] === 'boolean') {
          cleaned.notifications[key] = body.notifications[key];
        }
      }
    }
    if (body.delivery && typeof body.delivery === 'object') {
      cleaned.delivery = {};
      for (const key of Object.keys(this.DEFAULT_PREFERENCES.delivery)) {
        if (typeof body.delivery[key] === 'boolean') {
          cleaned.delivery[key] = body.delivery[key];
        }
      }
    }
    if (body.ui && typeof body.ui === 'object') {
      cleaned.ui = {};
      if (typeof body.ui.darkMode === 'boolean') cleaned.ui.darkMode = body.ui.darkMode;
      if (['compact', 'comfortable', 'spacious'].includes(body.ui.density)) {
        cleaned.ui.density = body.ui.density;
      }
    }

    // Merge with existing so partial updates don't wipe other categories.
    const existing = await this.prisma.user.findUnique({
      where: { id: userId },
      select: { preferences: true },
    });
    if (!existing) throw new NotFoundException('User not found');
    const current = (existing.preferences as Record<string, any>) || {};
    const merged = {
      ...current,
      ...cleaned,
      notifications: { ...(current.notifications || {}), ...(cleaned.notifications || {}) },
      delivery: { ...(current.delivery || {}), ...(cleaned.delivery || {}) },
      ui: { ...(current.ui || {}), ...(cleaned.ui || {}) },
    };

    await this.prisma.user.update({
      where: { id: userId },
      data: { preferences: merged },
    });

    return this.getMyPreferences(userId);
  }
}

results matching ""

    No results matching ""