src/users/users.service.ts
Methods |
|
constructor(prisma: PrismaService, storage: StorageService, aiEnabledGuard: AiEnabledGuard)
|
||||||||||||
|
Defined in src/users/users.service.ts:22
|
||||||||||||
|
Parameters :
|
| Async clearTelematics | ||||||
clearTelematics(organizationId: string)
|
||||||
|
Defined in src/users/users.service.ts:572
|
||||||
|
Wipe the telematics configuration for this organization. Used by the "Disconnect" button in Settings → Integrations. Returns the updated (now-empty) customization payload.
Parameters :
Returns :
unknown
|
| Async create | |||||||||
create(organizationId: string, dto: CreateUserDto)
|
|||||||||
|
Defined in src/users/users.service.ts:29
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async createInviteCode | ||||||||||||
createInviteCode(organizationId: string, user: literal type, body: literal type)
|
||||||||||||
|
Defined in src/users/users.service.ts:669
|
||||||||||||
|
Parameters :
Returns :
unknown
|
| Async findAll | |||||||||
findAll(organizationId: string, params: PaginationParams)
|
|||||||||
|
Defined in src/users/users.service.ts:162
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async findOne |
findOne(organizationId: string, id: string)
|
|
Defined in src/users/users.service.ts:189
|
|
Returns :
unknown
|
| Async getCustomization | ||||||
getCustomization(organizationId: string)
|
||||||
|
Defined in src/users/users.service.ts:403
|
||||||
|
Parameters :
Returns :
unknown
|
| Async getMyPreferences | ||||||
getMyPreferences(userId: string)
|
||||||
|
Defined in src/users/users.service.ts:780
|
||||||
|
Parameters :
Returns :
unknown
|
| Async getRolePermissions | ||||||
getRolePermissions(organizationId: string)
|
||||||
|
Defined in src/users/users.service.ts:272
|
||||||
|
Parameters :
Returns :
unknown
|
| Async listInviteCodes | ||||||
listInviteCodes(organizationId: string)
|
||||||
|
Defined in src/users/users.service.ts:662
|
||||||
|
Parameters :
Returns :
unknown
|
| Async remove |
remove(organizationId: string, id: string)
|
|
Defined in src/users/users.service.ts:252
|
|
Returns :
unknown
|
| Async removeLogo | ||||||
removeLogo(organizationId: string)
|
||||||
|
Defined in src/users/users.service.ts:747
|
||||||
|
Parameters :
Returns :
unknown
|
| Async revokeInviteCode | ||||||
revokeInviteCode(id: string)
|
||||||
|
Defined in src/users/users.service.ts:697
|
||||||
|
Parameters :
Returns :
unknown
|
| Async testTelematicsConnection | ||||||
testTelematicsConnection(organizationId: string)
|
||||||
|
Defined in src/users/users.service.ts:597
|
||||||
|
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 :
Returns :
unknown
|
| Async update | ||||||||||||
update(organizationId: string, id: string, dto: UpdateUserDto)
|
||||||||||||
|
Defined in src/users/users.service.ts:214
|
||||||||||||
|
Parameters :
Returns :
unknown
|
| Async updateCustomization | ||||||||||||
updateCustomization(organizationId: string, body: UpdateCustomizationDto, actor?: literal type)
|
||||||||||||
|
Defined in src/users/users.service.ts:436
|
||||||||||||
|
Parameters :
Returns :
unknown
|
| Async updateMyPreferences | |||||||||
updateMyPreferences(userId: string, body: Record
|
|||||||||
|
Defined in src/users/users.service.ts:796
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async updateRolePermissions | |||||||||
updateRolePermissions(organizationId: string, body: UpdateRolePermissionsDto)
|
|||||||||
|
Defined in src/users/users.service.ts:284
|
|||||||||
|
Parameters :
Returns :
unknown
|
| Async uploadLogo | |||||||||
uploadLogo(organizationId: string, file: literal type)
|
|||||||||
|
Defined in src/users/users.service.ts:712
|
|||||||||
|
Upload an organization logo. Tries S3 first; if S3 isn't configured
we fall back to embedding a data URL directly in
Parameters :
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);
}
}