diff --git a/database/migrations/040_invoice_public_id.sql b/database/migrations/040_invoice_public_id.sql new file mode 100644 index 0000000..0f40159 --- /dev/null +++ b/database/migrations/040_invoice_public_id.sql @@ -0,0 +1,25 @@ +-- Opaque public invoice id (unguessable link token; not the sequential PK) + +ALTER TABLE invoices + ADD COLUMN IF NOT EXISTS public_id VARCHAR(32); + +-- Backfill existing rows with unique 12-digit codes +DO $$ +DECLARE + r RECORD; + candidate TEXT; +BEGIN + FOR r IN SELECT id FROM invoices WHERE public_id IS NULL LOOP + LOOP + candidate := lpad((floor(random() * 900000000000) + 100000000000)::bigint::text, 12, '0'); + EXIT WHEN NOT EXISTS (SELECT 1 FROM invoices WHERE public_id = candidate); + END LOOP; + UPDATE invoices SET public_id = candidate WHERE id = r.id; + END LOOP; +END $$; + +ALTER TABLE invoices + ALTER COLUMN public_id SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_public_id + ON invoices (public_id); diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 99f0435..f32a9a6 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -156,6 +156,7 @@ Example super admin: `+989121111111` / `password` | `037_invoice_name.sql` | Optional `invoices.name` | | `038_invoice_templates.sql` | Full invoice templates + key points/accounts on invoices | | `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts | +| `040_invoice_public_id.sql` | Opaque `public_id` for unguessable public invoice links | Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations. @@ -588,17 +589,17 @@ Super admins issue invoices **to** a business. Schema is ready for future busine | GET/PATCH/DELETE | `/invoice-templates/:templateId` | | GET/POST | `/businesses/:businessId/invoices` | | GET/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` | -| GET | `/public/invoices/:invoiceId` (no auth; issued/paid only) | +| GET | `/public/invoices/:publicId` (no auth; issued/paid only; opaque 12-digit id) | Auth (admin routes): `JwtAuthGuard` + service `assertSuperAdmin`. -Serialized platform invoices include `publicUrl`: `https://{INVOICE_PUBLIC_DOMAIN}/invoices/{id}` (default `meshkee.com`), or `{INVOICE_PUBLIC_BASE_URL}/invoices/{id}` when set. Accounts include optional `accountHolderName`. +Serialized platform invoices include `publicId` + `publicUrl`: `https://{INVOICE_PUBLIC_DOMAIN}/invoices/{publicId}` (default `meshkee.com`), or `{INVOICE_PUBLIC_BASE_URL}/invoices/{publicId}` when set. Accounts include optional `accountHolderName`. -Public HTML viewer lives in the dashboards super-admin SPA (`/invoices/:id`); API serves JSON via `/public/invoices/:id`. +Public HTML viewer lives in the dashboards super-admin SPA (`/invoices/:publicId`); API serves JSON via `/public/invoices/:publicId` (sequential PK is not accepted). Permissions seeded for future business dashboard: `invoices.*`, `invoice_templates.*`. -Module: `src/invoices/` · Migrations: `036`, `037`, `038`, `039` +Module: `src/invoices/` · Migrations: `036` … `040` --- @@ -646,7 +647,7 @@ Follow the pattern in `CategoryVariationsService` / `CategoryTechnicalFormServic | Prisma schema | `prisma/schema.prisma` | | Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` / optional `INVOICE_PUBLIC_BASE_URL`) | | Invoices module | `src/invoices/` | -| Invoice migrations | `database/migrations/036_invoices.sql` … `039_invoice_account_holder.sql` | +| Invoice migrations | `database/migrations/036_invoices.sql` … `040_invoice_public_id.sql` | | Docker services | `docker-compose.yml` | | Dev seed data | `database/seeds/001_sample_data.sql` | | Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` | diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d898b16..99e5d86 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1242,6 +1242,7 @@ model InvoiceTemplateAccount { model Invoice { id BigInt @id @default(autoincrement()) + publicId String @unique(map: "idx_invoices_public_id") @map("public_id") @db.VarChar(32) businessId BigInt @map("business_id") ownerScope InvoiceOwnerScope @default(platform) @map("owner_scope") issuerBusinessId BigInt? @map("issuer_business_id") diff --git a/src/invoices/invoices.controller.ts b/src/invoices/invoices.controller.ts index b4f274d..99128ba 100644 --- a/src/invoices/invoices.controller.ts +++ b/src/invoices/invoices.controller.ts @@ -144,8 +144,8 @@ export class InvoicesController { } /** Public invoice show page (no auth). Issued / paid platform invoices only. */ - @Get('public/invoices/:invoiceId') - getPublic(@Param('invoiceId') invoiceId: string) { - return this.service.getPublicInvoice(invoiceId); + @Get('public/invoices/:publicId') + getPublic(@Param('publicId') publicId: string) { + return this.service.getPublicInvoice(publicId); } } diff --git a/src/invoices/invoices.service.ts b/src/invoices/invoices.service.ts index 1fa8bdf..9e8ba75 100644 --- a/src/invoices/invoices.service.ts +++ b/src/invoices/invoices.service.ts @@ -4,6 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; +import { randomInt } from 'crypto'; import { InvoiceOwnerScope, InvoiceStatus, Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; @@ -22,6 +23,10 @@ import { UpdateInvoiceTemplateDto, } from './dto/invoice.dto'; +/** 12-digit unguessable public link token (not the sequential PK). */ +const PUBLIC_ID_MIN = 100_000_000_000; +const PUBLIC_ID_MAX = 999_999_999_999; + @Injectable() export class InvoicesService { constructor( @@ -29,6 +34,17 @@ export class InvoicesService { private readonly permissions: PermissionsService, ) {} + private async generateUniquePublicId(): Promise { + for (let attempt = 0; attempt < 12; attempt += 1) { + const candidate = String(randomInt(PUBLIC_ID_MIN, PUBLIC_ID_MAX + 1)); + const existing = await this.prisma.invoice.findUnique({ + where: { publicId: candidate }, + select: { id: true }, + }); + if (!existing) return candidate; + } + throw new BadRequestException('Unable to allocate a public invoice id'); + } private async assertSuperAdmin(actor: AuthUser) { if (!(await this.permissions.isSuperAdmin(actor.id))) { throw new ForbiddenException('Super admin access required'); @@ -227,18 +243,19 @@ export class InvoicesService { }; } - private platformInvoicePublicUrl(invoiceId: bigint) { + private platformInvoicePublicUrl(publicId: string) { const base = process.env.INVOICE_PUBLIC_BASE_URL?.trim(); if (base) { - return `${base.replace(/\/$/, '')}/invoices/${invoiceId.toString()}`; + return `${base.replace(/\/$/, '')}/invoices/${publicId}`; } const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com'; - return `https://${domain}/invoices/${invoiceId.toString()}`; + return `https://${domain}/invoices/${publicId}`; } private serializeInvoice( row: { id: bigint; + publicId: string; businessId: bigint; ownerScope: InvoiceOwnerScope; issuerBusinessId: bigint | null; @@ -301,6 +318,7 @@ export class InvoicesService { return { id: row.id.toString(), + publicId: row.publicId, businessId: row.businessId.toString(), ownerScope: row.ownerScope, issuerBusinessId: row.issuerBusinessId?.toString() ?? null, @@ -311,7 +329,7 @@ export class InvoicesService { invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null, publicUrl: row.ownerScope === InvoiceOwnerScope.platform - ? this.platformInvoicePublicUrl(row.id) + ? this.platformInvoicePublicUrl(row.publicId) : null, issuedBy: row.issuedBy?.toString() ?? null, issuedAt: row.issuedAt, @@ -715,17 +733,15 @@ export class InvoicesService { // --- Public invoice viewer (platform) --- - async getPublicInvoice(invoiceIdRaw: string) { - let invoiceId: bigint; - try { - invoiceId = BigInt(invoiceIdRaw); - } catch { + async getPublicInvoice(publicIdRaw: string) { + const publicId = publicIdRaw.trim(); + if (!/^\d{6,32}$/.test(publicId)) { throw new NotFoundException('Invoice not found'); } const row = await this.prisma.invoice.findFirst({ where: { - id: invoiceId, + publicId, ownerScope: InvoiceOwnerScope.platform, status: { in: [InvoiceStatus.issued, InvoiceStatus.paid] }, }, @@ -737,9 +753,9 @@ export class InvoicesService { } const serialized = this.serializeInvoice(row, true); - // Public payload: no internal notes / issuer identity + // Public payload: no internal notes / issuer / sequential id return { - id: serialized.id, + publicId: serialized.publicId, status: serialized.status, name: serialized.name, topText: serialized.topText, @@ -849,6 +865,7 @@ export class InvoicesService { const row = await this.prisma.invoice.create({ data: { + publicId: await this.generateUniquePublicId(), businessId, ownerScope: InvoiceOwnerScope.platform, status: dto.status ?? InvoiceStatus.issued,