17 Commits
develop ... uat

Author SHA1 Message Date
kevin-asprec
b771bb124c chore: trigger Coolify redeploy for lat/lng + invoice client data 2026-05-04 14:09:39 +08:00
kevin-asprec
f1cfe85dfe feat: add latitude/longitude to ticket update endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 13:22:34 +08:00
kevin-asprec
dcfd93c545 fix: include phone, latitude, longitude in invoice client data
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-04 12:36:49 +08:00
kevin-asprec
c8c6e0ebc3 fix: include full payment details in remittance history
findRemittances used `payments: true` which returned raw RemittancePayment
join records without actual payment data. Now fetches full Payment objects
via manual join and maps them to the remittance records so the mobile app
can display client names, amounts, and invoice numbers.
2026-05-03 11:00:03 +08:00
kevin-asprec
e94644548e chore: add schema fix script for deployment 2026-04-15 11:36:45 +08:00
kevin-asprec
de930e470b chore: force cache invalidation with RUN before COPY 2026-04-15 11:19:02 +08:00
kevin-asprec
5b168f5682 fix: make seed resilient to tenantId NOT NULL constraint 2026-04-15 11:08:56 +08:00
kevin-asprec
ab4eeb7ce6 feat: embed seed in CMD, remove post_deployment_command dependency 2026-04-15 11:06:18 +08:00
kevin-asprec
67081dbe76 chore: bust docker cache for migration deployment 2026-04-15 10:51:15 +08:00
kevin-asprec
7ee821d46e feat: add migration to make user tenantId nullable for platform-level superadmin 2026-04-15 10:39:10 +08:00
kevin-asprec
78d403dcff fix: assign tenantId to superadmin in seed (column is NOT NULL) 2026-04-15 10:26:20 +08:00
kevin-asprec
7c90f118ed revert: remove forced seed from CMD, use post_deployment_command instead 2026-04-15 10:14:20 +08:00
kevin-asprec
1eb61000c3 feat: force seed for initial UAT deploy 2026-04-15 09:58:47 +08:00
kevin-asprec
82cac36a48 feat: add RUN_SEED support to Dockerfile CMD 2026-04-15 09:47:51 +08:00
kevin-asprec
555bd4a9f8 fix: compile shared package as CommonJS for NestJS compatibility 2026-04-13 16:52:48 +08:00
kevin-asprec
fc704c2d23 merge: develop into main 2026-04-13 13:04:32 +08:00
0321239266 Initial commit 2026-04-13 00:50:56 +00:00
11 changed files with 93 additions and 46 deletions

View File

@@ -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"]

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# fiberops-api-new

5
fix-schema.js Normal file
View 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(); });

View File

@@ -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;

View File

@@ -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)`);

View File

@@ -2,8 +2,8 @@
"name": "@fiberops/shared",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"lint": "tsc --noEmit",

View File

@@ -1,15 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"module": "CommonJS",
"moduleResolution": "node",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",

View File

@@ -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 } },
},
});
}

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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;
}
}