Compare commits
27 Commits
uat
...
59ee1fbe33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ee1fbe33 | ||
|
|
248f6b51ea | ||
|
|
5f4a4c6874 | ||
|
|
8fdfa6b7ba | ||
|
|
ade5df1c32 | ||
|
|
d2864961de | ||
|
|
91ea365cf9 | ||
|
|
58c01d4580 | ||
|
|
58d77fc67f | ||
|
|
c14521a41d | ||
|
|
83ad0cb8d5 | ||
|
|
469fa6bb28 | ||
|
|
94528841d9 | ||
|
|
a66fff68de | ||
|
|
40f30b4a5d | ||
|
|
bb3baeb6ac | ||
|
|
ff6295e1fc | ||
|
|
555327a891 | ||
|
|
29c6b70878 | ||
|
|
d7ab370bb5 | ||
|
|
1a0b4916d0 | ||
|
|
3b032ab7c4 | ||
|
|
d792a9a9ed | ||
|
|
c923285c7e | ||
|
|
ff0dd2e418 | ||
|
|
a5ed2cc666 | ||
|
|
bef320f32e |
@@ -1,5 +1,4 @@
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
# cache-bust-v3
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
@@ -9,7 +8,6 @@ COPY packages/shared/package.json ./packages/shared/
|
|||||||
COPY packages/db/package.json ./packages/db/
|
COPY packages/db/package.json ./packages/db/
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
RUN echo "bust-20260415-3" > /tmp/.cachebust && rm /tmp/.cachebust
|
|
||||||
COPY packages/shared/ ./packages/shared/
|
COPY packages/shared/ ./packages/shared/
|
||||||
COPY packages/db/ ./packages/db/
|
COPY packages/db/ ./packages/db/
|
||||||
COPY nest-cli.json ./
|
COPY nest-cli.json ./
|
||||||
@@ -32,9 +30,8 @@ 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/prisma ./packages/db/prisma
|
||||||
COPY --from=builder /app/packages/db/package.json ./packages/db/
|
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/src ./packages/db/src
|
||||||
COPY --from=builder /app/packages/db/prisma/seed.ts ./packages/db/prisma/seed.ts
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["sh", "-c", "cd packages/db && npx prisma migrate deploy && echo 'Seeding database...' && npx tsx prisma/seed.ts && cd /app && node dist/main"]
|
CMD ["sh", "-c", "cd packages/db && npx prisma migrate resolve --applied 20260504100000_add_user_must_change_password 2>/dev/null; npx prisma migrate resolve --applied 20260506070000_add_client_coordinates 2>/dev/null; npx prisma migrate deploy && if [ \"$RUN_SEED\" = \"true\" ]; then echo 'Seeding database...' && npx tsx prisma/seed.ts; fi && cd /app && node dist/main"]
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
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(); });
|
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
"@nestjs/testing": "^11.0.0",
|
"@nestjs/testing": "^11.0.0",
|
||||||
"@types/bcrypt": "^5.0.2",
|
"@types/bcrypt": "^5.0.2",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
"vitest": "^3.1.0"
|
"vitest": "^3.1.0"
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
-- AlterColumn: make tenantId nullable for platform-level users (superadmin)
|
-- AlterTable: make tenantId nullable for super_admin users
|
||||||
ALTER TABLE "users" ALTER COLUMN "tenantId" DROP NOT NULL;
|
ALTER TABLE "users" ALTER COLUMN "tenantId" DROP NOT NULL;
|
||||||
|
|
||||||
-- Drop existing FK and recreate with ON DELETE SET NULL
|
-- Fix FK to allow NULL (super_admin has no tenant)
|
||||||
ALTER TABLE "users" DROP CONSTRAINT "users_tenantId_fkey";
|
ALTER TABLE "users" DROP CONSTRAINT "users_tenantId_fkey";
|
||||||
ALTER TABLE "users" ADD 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;
|
FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
-- Recreate unique index to allow multiple NULL tenantId values
|
-- Drop unique index that requires tenantId (email must be unique globally for super_admin)
|
||||||
DROP INDEX IF EXISTS "users_tenantId_email_key";
|
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_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;
|
CREATE UNIQUE INDEX "users_email_key" ON "users"("email") WHERE "tenantId" IS NULL;
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "tickets" ADD COLUMN "latitude" DOUBLE PRECISION,
|
||||||
|
ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ticket_comments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"tenantId" TEXT NOT NULL,
|
||||||
|
"ticketId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "ticket_comments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "ticket_attachments" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"commentId" TEXT NOT NULL,
|
||||||
|
"fileName" TEXT NOT NULL,
|
||||||
|
"filePath" TEXT NOT NULL,
|
||||||
|
"fileType" TEXT NOT NULL,
|
||||||
|
"fileSize" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "ticket_attachments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AlterTable: add ticketId to notifications
|
||||||
|
ALTER TABLE "notifications" ADD COLUMN "ticketId" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "ticket_comments_ticketId_idx" ON "ticket_comments"("ticketId");
|
||||||
|
CREATE INDEX "ticket_comments_tenantId_idx" ON "ticket_comments"("tenantId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "ticket_comments" ADD CONSTRAINT "ticket_comments_tenantId_fkey" FOREIGN KEY ("tenantId") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ticket_comments" ADD CONSTRAINT "ticket_comments_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ticket_comments" ADD CONSTRAINT "ticket_comments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "ticket_attachments" ADD CONSTRAINT "ticket_attachments_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "ticket_comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "latitude" DOUBLE PRECISION;
|
||||||
|
ALTER TABLE "clients" ADD COLUMN "longitude" DOUBLE PRECISION;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -57,14 +57,6 @@ async function hashPassword(password: string): Promise<string> {
|
|||||||
async function main() {
|
async function main() {
|
||||||
console.log('Seeding database...');
|
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)
|
// Clean existing data (order matters for FK constraints)
|
||||||
await prisma.journalLine.deleteMany();
|
await prisma.journalLine.deleteMany();
|
||||||
await prisma.journalEntry.deleteMany();
|
await prisma.journalEntry.deleteMany();
|
||||||
@@ -109,22 +101,9 @@ async function main() {
|
|||||||
console.log(`Tenant: ${tenant.name}`);
|
console.log(`Tenant: ${tenant.name}`);
|
||||||
|
|
||||||
// ─── Super Admin (platform-level, no tenant) ───────────
|
// ─── Super Admin (platform-level, no tenant) ───────────
|
||||||
// If tenantId column is NOT NULL (migration not applied), use tenant.id as fallback
|
const superAdmin = await prisma.user.create({
|
||||||
let superAdmin;
|
data: { tenantId: null, email: 'superadmin@fiberops.dev', password: await hashPassword('admin123!'), firstName: 'Super', lastName: 'Admin' },
|
||||||
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' } });
|
await prisma.userRole.create({ data: { userId: superAdmin.id, role: 'super_admin' } });
|
||||||
console.log(`Super Admin: superadmin@fiberops.dev (super_admin)`);
|
console.log(`Super Admin: superadmin@fiberops.dev (super_admin)`);
|
||||||
|
|
||||||
@@ -134,7 +113,7 @@ async function main() {
|
|||||||
const userDefs = [
|
const userDefs = [
|
||||||
{ email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' },
|
{ email: 'admin@demo-isp.com', first: 'Admin', last: 'User', role: 'tenant_admin' },
|
||||||
{ email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' },
|
{ email: 'manager@demo-isp.com', first: 'Maria', last: 'Reyes', role: 'manager' },
|
||||||
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'technician' },
|
{ email: 'collector@demo-isp.com', first: 'Juan', last: 'Santos', role: 'collector' },
|
||||||
{ email: 'tech@demo-isp.com', first: 'Pedro', last: 'Cruz', role: 'technician' },
|
{ email: 'tech@demo-isp.com', first: 'Pedro', last: 'Cruz', role: 'technician' },
|
||||||
{ email: 'tech2@demo-isp.com', first: 'Jose', last: 'Garcia', role: 'technician' },
|
{ email: 'tech2@demo-isp.com', first: 'Jose', last: 'Garcia', role: 'technician' },
|
||||||
{ email: 'viewer@demo-isp.com', first: 'Ana', last: 'Lopez', role: 'viewer' },
|
{ email: 'viewer@demo-isp.com', first: 'Ana', last: 'Lopez', role: 'viewer' },
|
||||||
@@ -183,6 +162,7 @@ async function main() {
|
|||||||
tenant_admin: 'tenant_admin',
|
tenant_admin: 'tenant_admin',
|
||||||
manager: 'manager',
|
manager: 'manager',
|
||||||
technician: 'technician',
|
technician: 'technician',
|
||||||
|
collector: 'collector', // collector user gets collector tenant role
|
||||||
viewer: 'collector', // viewer user gets collector role for demo
|
viewer: 'collector', // viewer user gets collector role for demo
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,7 +225,7 @@ async function main() {
|
|||||||
]);
|
]);
|
||||||
console.log(`Plans: ${plans.length}`);
|
console.log(`Plans: ${plans.length}`);
|
||||||
|
|
||||||
// ─── Clients (20 clients across various areas/plans) ──
|
// ─── Clients (45 clients across various areas/plans) ──
|
||||||
// Area center coordinates (Lipa City, Batangas area)
|
// Area center coordinates (Lipa City, Batangas area)
|
||||||
const areaCoords: [number, number][] = [
|
const areaCoords: [number, number][] = [
|
||||||
[14.0785, 121.1760], // Barangay 1 - Centro
|
[14.0785, 121.1760], // Barangay 1 - Centro
|
||||||
@@ -271,6 +251,38 @@ async function main() {
|
|||||||
{ first: 'Ricardo', last: 'Flores', phone: '09291234567', email: null, address: '55 Katipunan Rd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
|
{ first: 'Ricardo', last: 'Flores', phone: '09291234567', email: null, address: '55 Katipunan Rd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
|
||||||
{ first: 'Teresa', last: 'Navarro', phone: '09301234567', email: 'teresa@email.com', address: '66 Makabayan St, Riverside', area: 3, plan: 1, type: 'prepaid', latOff: -0.003, lngOff: 0.001 },
|
{ first: 'Teresa', last: 'Navarro', phone: '09301234567', email: 'teresa@email.com', address: '66 Makabayan St, Riverside', area: 3, plan: 1, type: 'prepaid', latOff: -0.003, lngOff: 0.001 },
|
||||||
{ first: 'Fernando', last: 'Castillo', phone: '09311234567', email: null, address: '77 Silang Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
|
{ first: 'Fernando', last: 'Castillo', phone: '09311234567', email: null, address: '77 Silang Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
|
||||||
|
// New signups — pending installation (no lat/lng, pending status, open tickets)
|
||||||
|
{ first: 'Rafael', last: 'Dimaculangan', phone: '09321234567', email: null, address: '88 Burgos St, Centro', area: 0, plan: 1, type: 'postpaid', latOff: 0.0015, lngOff: -0.001 },
|
||||||
|
{ first: 'Lorna', last: 'Perez', phone: '09331234567', email: 'lorna@email.com', address: '99 Villareal St, Poblacion', area: 1, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.002 },
|
||||||
|
{ first: 'Danilo', last: 'Rivera', phone: '09341234567', email: null, address: '101 Kapitan St, San Isidro', area: 2, plan: 0, type: 'prepaid', latOff: 0.002, lngOff: 0.001 },
|
||||||
|
{ first: 'Grace', last: 'Sison', phone: '09351234567', email: 'grace@email.com', address: '202 Magdalena St, Riverside', area: 3, plan: 3, type: 'postpaid', latOff: -0.001, lngOff: -0.002 },
|
||||||
|
{ first: 'Allan', last: 'Vergara', phone: '09361234567', email: null, address: '303 Gomez St, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: 0.0015 },
|
||||||
|
// Additional clients (indices 20-29)
|
||||||
|
{ first: 'Angelo', last: 'Manalo', phone: '09371234567', email: 'angelo@email.com', address: '15 Rizal Ave, Centro', area: 0, plan: 3, type: 'postpaid', latOff: 0.0025, lngOff: -0.0015 },
|
||||||
|
{ first: 'Bella', last: 'Cruz', phone: '09381234567', email: 'bella@email.com', address: '26 Mabini Ext, Poblacion', area: 1, plan: 1, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
|
||||||
|
{ first: 'Claudio', last: 'Diaz', phone: '09391234567', email: null, address: '37 Bonifacio Rd, San Isidro', area: 2, plan: 4, type: 'postpaid', latOff: 0.001, lngOff: 0.003 },
|
||||||
|
{ first: 'Diana', last: 'Espiritu', phone: '09401234567', email: 'diana@email.com', address: '48 Luna Ext, Riverside', area: 3, plan: 0, type: 'prepaid', latOff: -0.002, lngOff: -0.001 },
|
||||||
|
{ first: 'Eduardo', last: 'Fernandez', phone: '09411234567', email: null, address: '59 Del Pilar St, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.003, lngOff: 0.001 },
|
||||||
|
{ first: 'Flora', last: 'Gonzales', phone: '09421234567', email: 'flora@email.com', address: '60 Quezon Blvd, Centro', area: 0, plan: 1, type: 'postpaid', latOff: -0.001, lngOff: -0.002 },
|
||||||
|
{ first: 'Gilbert', last: 'Hernandez', phone: '09431234567', email: null, address: '71 Magsaysay St, Poblacion', area: 1, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: 0.001 },
|
||||||
|
{ first: 'Helen', last: 'Ibañez', phone: '09441234567', email: 'helen@email.com', address: '82 Roxas Blvd, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: -0.003, lngOff: -0.001 },
|
||||||
|
{ first: 'Ivan', last: 'Jimenez', phone: '09451234567', email: null, address: '93 Laurel Ave, Riverside', area: 3, plan: 4, type: 'postpaid', latOff: 0.0015, lngOff: 0.002 },
|
||||||
|
{ first: 'Julia', last: 'Kho', phone: '09461234567', email: 'julia@email.com', address: '104 Osmena St, Hilltop', area: 4, plan: 1, type: 'prepaid', latOff: -0.002, lngOff: 0.0015 },
|
||||||
|
{ first: 'Kenneth', last: 'Lopez', phone: '09471234567', email: null, address: '115 Aguinaldo Blvd, Centro', area: 0, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.003 },
|
||||||
|
{ first: 'Linda', last: 'Madrid', phone: '09481234567', email: 'linda@email.com', address: '126 Andres St, Poblacion', area: 1, plan: 0, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
|
||||||
|
{ first: 'Mario', last: 'Ng', phone: '09491234567', email: null, address: '137 Katipunan Rd, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: -0.002 },
|
||||||
|
{ first: 'Nancy', last: 'Ong', phone: '09501234567', email: 'nancy@email.com', address: '148 Makabayan Blvd, Riverside', area: 3, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
|
||||||
|
// New signups — pending installation (indices 30-44)
|
||||||
|
{ first: 'Oscar', last: 'Pineda', phone: '09511234567', email: 'oscar@email.com', address: '159 Rizal Ext, Centro', area: 0, plan: 2, type: 'postpaid', latOff: 0.001, lngOff: -0.002 },
|
||||||
|
{ first: 'Patricia', last: 'Quintos', phone: '09521234567', email: 'patricia@email.com', address: '170 Mabini Rd, Poblacion', area: 1, plan: 1, type: 'postpaid', latOff: -0.002, lngOff: 0.001 },
|
||||||
|
{ first: 'Quentin', last: 'Reyes Jr', phone: '09531234567', email: null, address: '181 Bonifacio Ext, San Isidro', area: 2, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: 0.002 },
|
||||||
|
{ first: 'Rita', last: 'Santillan', phone: '09541234567', email: 'rita@email.com', address: '192 Luna St, Riverside', area: 3, plan: 0, type: 'prepaid', latOff: -0.001, lngOff: -0.001 },
|
||||||
|
{ first: 'Samuel', last: 'Torres', phone: '09551234567', email: null, address: '203 Del Pilar Blvd, Hilltop', area: 4, plan: 2, type: 'postpaid', latOff: 0.0015, lngOff: 0.001 },
|
||||||
|
{ first: 'Tina', last: 'Uy', phone: '09561234567', email: 'tina@email.com', address: '214 Quezon Rd, Centro', area: 0, plan: 4, type: 'postpaid', latOff: -0.002, lngOff: -0.0015 },
|
||||||
|
{ first: 'Ulysses', last: 'Velasco', phone: '09571234567', email: null, address: '225 Magsaysay Ext, Poblacion', area: 1, plan: 1, type: 'prepaid', latOff: 0.003, lngOff: -0.002 },
|
||||||
|
{ first: 'Vivian', last: 'Walsh', phone: '09581234567', email: 'vivian@email.com', address: '236 Roxas Ave, San Isidro', area: 2, plan: 2, type: 'postpaid', latOff: -0.001, lngOff: 0.003 },
|
||||||
|
{ first: 'Walter', last: 'Xavier', phone: '09591234567', email: null, address: '247 Laurel Blvd, Riverside', area: 3, plan: 3, type: 'postpaid', latOff: 0.002, lngOff: -0.001 },
|
||||||
|
{ first: 'Yolanda', last: 'Yap', phone: '09601234567', email: 'yolanda@email.com', address: '258 Osmena Rd, Hilltop', area: 4, plan: 1, type: 'postpaid', latOff: -0.0015, lngOff: 0.002 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const clients: any[] = [];
|
const clients: any[] = [];
|
||||||
@@ -281,6 +293,7 @@ async function main() {
|
|||||||
const c = clientDefs[i];
|
const c = clientDefs[i];
|
||||||
const accountNumber = `C-${String(i + 1).padStart(6, '0')}`;
|
const accountNumber = `C-${String(i + 1).padStart(6, '0')}`;
|
||||||
const plan = plans[c.plan];
|
const plan = plans[c.plan];
|
||||||
|
const isNewSignup = i >= 30; // 15 new signups pending installation
|
||||||
|
|
||||||
const client = await prisma.client.create({
|
const client = await prisma.client.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -292,120 +305,230 @@ async function main() {
|
|||||||
email: c.email,
|
email: c.email,
|
||||||
address: c.address,
|
address: c.address,
|
||||||
areaId: areas[c.area].id,
|
areaId: areas[c.area].id,
|
||||||
latitude: areaCoords[c.area][0] + c.latOff,
|
status: isNewSignup ? 'pending' : 'active',
|
||||||
longitude: areaCoords[c.area][1] + c.lngOff,
|
latitude: isNewSignup ? null : (areaCoords[c.area][0] + c.latOff),
|
||||||
|
longitude: isNewSignup ? null : (areaCoords[c.area][1] + c.lngOff),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
clients.push(client);
|
clients.push(client);
|
||||||
|
|
||||||
// Create subscription
|
if (isNewSignup) {
|
||||||
const installedAt = new Date(now);
|
// ── New signup: pending subscription + open installation ticket ──
|
||||||
installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago
|
|
||||||
|
|
||||||
const activatedAt = new Date(installedAt);
|
await prisma.subscription.create({
|
||||||
activatedAt.setDate(activatedAt.getDate() + 2);
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
planId: plan.id,
|
||||||
|
type: c.type,
|
||||||
|
status: 'pending',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.subscription.create({
|
// Open installation ticket (alternating assigned/unassigned)
|
||||||
data: {
|
const assignedTech = i % 2 === 0 ? users.technician.id : null;
|
||||||
tenantId: tenant.id,
|
await prisma.ticket.create({
|
||||||
clientId: client.id,
|
data: {
|
||||||
planId: plan.id,
|
tenantId: tenant.id,
|
||||||
type: c.type,
|
clientId: client.id,
|
||||||
status: 'active',
|
createdById: users.tenant_admin.id,
|
||||||
installedAt,
|
assigneeId: assignedTech,
|
||||||
activatedAt,
|
type: 'installation',
|
||||||
startDate: activatedAt,
|
title: `Installation for ${c.first} ${c.last}`,
|
||||||
},
|
description: `New installation at ${c.address}`,
|
||||||
});
|
status: assignedTech ? 'in_progress' : 'open',
|
||||||
|
priority: 'high',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Create resolved installation + activation tickets
|
// Overdue invoice for new signup (installation fee / first billing)
|
||||||
await prisma.ticket.create({
|
|
||||||
data: {
|
|
||||||
tenantId: tenant.id,
|
|
||||||
clientId: client.id,
|
|
||||||
createdById: users.tenant_admin.id,
|
|
||||||
assigneeId: users.technician.id,
|
|
||||||
type: 'installation',
|
|
||||||
title: `Installation for ${c.first} ${c.last}`,
|
|
||||||
description: `Installation at ${c.address}`,
|
|
||||||
status: 'resolved',
|
|
||||||
priority: 'high',
|
|
||||||
resolvedAt: new Date(installedAt.getTime() + 86400000),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.ticket.create({
|
|
||||||
data: {
|
|
||||||
tenantId: tenant.id,
|
|
||||||
clientId: client.id,
|
|
||||||
createdById: users.tenant_admin.id,
|
|
||||||
assigneeId: users.technician.id,
|
|
||||||
type: 'activation',
|
|
||||||
title: `Activation for ${c.first} ${c.last}`,
|
|
||||||
status: 'resolved',
|
|
||||||
priority: 'high',
|
|
||||||
resolvedAt: activatedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create 2 invoices per client
|
|
||||||
for (let m = 0; m < 2; m++) {
|
|
||||||
invoiceCount++;
|
invoiceCount++;
|
||||||
const periodStart = new Date(activatedAt);
|
const overdueDate = new Date(now);
|
||||||
periodStart.setMonth(periodStart.getMonth() + m);
|
overdueDate.setDate(overdueDate.getDate() - 7);
|
||||||
const periodEnd = new Date(periodStart);
|
await prisma.invoice.create({
|
||||||
periodEnd.setDate(periodEnd.getDate() + 30);
|
|
||||||
const dueDate = new Date(periodStart);
|
|
||||||
dueDate.setDate(dueDate.getDate() + 15);
|
|
||||||
|
|
||||||
const isPaid = m === 0 || Math.random() > 0.4; // First invoice always paid, second 60% chance
|
|
||||||
const balance = isPaid ? 0 : Number(plan.price);
|
|
||||||
|
|
||||||
const invoice = await prisma.invoice.create({
|
|
||||||
data: {
|
data: {
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
clientId: client.id,
|
clientId: client.id,
|
||||||
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
|
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
|
||||||
amount: plan.price,
|
amount: plan ? Number(plan.price) : 999,
|
||||||
balance,
|
balance: plan ? Number(plan.price) : 999,
|
||||||
status: isPaid ? 'paid' : (dueDate < now ? 'overdue' : 'sent'),
|
status: 'overdue',
|
||||||
dueDate,
|
dueDate: overdueDate,
|
||||||
paidAt: isPaid ? new Date(dueDate.getTime() - 86400000 * 3) : null,
|
periodStart: new Date(now.getTime() - 30 * 86400000),
|
||||||
periodStart,
|
periodEnd: new Date(now.getTime()),
|
||||||
periodEnd,
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// ── Existing client: active subscription, resolved tickets, invoices ──
|
||||||
|
|
||||||
|
// Create subscription
|
||||||
|
const installedAt = new Date(now);
|
||||||
|
installedAt.setDate(installedAt.getDate() - (30 + Math.floor(Math.random() * 60))); // 30-90 days ago
|
||||||
|
|
||||||
|
const activatedAt = new Date(installedAt);
|
||||||
|
activatedAt.setDate(activatedAt.getDate() + 2);
|
||||||
|
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
planId: plan.id,
|
||||||
|
type: c.type,
|
||||||
|
status: 'active',
|
||||||
|
installedAt,
|
||||||
|
activatedAt,
|
||||||
|
startDate: activatedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create payment for paid invoices
|
// Create resolved installation + activation tickets
|
||||||
if (isPaid) {
|
await prisma.ticket.create({
|
||||||
const methods = ['gcash', 'maya', 'cash', 'bank_transfer'];
|
data: {
|
||||||
const method = methods[Math.floor(Math.random() * methods.length)];
|
tenantId: tenant.id,
|
||||||
const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5));
|
clientId: client.id,
|
||||||
|
createdById: users.tenant_admin.id,
|
||||||
|
assigneeId: users.technician.id,
|
||||||
|
type: 'installation',
|
||||||
|
title: `Installation for ${c.first} ${c.last}`,
|
||||||
|
description: `Installation at ${c.address}`,
|
||||||
|
status: 'resolved',
|
||||||
|
priority: 'high',
|
||||||
|
resolvedAt: new Date(installedAt.getTime() + 86400000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await prisma.payment.create({
|
await prisma.ticket.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
createdById: users.tenant_admin.id,
|
||||||
|
assigneeId: users.technician.id,
|
||||||
|
type: 'activation',
|
||||||
|
title: `Activation for ${c.first} ${c.last}`,
|
||||||
|
status: 'resolved',
|
||||||
|
priority: 'high',
|
||||||
|
resolvedAt: activatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create invoices per client (3-4 per client with varied statuses)
|
||||||
|
for (let m = 0; m < 4; m++) {
|
||||||
|
invoiceCount++;
|
||||||
|
const periodStart = new Date(activatedAt);
|
||||||
|
periodStart.setMonth(periodStart.getMonth() + m);
|
||||||
|
const periodEnd = new Date(periodStart);
|
||||||
|
periodEnd.setDate(periodEnd.getDate() + 30);
|
||||||
|
const dueDate = new Date(periodStart);
|
||||||
|
dueDate.setDate(dueDate.getDate() + 15);
|
||||||
|
|
||||||
|
// Determine invoice status based on month
|
||||||
|
let status: string;
|
||||||
|
let balance: number;
|
||||||
|
let paidAt: Date | null = null;
|
||||||
|
const amount = Number(plan.price);
|
||||||
|
|
||||||
|
if (m === 0) {
|
||||||
|
// Month 1: always paid
|
||||||
|
status = 'paid';
|
||||||
|
balance = 0;
|
||||||
|
paidAt = new Date(dueDate.getTime() - 86400000 * 3);
|
||||||
|
} else if (m === 1) {
|
||||||
|
// Month 2: overdue (unpaid, past due)
|
||||||
|
status = 'overdue';
|
||||||
|
balance = amount;
|
||||||
|
} else if (m === 2) {
|
||||||
|
// Month 3: 50% paid → partial
|
||||||
|
status = 'partial';
|
||||||
|
balance = Math.round(amount / 2);
|
||||||
|
} else {
|
||||||
|
// Month 4: upcoming (due in near future)
|
||||||
|
const futureDue = new Date(now);
|
||||||
|
futureDue.setDate(futureDue.getDate() + 3);
|
||||||
|
status = 'sent';
|
||||||
|
balance = amount;
|
||||||
|
dueDate.setTime(futureDue.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = await prisma.invoice.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
clientId: client.id,
|
clientId: client.id,
|
||||||
invoiceId: invoice.id,
|
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
|
||||||
collectedById: users.technician.id,
|
amount,
|
||||||
amount: plan.price,
|
balance,
|
||||||
method,
|
status,
|
||||||
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
|
dueDate,
|
||||||
createdAt: paidDate,
|
paidAt,
|
||||||
|
periodStart,
|
||||||
|
periodEnd,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create payment(s) for paid/partial invoices
|
||||||
|
const collector = i % 2 === 0 ? usersByEmail['collector@demo-isp.com'] : usersByEmail['tech@demo-isp.com'];
|
||||||
|
if (status === 'paid') {
|
||||||
|
const methods = ['gcash', 'maya', 'cash', 'bank_transfer'];
|
||||||
|
const method = methods[Math.floor(Math.random() * methods.length)];
|
||||||
|
const paidDate = new Date(dueDate.getTime() - 86400000 * Math.floor(Math.random() * 5));
|
||||||
|
|
||||||
|
await prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
collectedById: collector.id,
|
||||||
|
amount,
|
||||||
|
method,
|
||||||
|
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
|
||||||
|
createdAt: paidDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else if (status === 'partial') {
|
||||||
|
const methods = ['gcash', 'cash'];
|
||||||
|
const method = methods[Math.floor(Math.random() * methods.length)];
|
||||||
|
const paidDate = new Date(dueDate.getTime() - 86400000 * 2);
|
||||||
|
|
||||||
|
await prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
collectedById: collector.id,
|
||||||
|
amount: Math.round(amount / 2),
|
||||||
|
method,
|
||||||
|
referenceNo: method !== 'cash' ? `REF-${String(Math.floor(Math.random() * 99999)).padStart(5, '0')}` : null,
|
||||||
|
createdAt: paidDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`Clients: ${clients.length} (with subscriptions, tickets, invoices, payments)`);
|
console.log(`Clients: ${clients.length} (with subscriptions, tickets, invoices, payments)`);
|
||||||
|
|
||||||
// ─── Open support tickets ──────────────────────────────
|
// ─── Support tickets ───────────────────────────────────
|
||||||
const supportTickets = [
|
const supportTickets = [
|
||||||
{ clientIdx: 2, title: 'Intermittent connection drops', desc: 'Internet keeps disconnecting every 30 minutes', priority: 'high' },
|
{ clientIdx: 2, title: 'Intermittent connection drops', desc: 'Internet keeps disconnecting every 30 minutes', priority: 'high', status: 'open', type: 'support', assignee: null },
|
||||||
{ clientIdx: 5, title: 'Slow speed during peak hours', desc: 'Speed drops to 5 Mbps from 8-10 PM', priority: 'normal' },
|
{ clientIdx: 5, title: 'Slow speed during peak hours', desc: 'Speed drops to 5 Mbps from 8-10 PM', priority: 'normal', status: 'open', type: 'support', assignee: null },
|
||||||
{ clientIdx: 8, title: 'No internet connection', desc: 'Complete outage since this morning', priority: 'urgent' },
|
{ clientIdx: 8, title: 'No internet connection', desc: 'Complete outage since this morning', priority: 'urgent', status: 'in_progress', type: 'support', assignee: 'tech' },
|
||||||
{ clientIdx: 11, title: 'Request for plan upgrade', desc: 'Would like to upgrade from Basic to Standard', priority: 'low' },
|
{ clientIdx: 11, title: 'Request for plan upgrade', desc: 'Would like to upgrade from Basic to Standard', priority: 'low', status: 'open', type: 'support', assignee: null },
|
||||||
{ clientIdx: 1, title: 'WiFi router not working', desc: 'Power light blinking, no WiFi signal', priority: 'high' },
|
{ clientIdx: 1, title: 'WiFi router not working', desc: 'Power light blinking, no WiFi signal', priority: 'high', status: 'in_progress', type: 'support', assignee: 'tech2' },
|
||||||
|
{ clientIdx: 20, title: 'Fiber cable damaged by construction', desc: 'Backhoe hit the fiber line on Rizal Ave', priority: 'urgent', status: 'in_progress', type: 'maintenance', assignee: 'tech' },
|
||||||
|
{ clientIdx: 22, title: 'Billing discrepancy - double charged', desc: 'Customer was charged twice for March billing', priority: 'high', status: 'open', type: 'support', assignee: null },
|
||||||
|
{ clientIdx: 25, title: 'New access point installation request', desc: 'Needs additional AP for 2nd floor', priority: 'normal', status: 'open', type: 'installation', assignee: null },
|
||||||
|
{ clientIdx: 18, title: 'Connection slow after rain', desc: 'Speed degrades significantly during/after rainfall', priority: 'normal', status: 'in_progress', type: 'maintenance', assignee: 'tech2' },
|
||||||
|
{ clientIdx: 28, title: 'Account suspension appeal', desc: 'Customer requests reconnection, willing to pay balance', priority: 'high', status: 'open', type: 'support', assignee: null },
|
||||||
|
{ clientIdx: 23, title: 'Router firmware update needed', desc: 'Current firmware causing intermittent WiFi drops', priority: 'normal', status: 'open', type: 'maintenance', assignee: null },
|
||||||
|
{ clientIdx: 15, title: 'Relocation request - new address', desc: 'Moving to Barangay 4, wants service transferred', priority: 'low', status: 'open', type: 'support', assignee: null },
|
||||||
|
{ clientIdx: 26, title: 'High latency for gaming', desc: 'Ping above 100ms during evenings', priority: 'normal', status: 'in_progress', type: 'support', assignee: 'tech' },
|
||||||
|
{ clientIdx: 19, title: 'ONT replacement needed', desc: 'ONT showing red fault light intermittently', priority: 'high', status: 'open', type: 'maintenance', assignee: null },
|
||||||
|
{ clientIdx: 21, title: 'Monthly service credit request', desc: 'Requesting credit for 2-day outage last month', priority: 'low', status: 'open', type: 'support', assignee: null },
|
||||||
|
{ clientIdx: 3, title: 'Second floor extension installation', desc: 'Client wants additional fiber drop to 2nd floor office', priority: 'normal', status: 'open', type: 'installation', assignee: null },
|
||||||
|
{ clientIdx: 9, title: 'Fiber relocation due to renovation', desc: 'House renovation requires moving fiber entry point', priority: 'normal', status: 'in_progress', type: 'installation', assignee: 'tech2' },
|
||||||
|
{ clientIdx: 14, title: 'ONT upgrade to GPON', desc: 'Current ONT outdated, needs GPON-compatible replacement', priority: 'low', status: 'open', type: 'installation', assignee: null },
|
||||||
|
{ clientIdx: 7, title: 'New branch office fiber install', desc: 'Client opened sari-sari store next door, wants 2nd connection', priority: 'high', status: 'open', type: 'installation', assignee: null },
|
||||||
|
{ clientIdx: 24, title: 'Intermittent packet loss', desc: 'Ping shows 5-10% packet loss during daytime', priority: 'high', status: 'in_progress', type: 'maintenance', assignee: 'tech' },
|
||||||
|
{ clientIdx: 12, title: 'Cable exposed across driveway', desc: 'Fiber cable hanging low across client driveway, safety hazard', priority: 'urgent', status: 'open', type: 'maintenance', assignee: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const t of supportTickets) {
|
for (const t of supportTickets) {
|
||||||
@@ -414,11 +537,12 @@ async function main() {
|
|||||||
tenantId: tenant.id,
|
tenantId: tenant.id,
|
||||||
clientId: clients[t.clientIdx].id,
|
clientId: clients[t.clientIdx].id,
|
||||||
createdById: users.tenant_admin.id,
|
createdById: users.tenant_admin.id,
|
||||||
type: 'support',
|
type: t.type,
|
||||||
title: t.title,
|
title: t.title,
|
||||||
description: t.desc,
|
description: t.desc,
|
||||||
priority: t.priority,
|
priority: t.priority,
|
||||||
status: 'open',
|
status: t.status,
|
||||||
|
assigneeId: t.assignee ? users[t.assignee]?.id ?? users.technician.id : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -594,14 +718,149 @@ async function main() {
|
|||||||
});
|
});
|
||||||
console.log('Fund transfers: 2');
|
console.log('Fund transfers: 2');
|
||||||
|
|
||||||
// ─── Remittances ───────────────────────────────────────
|
// ─── Remittances (properly linked to payments) ──────────
|
||||||
await prisma.remittance.create({
|
// Get all payments that were for paid invoices (these are candidates for remittances)
|
||||||
data: { tenantId: tenant.id, collectorId: users.technician.id, confirmedById: users.tenant_admin.id, totalAmount: 8995, status: 'confirmed', confirmedAt: new Date() },
|
const allPayments = await prisma.payment.findMany({
|
||||||
|
where: { tenantId: tenant.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
});
|
});
|
||||||
await prisma.remittance.create({
|
|
||||||
data: { tenantId: tenant.id, collectorId: users.technician.id, totalAmount: 5497, status: 'pending' },
|
// Split payments: first 60% → remitted (confirmed), next 20% → remitted (pending), last 20% → unremitted
|
||||||
});
|
const confirmedEnd = Math.floor(allPayments.length * 0.6);
|
||||||
console.log('Remittances: 2');
|
const pendingEnd = Math.floor(allPayments.length * 0.8);
|
||||||
|
|
||||||
|
const confirmedPayments = allPayments.slice(0, confirmedEnd);
|
||||||
|
const pendingPayments = allPayments.slice(confirmedEnd, pendingEnd);
|
||||||
|
// remaining payments (pendingEnd onward) stay unremitted
|
||||||
|
|
||||||
|
// Create confirmed remittance
|
||||||
|
if (confirmedPayments.length > 0) {
|
||||||
|
const confirmedTotal = confirmedPayments.reduce((s, p) => s + Number(p.amount), 0);
|
||||||
|
const confirmedRemittance = await prisma.remittance.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
collectorId: users.technician.id,
|
||||||
|
confirmedById: users.tenant_admin.id,
|
||||||
|
totalAmount: confirmedTotal,
|
||||||
|
status: 'confirmed',
|
||||||
|
submittedAt: new Date(Date.now() - 86400000 * 7),
|
||||||
|
confirmedAt: new Date(Date.now() - 86400000 * 5),
|
||||||
|
payments: {
|
||||||
|
create: confirmedPayments.map((p) => ({ paymentId: p.id })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Remittance (confirmed): ₱${confirmedTotal} (${confirmedPayments.length} payments)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pending remittance
|
||||||
|
if (pendingPayments.length > 0) {
|
||||||
|
const pendingTotal = pendingPayments.reduce((s, p) => s + Number(p.amount), 0);
|
||||||
|
const pendingRemittance = await prisma.remittance.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
collectorId: users.technician.id,
|
||||||
|
totalAmount: pendingTotal,
|
||||||
|
status: 'pending',
|
||||||
|
submittedAt: new Date(Date.now() - 86400000 * 2),
|
||||||
|
payments: {
|
||||||
|
create: pendingPayments.map((p) => ({ paymentId: p.id })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`Remittance (pending): ₱${pendingTotal} (${pendingPayments.length} payments)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unremittedCount = allPayments.length - pendingEnd;
|
||||||
|
console.log(`Unremitted payments: ${unremittedCount} (from invoice loop)`);
|
||||||
|
|
||||||
|
// ─── Additional unremitted payments (recent, for testing) ──
|
||||||
|
const recentPaymentDefs = [
|
||||||
|
{ clientIdx: 0, amount: 699, method: 'gcash' as const, ref: 'GCASH-44221', daysAgo: 0 },
|
||||||
|
{ clientIdx: 2, amount: 999, method: 'cash' as const, ref: null, daysAgo: 0 },
|
||||||
|
{ clientIdx: 5, amount: 1499, method: 'maya' as const, ref: 'MAYA-88312', daysAgo: 1 },
|
||||||
|
{ clientIdx: 7, amount: 999, method: 'bank_transfer' as const, ref: 'BDO-10293', daysAgo: 1 },
|
||||||
|
{ clientIdx: 9, amount: 2499, method: 'gcash' as const, ref: 'GCASH-44228', daysAgo: 2 },
|
||||||
|
{ clientIdx: 10, amount: 699, method: 'cash' as const, ref: null, daysAgo: 2 },
|
||||||
|
{ clientIdx: 14, amount: 1499, method: 'gcash' as const, ref: 'GCASH-44235', daysAgo: 3 },
|
||||||
|
{ clientIdx: 3, amount: 999, method: 'maya' as const, ref: 'MAYA-88319', daysAgo: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Find or create overdue invoices for these clients to attach payments to
|
||||||
|
for (const rp of recentPaymentDefs) {
|
||||||
|
const client = clients[rp.clientIdx];
|
||||||
|
const paidDate = new Date();
|
||||||
|
paidDate.setDate(paidDate.getDate() - rp.daysAgo);
|
||||||
|
|
||||||
|
// Find an existing overdue or partial invoice for this client
|
||||||
|
let invoice = await prisma.invoice.findFirst({
|
||||||
|
where: { tenantId: tenant.id, clientId: client.id, status: { in: ['overdue', 'partial'] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no overdue invoice, create one
|
||||||
|
if (!invoice) {
|
||||||
|
invoiceCount++;
|
||||||
|
const dueDate = new Date();
|
||||||
|
dueDate.setDate(dueDate.getDate() - 5);
|
||||||
|
invoice = await prisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
number: `INV-${String(invoiceCount).padStart(6, '0')}`,
|
||||||
|
amount: rp.amount,
|
||||||
|
balance: rp.amount,
|
||||||
|
status: 'overdue',
|
||||||
|
dueDate,
|
||||||
|
periodStart: new Date(dueDate.getTime() - 30 * 86400000),
|
||||||
|
periodEnd: dueDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.payment.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
clientId: client.id,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
collectedById: users.collector.id,
|
||||||
|
amount: rp.amount,
|
||||||
|
method: rp.method,
|
||||||
|
referenceNo: rp.ref,
|
||||||
|
createdAt: paidDate,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Recent unremitted payments: ${recentPaymentDefs.length}`);
|
||||||
|
|
||||||
|
// ─── Notifications (unread, for testing) ──────────────────
|
||||||
|
const notifDefs = [
|
||||||
|
{ userId: users.collector.id, type: 'in_app', channel: 'payment_confirmation', title: 'Payment Recorded', message: 'Payment of ₱699 for Juan Dela Cruz has been recorded.', daysAgo: 0 },
|
||||||
|
{ userId: users.collector.id, type: 'in_app', channel: 'billing_reminder', title: 'Overdue Reminder', message: '3 invoices are overdue in Barangay 1 - Centro.', daysAgo: 1 },
|
||||||
|
{ userId: users.collector.id, type: 'in_app', channel: 'ticket_update', title: 'Ticket Assigned', message: 'DNS resolution issues ticket has been assigned to you.', daysAgo: 1 },
|
||||||
|
{ userId: users.technician.id, type: 'in_app', channel: 'ticket_update', title: 'New Ticket', message: 'Fiber cable repair - Poblacion ticket needs attention.', daysAgo: 0 },
|
||||||
|
{ userId: users.technician.id, type: 'in_app', channel: 'ticket_update', title: 'Ticket Resolved', message: 'Cannot connect after reboot ticket has been resolved.', daysAgo: 0 },
|
||||||
|
{ userId: users.manager.id, type: 'in_app', channel: 'billing_reminder', title: 'Weekly Summary', message: '12 payments collected this week totaling ₱14,988.', daysAgo: 2 },
|
||||||
|
{ userId: users.manager.id, type: 'in_app', channel: 'ticket_update', title: 'Urgent Maintenance', message: 'Node outage - Riverside sector affecting 8 subscribers.', daysAgo: 0 },
|
||||||
|
{ userId: users.tenant_admin.id, type: 'in_app', channel: 'payment_confirmation', title: 'Remittance Confirmed', message: 'Remittance of ₱5,000 has been confirmed by Admin.', daysAgo: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const n of notifDefs) {
|
||||||
|
const sentAt = new Date();
|
||||||
|
sentAt.setDate(sentAt.getDate() - n.daysAgo);
|
||||||
|
await prisma.notification.create({
|
||||||
|
data: {
|
||||||
|
tenantId: tenant.id,
|
||||||
|
userId: n.userId,
|
||||||
|
type: n.type,
|
||||||
|
channel: n.channel,
|
||||||
|
title: n.title,
|
||||||
|
message: n.message,
|
||||||
|
isRead: false,
|
||||||
|
sentAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
console.log(`Notifications: ${notifDefs.length} (unread)`);
|
||||||
|
|
||||||
console.log('\n✅ Seed completed successfully!');
|
console.log('\n✅ Seed completed successfully!');
|
||||||
console.log(`\n📊 Summary:`);
|
console.log(`\n📊 Summary:`);
|
||||||
@@ -615,8 +874,12 @@ async function main() {
|
|||||||
console.log(` Expenses: ${expDefs.length} (approved + pending)`);
|
console.log(` Expenses: ${expDefs.length} (approved + pending)`);
|
||||||
console.log(` Assets: ${assetDefs.length}`);
|
console.log(` Assets: ${assetDefs.length}`);
|
||||||
console.log(` Company Accounts: ${accts.length}`);
|
console.log(` Company Accounts: ${accts.length}`);
|
||||||
console.log(`\n🔑 Login: admin@demo-isp.com / admin123!`);
|
console.log(`\n🔑 Logins (all passwords: admin123!):`);
|
||||||
console.log(`🌐 Portal: C-000001 / 09171234567`);
|
console.log(` admin@demo-isp.com (tenant_admin) - full access`);
|
||||||
|
console.log(` manager@demo-isp.com (manager) - operational management`);
|
||||||
|
console.log(` collector@demo-isp.com (collector) - payment collection`);
|
||||||
|
console.log(` tech@demo-isp.com (technician) - field operations`);
|
||||||
|
console.log(` tech2@demo-isp.com (technician) - field operations`);
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export const Role = {
|
|||||||
TENANT_ADMIN: 'tenant_admin',
|
TENANT_ADMIN: 'tenant_admin',
|
||||||
MANAGER: 'manager',
|
MANAGER: 'manager',
|
||||||
TECHNICIAN: 'technician',
|
TECHNICIAN: 'technician',
|
||||||
|
COLLECTOR: 'collector',
|
||||||
VIEWER: 'viewer',
|
VIEWER: 'viewer',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ const ROLE_LEVEL: Record<string, number> = {
|
|||||||
[Role.TENANT_ADMIN]: 80,
|
[Role.TENANT_ADMIN]: 80,
|
||||||
[Role.MANAGER]: 60,
|
[Role.MANAGER]: 60,
|
||||||
[Role.TECHNICIAN]: 40,
|
[Role.TECHNICIAN]: 40,
|
||||||
|
[Role.COLLECTOR]: 30,
|
||||||
[Role.VIEWER]: 20,
|
[Role.VIEWER]: 20,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { SchedulerModule } from './scheduler/scheduler.module';
|
|||||||
import { AccountingModule } from './accounting/accounting.module';
|
import { AccountingModule } from './accounting/accounting.module';
|
||||||
import { PayrollModule } from './payroll/payroll.module';
|
import { PayrollModule } from './payroll/payroll.module';
|
||||||
import { RoleModule } from './role/role.module';
|
import { RoleModule } from './role/role.module';
|
||||||
|
import { CommentModule } from './comment/comment.module';
|
||||||
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
|
||||||
import { PermissionsGuard } from './common/guards/permissions.guard';
|
import { PermissionsGuard } from './common/guards/permissions.guard';
|
||||||
@@ -69,6 +70,7 @@ import { AccessGuard } from './common/guards/access.guard';
|
|||||||
AccountingModule,
|
AccountingModule,
|
||||||
PayrollModule,
|
PayrollModule,
|
||||||
RoleModule,
|
RoleModule,
|
||||||
|
CommentModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class ClientController {
|
|||||||
constructor(private readonly clientService: ClientService) {}
|
constructor(private readonly clientService: ClientService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Query('areaId') areaId?: string,
|
@Query('areaId') areaId?: string,
|
||||||
@@ -36,7 +36,7 @@ export class ClientController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async findById(
|
async findById(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
52
src/comment/comment.controller.ts
Normal file
52
src/comment/comment.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Param,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
UseInterceptors,
|
||||||
|
UploadedFiles,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { FileFieldsInterceptor } from '@nestjs/platform-express';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
|
import { Roles } from '../common/decorators/roles.decorator';
|
||||||
|
import { RolesGuard } from '../common/guards/roles.guard';
|
||||||
|
import { TenantGuard } from '../common/guards/tenant.guard';
|
||||||
|
import { CurrentUser, CurrentUserPayload } from '../common/decorators/current-user.decorator';
|
||||||
|
import { multerOptions } from '../common/multer/multer.config';
|
||||||
|
|
||||||
|
@Controller('tickets/:ticketId/comments')
|
||||||
|
@UseGuards(AuthGuard('jwt'), TenantGuard, RolesGuard)
|
||||||
|
export class CommentController {
|
||||||
|
constructor(private readonly commentService: CommentService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@Roles('technician', 'collector')
|
||||||
|
async findAll(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
@Param('ticketId') ticketId: string,
|
||||||
|
) {
|
||||||
|
return this.commentService.findAll(user.tenantId, ticketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Roles('technician', 'collector')
|
||||||
|
@UseInterceptors(FileFieldsInterceptor([{ name: 'files', maxCount: 3 }], multerOptions))
|
||||||
|
async create(
|
||||||
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
@Param('ticketId') ticketId: string,
|
||||||
|
@Body() dto: CreateCommentDto,
|
||||||
|
@UploadedFiles() files?: { files?: Express.Multer.File[] },
|
||||||
|
) {
|
||||||
|
return this.commentService.create(
|
||||||
|
user.tenantId,
|
||||||
|
ticketId,
|
||||||
|
user.sub,
|
||||||
|
dto.content,
|
||||||
|
files?.files,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/comment/comment.module.ts
Normal file
13
src/comment/comment.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CommentController } from './comment.controller';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
import { NotificationModule } from '../notification/notification.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, NotificationModule],
|
||||||
|
controllers: [CommentController],
|
||||||
|
providers: [CommentService],
|
||||||
|
exports: [CommentService],
|
||||||
|
})
|
||||||
|
export class CommentModule {}
|
||||||
117
src/comment/comment.service.ts
Normal file
117
src/comment/comment.service.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { NotificationService } from '../notification/notification.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CommentService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll(tenantId: string, ticketId: string) {
|
||||||
|
const db = this.prisma.forTenant(tenantId);
|
||||||
|
const ticket = await db.ticket.findFirst({ where: { id: ticketId } });
|
||||||
|
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
|
||||||
|
return this.prisma.ticketComment.findMany({
|
||||||
|
where: { tenantId, ticketId },
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
attachments: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(
|
||||||
|
tenantId: string,
|
||||||
|
ticketId: string,
|
||||||
|
userId: string,
|
||||||
|
content: string,
|
||||||
|
files?: Express.Multer.File[],
|
||||||
|
) {
|
||||||
|
const db = this.prisma.forTenant(tenantId);
|
||||||
|
const ticket = await db.ticket.findFirst({ where: { id: ticketId } });
|
||||||
|
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||||
|
|
||||||
|
const comment = await this.prisma.ticketComment.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
ticketId,
|
||||||
|
userId,
|
||||||
|
content,
|
||||||
|
attachments: files?.length
|
||||||
|
? {
|
||||||
|
create: files.map((f) => ({
|
||||||
|
fileName: f.originalname,
|
||||||
|
filePath: f.filename,
|
||||||
|
fileType: f.mimetype,
|
||||||
|
fileSize: f.size,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: { select: { id: true, firstName: true, lastName: true } },
|
||||||
|
attachments: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Parse @mentions and notify mentioned users
|
||||||
|
const mentions = this.parseMentions(content);
|
||||||
|
if (mentions.length > 0) {
|
||||||
|
const db2 = this.prisma.forTenant(tenantId);
|
||||||
|
const users = await db2.user.findMany({
|
||||||
|
where: { tenantId, isActive: true },
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const commenterName = `${comment.author.firstName} ${comment.author.lastName}`;
|
||||||
|
|
||||||
|
for (const mention of mentions) {
|
||||||
|
const mentionedUser = users.find(
|
||||||
|
(u) =>
|
||||||
|
`${u.firstName} ${u.lastName}`.toLowerCase() === mention.toLowerCase() ||
|
||||||
|
u.firstName.toLowerCase() === mention.toLowerCase(),
|
||||||
|
);
|
||||||
|
if (mentionedUser && mentionedUser.id !== userId) {
|
||||||
|
await this.notificationService.create(tenantId, {
|
||||||
|
userId: mentionedUser.id,
|
||||||
|
type: 'in_app',
|
||||||
|
channel: 'mention',
|
||||||
|
title: 'You were mentioned',
|
||||||
|
message: `${commenterName} mentioned you in "${ticket.title}"`,
|
||||||
|
ticketId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify ticket creator (if not the commenter)
|
||||||
|
if (ticket.createdById && ticket.createdById !== userId) {
|
||||||
|
const commenterName = `${comment.author.firstName} ${comment.author.lastName}`;
|
||||||
|
await this.notificationService.create(tenantId, {
|
||||||
|
userId: ticket.createdById,
|
||||||
|
type: 'in_app',
|
||||||
|
channel: 'comment_added',
|
||||||
|
title: 'New comment on your ticket',
|
||||||
|
message: `${commenterName} commented on "${ticket.title}"`,
|
||||||
|
ticketId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Extract @FirstName or @FirstNameLastName from content */
|
||||||
|
private parseMentions(content: string): string[] {
|
||||||
|
const regex = /@(\w+(?:\s+\w+)?)/g;
|
||||||
|
const matches: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
matches.push(match[1]);
|
||||||
|
}
|
||||||
|
return [...new Set(matches)];
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/comment/dto/create-comment.dto.ts
Normal file
7
src/comment/dto/create-comment.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCommentDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
content!: string;
|
||||||
|
}
|
||||||
33
src/common/multer/multer.config.ts
Normal file
33
src/common/multer/multer.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
|
||||||
|
import { diskStorage } from 'multer';
|
||||||
|
import { extname } from 'path';
|
||||||
|
import { Request } from 'express';
|
||||||
|
|
||||||
|
export const multerOptions: MulterOptions = {
|
||||||
|
storage: diskStorage({
|
||||||
|
destination: (_req: Request, _file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) => {
|
||||||
|
cb(null, './uploads');
|
||||||
|
},
|
||||||
|
filename: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||||
|
cb(null, uniqueSuffix + extname(file.originalname));
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024, // 5MB per file
|
||||||
|
},
|
||||||
|
fileFilter: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, acceptFile: boolean) => void) => {
|
||||||
|
const allowed = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
'application/pdf',
|
||||||
|
];
|
||||||
|
if (allowed.includes(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error(`File type ${file.mimetype} not allowed`), false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -14,24 +14,28 @@ export class DashboardController {
|
|||||||
@Get('kpis')
|
@Get('kpis')
|
||||||
@Roles('manager')
|
@Roles('manager')
|
||||||
async getKpis(@CurrentUser() user: CurrentUserPayload) {
|
async getKpis(@CurrentUser() user: CurrentUserPayload) {
|
||||||
return this.dashboardService.getKpis(user.tenantId);
|
const data = await this.dashboardService.getKpis(user.tenantId);
|
||||||
|
return { data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('revenue-chart')
|
@Get('revenue-chart')
|
||||||
@Roles('manager')
|
@Roles('manager')
|
||||||
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
|
async getRevenueChart(@CurrentUser() user: CurrentUserPayload) {
|
||||||
return this.dashboardService.getRevenueChart(user.tenantId);
|
const data = await this.dashboardService.getRevenueChart(user.tenantId);
|
||||||
|
return { data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('activity')
|
@Get('activity')
|
||||||
@Roles('manager')
|
@Roles('manager')
|
||||||
async getActivity(@CurrentUser() user: CurrentUserPayload) {
|
async getActivity(@CurrentUser() user: CurrentUserPayload) {
|
||||||
return this.dashboardService.getRecentActivity(user.tenantId);
|
const data = await this.dashboardService.getRecentActivity(user.tenantId);
|
||||||
|
return { data };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('financial-summary')
|
@Get('financial-summary')
|
||||||
@Roles('manager')
|
@Roles('manager')
|
||||||
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
|
async getFinancialSummary(@CurrentUser() user: CurrentUserPayload) {
|
||||||
return this.dashboardService.getFinancialSummary(user.tenantId);
|
const data = await this.dashboardService.getFinancialSummary(user.tenantId);
|
||||||
|
return { data };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,17 +20,18 @@ export class InvoiceController {
|
|||||||
constructor(private readonly invoiceService: InvoiceService) {}
|
constructor(private readonly invoiceService: InvoiceService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Query('clientId') clientId?: string,
|
@Query('clientId') clientId?: string,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
|
@Query('sort') sort?: string,
|
||||||
) {
|
) {
|
||||||
return this.invoiceService.findAll(user.tenantId, { clientId, status });
|
return this.invoiceService.findAll(user.tenantId, { clientId, status, sort });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async findById(
|
async findById(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -10,13 +10,23 @@ import { paginationArgs, paginatedResult } from '../common/dto/pagination.dto';
|
|||||||
export class InvoiceService {
|
export class InvoiceService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async findAll(tenantId: string, filters?: { clientId?: string; status?: string; page?: number; limit?: number }) {
|
async findAll(tenantId: string, filters?: { clientId?: string; status?: string; sort?: string; page?: number; limit?: number }) {
|
||||||
const { skip, take, page, limit } = paginationArgs({ page: filters?.page, limit: filters?.limit });
|
const { skip, take, page, limit } = paginationArgs({ page: filters?.page, limit: filters?.limit });
|
||||||
const db = this.prisma.forTenant(tenantId);
|
const db = this.prisma.forTenant(tenantId);
|
||||||
|
|
||||||
|
const statusFilter = filters?.status
|
||||||
|
? (filters.status.includes(',') ? { in: filters.status.split(',') } : filters.status)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
...(filters?.clientId && { clientId: filters.clientId }),
|
...(filters?.clientId && { clientId: filters.clientId }),
|
||||||
...(filters?.status && { status: filters.status }),
|
...(statusFilter && { status: statusFilter }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const orderBy = filters?.sort === 'dueDate:asc'
|
||||||
|
? { dueDate: 'asc' as const }
|
||||||
|
: { createdAt: 'desc' as const };
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
db.invoice.findMany({
|
db.invoice.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -26,7 +36,7 @@ export class InvoiceService {
|
|||||||
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true, phone: true, latitude: true, longitude: true } },
|
client: { select: { id: true, firstName: true, lastName: true, accountNumber: true, phone: true, latitude: true, longitude: true } },
|
||||||
_count: { select: { payments: true } },
|
_count: { select: { payments: true } },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy,
|
||||||
}),
|
}),
|
||||||
db.invoice.count({ where }),
|
db.invoice.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ import { NestFactory } from '@nestjs/core';
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { join } from 'path';
|
||||||
|
import * as express from 'express';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
logger: ['error', 'warn', 'log'],
|
logger: ['error', 'warn', 'log'],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Serve uploaded files before helmet so they're not blocked
|
||||||
|
app.use('/uploads', express.static(join(__dirname, '..', 'uploads')));
|
||||||
|
|
||||||
// Security headers
|
// Security headers
|
||||||
app.use(
|
app.use(
|
||||||
helmet({
|
helmet({
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export class NotificationService {
|
|||||||
channel: string;
|
channel: string;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
ticketId?: string;
|
||||||
}) {
|
}) {
|
||||||
return this.prisma.notification.create({
|
return this.prisma.notification.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -50,6 +51,7 @@ export class NotificationService {
|
|||||||
channel: data.channel,
|
channel: data.channel,
|
||||||
title: data.title,
|
title: data.title,
|
||||||
message: data.message,
|
message: data.message,
|
||||||
|
ticketId: data.ticketId,
|
||||||
sentAt: new Date(),
|
sentAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export class PaymentController {
|
|||||||
constructor(private readonly paymentService: PaymentService) {}
|
constructor(private readonly paymentService: PaymentService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Query('clientId') clientId?: string,
|
@Query('clientId') clientId?: string,
|
||||||
@@ -32,7 +32,7 @@ export class PaymentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async record(
|
async record(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Body() dto: RecordPaymentDto,
|
@Body() dto: RecordPaymentDto,
|
||||||
@@ -41,19 +41,21 @@ export class PaymentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('unremitted')
|
@Get('unremitted')
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('collectorId') collectorId?: string) {
|
async getUnremitted(@CurrentUser() user: CurrentUserPayload, @Query('all') all?: string) {
|
||||||
return this.paymentService.getUnremittedPayments(user.tenantId, collectorId || user.sub);
|
const isManager = user.roles?.some((r: string) => ['manager', 'tenant_admin', 'super_admin'].includes(r));
|
||||||
|
// Managers see all unremitted; collectors see only their own
|
||||||
|
return this.paymentService.getUnremittedPayments(user.tenantId, isManager ? null : user.sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('remittances')
|
@Get('remittances')
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async findRemittances(@CurrentUser() user: CurrentUserPayload) {
|
async findRemittances(@CurrentUser() user: CurrentUserPayload) {
|
||||||
return this.paymentService.findRemittances(user.tenantId);
|
return this.paymentService.findRemittances(user.tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('remittances')
|
@Post('remittances')
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async submitRemittance(
|
async submitRemittance(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Body() dto: CreateRemittanceDto,
|
@Body() dto: CreateRemittanceDto,
|
||||||
|
|||||||
@@ -158,15 +158,23 @@ export class PaymentService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUnremittedPayments(tenantId: string, collectorId: string) {
|
async getUnremittedPayments(tenantId: string, collectorId: string | null) {
|
||||||
const remittedIds = (await this.prisma.remittancePayment.findMany({ select: { paymentId: true } }))
|
const remittedIds = (await this.prisma.remittancePayment.findMany({
|
||||||
|
where: { remittance: { tenantId } },
|
||||||
|
select: { paymentId: true },
|
||||||
|
}))
|
||||||
.map((r) => r.paymentId);
|
.map((r) => r.paymentId);
|
||||||
|
|
||||||
return this.prisma.payment.findMany({
|
return this.prisma.payment.findMany({
|
||||||
where: { tenantId, collectedById: collectorId, id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] } },
|
where: {
|
||||||
|
tenantId,
|
||||||
|
...(collectorId ? { collectedById: collectorId } : {}),
|
||||||
|
id: { notIn: remittedIds.length > 0 ? remittedIds : ['none'] },
|
||||||
|
},
|
||||||
include: {
|
include: {
|
||||||
client: { select: { firstName: true, lastName: true, accountNumber: true } },
|
client: { select: { firstName: true, lastName: true, accountNumber: true } },
|
||||||
invoice: { select: { number: true } },
|
invoice: { select: { id: true, number: true } },
|
||||||
|
collectedBy: { select: { id: true, firstName: true, lastName: true } },
|
||||||
},
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
});
|
});
|
||||||
@@ -211,6 +219,17 @@ export class PaymentService {
|
|||||||
include: { collector: { select: { id: true, firstName: true, lastName: true } }, confirmedBy: { select: { id: true, firstName: true, lastName: true } } },
|
include: { collector: { select: { id: true, firstName: true, lastName: true } }, confirmedBy: { select: { id: true, firstName: true, lastName: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify collector that their remittance was approved
|
||||||
|
if (remittance.collectorId) {
|
||||||
|
this.notificationService.create(tenantId, {
|
||||||
|
userId: remittance.collectorId,
|
||||||
|
type: 'in_app',
|
||||||
|
channel: 'remittance_approved',
|
||||||
|
title: 'Remittance Approved',
|
||||||
|
message: `Your remittance of ₱${Number(remittance.totalAmount).toLocaleString()} has been approved`,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
this.audit.log({
|
this.audit.log({
|
||||||
tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId,
|
tenantId, userId: confirmedById, action: 'remittance.confirmed', entity: 'remittance', entityId: remittanceId,
|
||||||
details: { collectorId: remittance.collectorId, amount: Number(remittance.totalAmount) },
|
details: { collectorId: remittance.collectorId, amount: Number(remittance.totalAmount) },
|
||||||
@@ -260,7 +279,7 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.remittance.update({
|
const result = await this.prisma.remittance.update({
|
||||||
where: { id: remittanceId },
|
where: { id: remittanceId },
|
||||||
data: {
|
data: {
|
||||||
status: 'rejected',
|
status: 'rejected',
|
||||||
@@ -268,5 +287,18 @@ export class PaymentService {
|
|||||||
confirmedAt: new Date(),
|
confirmedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Notify collector that their remittance was rejected
|
||||||
|
if (remittance.collectorId) {
|
||||||
|
this.notificationService.create(tenantId, {
|
||||||
|
userId: remittance.collectorId,
|
||||||
|
type: 'in_app',
|
||||||
|
channel: 'remittance_rejected',
|
||||||
|
title: 'Remittance Rejected',
|
||||||
|
message: `Your remittance of ₱${Number(remittance.totalAmount).toLocaleString()} has been rejected`,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/ticket/dto/create-comment.dto.ts
Normal file
19
src/ticket/dto/create-comment.dto.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
IsString,
|
||||||
|
IsOptional,
|
||||||
|
IsArray,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateCommentDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(5000)
|
||||||
|
content: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
files?: string[];
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ export class TicketController {
|
|||||||
constructor(private readonly ticketService: TicketService) {}
|
constructor(private readonly ticketService: TicketService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async findAll(
|
async findAll(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Query('clientId') clientId?: string,
|
@Query('clientId') clientId?: string,
|
||||||
@@ -34,7 +34,7 @@ export class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async findById(
|
async findById(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -43,7 +43,7 @@ export class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Roles('manager')
|
@Roles('technician')
|
||||||
async create(
|
async create(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Body() dto: CreateTicketDto,
|
@Body() dto: CreateTicketDto,
|
||||||
@@ -52,7 +52,7 @@ export class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async update(
|
async update(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -62,7 +62,7 @@ export class TicketController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/resolve')
|
@Patch(':id/resolve')
|
||||||
@Roles('technician')
|
@Roles('technician', 'collector')
|
||||||
async resolve(
|
async resolve(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { NotificationService } from '../notification/notification.service';
|
||||||
import { CreateTicketDto } from './dto/create-ticket.dto';
|
import { CreateTicketDto } from './dto/create-ticket.dto';
|
||||||
import { UpdateTicketDto } from './dto/update-ticket.dto';
|
import { UpdateTicketDto } from './dto/update-ticket.dto';
|
||||||
|
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||||
|
|
||||||
export interface TicketResolvedEvent {
|
export interface TicketResolvedEvent {
|
||||||
ticketId: string;
|
ticketId: string;
|
||||||
@@ -20,7 +22,10 @@ export class TicketService {
|
|||||||
onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null =
|
onTicketResolved: ((event: TicketResolvedEvent) => Promise<void>) | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly notificationService: NotificationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) {
|
async findAll(tenantId: string, filters?: { clientId?: string; status?: string; type?: string }) {
|
||||||
const db = this.prisma.forTenant(tenantId);
|
const db = this.prisma.forTenant(tenantId);
|
||||||
@@ -103,6 +108,29 @@ export class TicketService {
|
|||||||
throw new NotFoundException('Ticket not found');
|
throw new NotFoundException('Ticket not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When saving location, also propagate to the client record
|
||||||
|
if (dto.latitude !== undefined && dto.longitude !== undefined && existing.clientId) {
|
||||||
|
await this.prisma.client.update({
|
||||||
|
where: { id: existing.clientId },
|
||||||
|
data: { latitude: dto.latitude, longitude: dto.longitude },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify newly assigned user
|
||||||
|
if (dto.assigneeId && dto.assigneeId !== existing.assigneeId) {
|
||||||
|
const assignee = await this.prisma.user.findUnique({ where: { id: dto.assigneeId } });
|
||||||
|
if (assignee) {
|
||||||
|
this.notificationService.create(tenantId, {
|
||||||
|
userId: dto.assigneeId,
|
||||||
|
type: 'in_app',
|
||||||
|
channel: 'ticket_assigned',
|
||||||
|
title: 'Ticket assigned to you',
|
||||||
|
message: `"${existing.title}" has been assigned to you`,
|
||||||
|
ticketId: id,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this.prisma.ticket.update({
|
return this.prisma.ticket.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -117,7 +145,7 @@ export class TicketService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async resolve(tenantId: string, id: string, resolvedById: string) {
|
async resolve(tenantId: string, id: string, resolvedById: string, body?: { latitude?: number; longitude?: number }) {
|
||||||
const db = this.prisma.forTenant(tenantId);
|
const db = this.prisma.forTenant(tenantId);
|
||||||
const ticket = await db.ticket.findFirst({ where: { id } });
|
const ticket = await db.ticket.findFirst({ where: { id } });
|
||||||
|
|
||||||
@@ -139,6 +167,8 @@ export class TicketService {
|
|||||||
status: 'resolved',
|
status: 'resolved',
|
||||||
resolvedAt: new Date(),
|
resolvedAt: new Date(),
|
||||||
assigneeId: resolvedById,
|
assigneeId: resolvedById,
|
||||||
|
...(body?.latitude !== undefined && { latitude: body.latitude }),
|
||||||
|
...(body?.longitude !== undefined && { longitude: body.longitude }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -154,4 +184,50 @@ export class TicketService {
|
|||||||
|
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
async getComments(tenantId: string, ticketId: string) {
|
||||||
|
const db = this.prisma.forTenant(tenantId);
|
||||||
|
const comments = await db.ticketComment.findMany({
|
||||||
|
where: { ticketId },
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
return comments.map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
createdByName: c.author
|
||||||
|
? `${c.author.firstName} ${c.author.lastName}`
|
||||||
|
: 'Unknown',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async addComment(
|
||||||
|
tenantId: string,
|
||||||
|
ticketId: string,
|
||||||
|
userId: string,
|
||||||
|
dto: CreateCommentDto,
|
||||||
|
) {
|
||||||
|
const db = this.prisma.forTenant(tenantId);
|
||||||
|
const ticket = await db.ticket.findFirst({
|
||||||
|
where: { id: ticketId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
throw new NotFoundException('Ticket not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.ticketComment.create({
|
||||||
|
data: {
|
||||||
|
tenantId,
|
||||||
|
ticketId,
|
||||||
|
userId,
|
||||||
|
content: dto.content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ export class UserController {
|
|||||||
return this.userService.findAll(user.tenantId);
|
return this.userService.findAll(user.tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('mention-list')
|
||||||
|
@Roles('tenant_admin', 'technician', 'collector')
|
||||||
|
async getMentionList(@CurrentUser() user: CurrentUserPayload) {
|
||||||
|
return this.userService.findForMention(user.tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
async findById(
|
async findById(
|
||||||
@CurrentUser() user: CurrentUserPayload,
|
@CurrentUser() user: CurrentUserPayload,
|
||||||
|
|||||||
@@ -51,6 +51,16 @@ export class UserService {
|
|||||||
return users.map((u) => this.formatUser(u));
|
return users.map((u) => this.formatUser(u));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findForMention(tenantId: string) {
|
||||||
|
const db = this.prisma.forTenant(tenantId);
|
||||||
|
const users = await db.user.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, firstName: true, lastName: true },
|
||||||
|
orderBy: { firstName: 'asc' },
|
||||||
|
});
|
||||||
|
return users;
|
||||||
|
}
|
||||||
|
|
||||||
async findById(tenantId: string, userId: string) {
|
async findById(tenantId: string, userId: string) {
|
||||||
const db = this.prisma.forTenant(tenantId);
|
const db = this.prisma.forTenant(tenantId);
|
||||||
const user = await db.user.findFirst({
|
const user = await db.user.findFirst({
|
||||||
|
|||||||
Reference in New Issue
Block a user