Compare commits
14 Commits
85aea30730
...
uat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b771bb124c | ||
|
|
f1cfe85dfe | ||
|
|
dcfd93c545 | ||
|
|
c8c6e0ebc3 | ||
|
|
e94644548e | ||
|
|
de930e470b | ||
|
|
5b168f5682 | ||
|
|
ab4eeb7ce6 | ||
|
|
67081dbe76 | ||
|
|
7ee821d46e | ||
|
|
78d403dcff | ||
|
|
7c90f118ed | ||
|
|
1eb61000c3 | ||
|
|
82cac36a48 |
@@ -1,4 +1,5 @@
|
||||
FROM node:20-alpine AS builder
|
||||
# cache-bust-v3
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=development
|
||||
@@ -8,6 +9,7 @@ COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/db/package.json ./packages/db/
|
||||
RUN npm install
|
||||
|
||||
RUN echo "bust-20260415-3" > /tmp/.cachebust && rm /tmp/.cachebust
|
||||
COPY packages/shared/ ./packages/shared/
|
||||
COPY packages/db/ ./packages/db/
|
||||
COPY nest-cli.json ./
|
||||
@@ -30,8 +32,9 @@ RUN node -e "const p=require('./packages/shared/package.json');p.main='./dist/in
|
||||
COPY --from=builder /app/packages/db/prisma ./packages/db/prisma
|
||||
COPY --from=builder /app/packages/db/package.json ./packages/db/
|
||||
COPY --from=builder /app/packages/db/src ./packages/db/src
|
||||
COPY --from=builder /app/packages/db/prisma/seed.ts ./packages/db/prisma/seed.ts
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3001
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["sh", "-c", "cd packages/db && npx prisma migrate deploy && cd /app && node dist/main"]
|
||||
CMD ["sh", "-c", "cd packages/db && npx prisma migrate deploy && echo 'Seeding database...' && npx tsx prisma/seed.ts && cd /app && node dist/main"]
|
||||
|
||||
5
fix-schema.js
Normal file
5
fix-schema.js
Normal file
@@ -0,0 +1,5 @@
|
||||
const { PrismaClient } = require("@prisma/client");
|
||||
const p = new PrismaClient();
|
||||
p.$executeRawUnsafe("ALTER TABLE users ALTER COLUMN \"tenantId\" DROP NOT NULL")
|
||||
.then(() => { console.log("tenantId nullable OK"); return p.$disconnect(); })
|
||||
.catch(e => { console.log("Error:", e.message.substring(0, 100)); return p.$disconnect(); });
|
||||
@@ -0,0 +1,12 @@
|
||||
-- AlterColumn: make tenantId nullable for platform-level users (superadmin)
|
||||
ALTER TABLE "users" ALTER COLUMN "tenantId" DROP NOT NULL;
|
||||
|
||||
-- Drop existing FK and recreate with ON DELETE SET NULL
|
||||
ALTER TABLE "users" DROP CONSTRAINT "users_tenantId_fkey";
|
||||
ALTER TABLE "users" ADD CONSTRAINT "users_tenantId_fkey"
|
||||
FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Recreate unique index to allow multiple NULL tenantId values
|
||||
DROP INDEX IF EXISTS "users_tenantId_email_key";
|
||||
CREATE UNIQUE INDEX "users_tenantId_email_key" ON "users"("tenantId", "email") WHERE "tenantId" IS NOT NULL;
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email") WHERE "tenantId" IS NULL;
|
||||
@@ -57,6 +57,14 @@ async function hashPassword(password: string): Promise<string> {
|
||||
async function main() {
|
||||
console.log('Seeding database...');
|
||||
|
||||
// Ensure tenantId is nullable (in case migration hasn't been applied)
|
||||
try {
|
||||
await prisma.$executeRawUnsafe(`ALTER TABLE "users" ALTER COLUMN "tenantId" DROP NOT NULL`);
|
||||
console.log('Made tenantId nullable');
|
||||
} catch (e: any) {
|
||||
console.log('tenantId already nullable or error:', e.message?.substring(0, 80));
|
||||
}
|
||||
|
||||
// Clean existing data (order matters for FK constraints)
|
||||
await prisma.journalLine.deleteMany();
|
||||
await prisma.journalEntry.deleteMany();
|
||||
@@ -101,9 +109,22 @@ async function main() {
|
||||
console.log(`Tenant: ${tenant.name}`);
|
||||
|
||||
// ─── Super Admin (platform-level, no tenant) ───────────
|
||||
const superAdmin = await prisma.user.create({
|
||||
data: { tenantId: null, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
|
||||
});
|
||||
// If tenantId column is NOT NULL (migration not applied), use tenant.id as fallback
|
||||
let superAdmin;
|
||||
try {
|
||||
superAdmin = await prisma.user.create({
|
||||
data: { tenantId: null, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e?.code === 'P2002' || e?.message?.includes('Null constraint')) {
|
||||
console.log('Note: tenantId is NOT NULL, creating superadmin with tenant binding');
|
||||
superAdmin = await prisma.user.create({
|
||||
data: { tenantId: tenant.id, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
await prisma.userRole.create({ data: { userId: superAdmin.id, role: 'super_admin' } });
|
||||
console.log(`Super Admin: superadmin@fiberops.dev (super_admin)`);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export class InvoiceService {
|
||||
skip,
|
||||
take,
|
||||
include: {
|
||||
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
|
||||
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true, phone: true, latitude: true, longitude: true } },
|
||||
_count: { select: { payments: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -83,7 +83,7 @@ export class InvoiceService {
|
||||
periodEnd: dueDate,
|
||||
},
|
||||
include: {
|
||||
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
|
||||
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true, phone: true, latitude: true, longitude: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ export class PaymentService {
|
||||
// ─── Remittance (custodial clearing) ─────────────────────────
|
||||
|
||||
async findRemittances(tenantId: string) {
|
||||
return this.prisma.remittance.findMany({
|
||||
const remittances = await this.prisma.remittance.findMany({
|
||||
where: { tenantId },
|
||||
include: {
|
||||
collector: { select: { id: true, firstName: true, lastName: true } },
|
||||
@@ -133,6 +133,29 @@ export class PaymentService {
|
||||
},
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
});
|
||||
|
||||
// Collect all payment IDs across remittances
|
||||
const paymentIds = remittances.flatMap((r) => r.payments.map((p) => p.paymentId));
|
||||
if (paymentIds.length === 0) return remittances;
|
||||
|
||||
// Fetch full payment details in one query
|
||||
const payments = await this.prisma.payment.findMany({
|
||||
where: { id: { in: paymentIds } },
|
||||
include: {
|
||||
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true } },
|
||||
invoice: { select: { id: true, number: true } },
|
||||
},
|
||||
});
|
||||
const paymentMap = new Map(payments.map((p) => [p.id, p]));
|
||||
|
||||
// Attach full payment details to each remittance's join records
|
||||
return remittances.map((r) => ({
|
||||
...r,
|
||||
payments: r.payments.map((rp) => ({
|
||||
...rp,
|
||||
payment: paymentMap.get(rp.paymentId) ?? null,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async getUnremittedPayments(tenantId: string, collectorId: string) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IsString, IsOptional, IsIn, MinLength, IsUUID } from 'class-validator';
|
||||
import { IsString, IsOptional, IsIn, MinLength, IsUUID, IsNumber } from 'class-validator';
|
||||
import { Transform } from 'class-transformer';
|
||||
|
||||
export class UpdateTicketDto {
|
||||
@IsOptional()
|
||||
@@ -23,4 +24,14 @@ export class UpdateTicketDto {
|
||||
@IsString()
|
||||
@IsIn(['open', 'in_progress', 'resolved', 'cancelled'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value !== undefined && value !== null ? Number(value) : undefined)
|
||||
@IsNumber()
|
||||
latitude?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value !== undefined && value !== null ? Number(value) : undefined)
|
||||
@IsNumber()
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateTicketDto } from './dto/create-ticket.dto';
|
||||
import { UpdateTicketDto } from './dto/update-ticket.dto';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
|
||||
export interface TicketResolvedEvent {
|
||||
ticketId: string;
|
||||
@@ -21,10 +20,7 @@ export class TicketService {
|
||||
onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null =
|
||||
null;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly notificationService: NotificationService,
|
||||
) {}
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
@@ -115,22 +111,13 @@ export class TicketService {
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.priority && { priority: dto.priority }),
|
||||
...(dto.status && { status: dto.status }),
|
||||
...(dto.latitude !== undefined && { latitude: dto.latitude }),
|
||||
...(dto.longitude !== undefined && { longitude: dto.longitude }),
|
||||
},
|
||||
}).then(async (ticket) => {
|
||||
// Notify on status change
|
||||
if (dto.status) {
|
||||
this.notificationService.create(tenantId, {
|
||||
type: 'in_app',
|
||||
channel: 'ticket_update',
|
||||
title: 'Ticket Updated',
|
||||
message: `Ticket "${existing.title}" status changed to ${dto.status.replace(/_/g, ' ')}`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
return ticket;
|
||||
});
|
||||
}
|
||||
|
||||
async resolve(tenantId: string, id: string, resolvedById: string, coords?: { latitude?: number; longitude?: number }) {
|
||||
async resolve(tenantId: string, id: string, resolvedById: string) {
|
||||
const db = this.prisma.forTenant(tenantId);
|
||||
const ticket = await db.ticket.findFirst({ where: { id } });
|
||||
|
||||
@@ -155,14 +142,6 @@ export class TicketService {
|
||||
},
|
||||
});
|
||||
|
||||
// Update client coordinates if this is an installation ticket with coords
|
||||
if (coords?.latitude !== undefined && coords?.longitude !== undefined && ticket.clientId && ticket.type === 'installation') {
|
||||
await this.prisma.client.update({
|
||||
where: { id: ticket.clientId },
|
||||
data: { latitude: coords.latitude, longitude: coords.longitude },
|
||||
});
|
||||
}
|
||||
|
||||
// Fire event for workflow automation
|
||||
if (this.onTicketResolved && ticket.clientId) {
|
||||
await this.onTicketResolved({
|
||||
@@ -173,14 +152,6 @@ export class TicketService {
|
||||
});
|
||||
}
|
||||
|
||||
// Notify on ticket resolution
|
||||
this.notificationService.create(tenantId, {
|
||||
type: 'in_app',
|
||||
channel: 'ticket_update',
|
||||
title: 'Ticket Resolved',
|
||||
message: `Ticket "${ticket.title}" has been resolved`,
|
||||
}).catch(() => {});
|
||||
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user