mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
Add opaque 12-digit publicId for unguessable invoice links.
Public viewer and URLs use publicId instead of sequential primary keys. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
3faeb9bc0d
commit
723948cd5e
@@ -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);
|
||||||
@@ -156,6 +156,7 @@ Example super admin: `+989121111111` / `password`
|
|||||||
| `037_invoice_name.sql` | Optional `invoices.name` |
|
| `037_invoice_name.sql` | Optional `invoices.name` |
|
||||||
| `038_invoice_templates.sql` | Full invoice templates + key points/accounts on invoices |
|
| `038_invoice_templates.sql` | Full invoice templates + key points/accounts on invoices |
|
||||||
| `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts |
|
| `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.
|
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/PATCH/DELETE | `/invoice-templates/:templateId` |
|
||||||
| GET/POST | `/businesses/:businessId/invoices` |
|
| GET/POST | `/businesses/:businessId/invoices` |
|
||||||
| GET/PATCH/DELETE | `/businesses/:businessId/invoices/:invoiceId` |
|
| 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`.
|
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.*`.
|
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` |
|
| Prisma schema | `prisma/schema.prisma` |
|
||||||
| Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` / optional `INVOICE_PUBLIC_BASE_URL`) |
|
| Env template | `.env.example` (`INVOICE_PUBLIC_DOMAIN` / optional `INVOICE_PUBLIC_BASE_URL`) |
|
||||||
| Invoices module | `src/invoices/` |
|
| 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` |
|
| Docker services | `docker-compose.yml` |
|
||||||
| Dev seed data | `database/seeds/001_sample_data.sql` |
|
| Dev seed data | `database/seeds/001_sample_data.sql` |
|
||||||
| Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` |
|
| Postman | `postman/Meshkee-CMS-Auth.postman_collection.json` |
|
||||||
|
|||||||
@@ -1242,6 +1242,7 @@ model InvoiceTemplateAccount {
|
|||||||
|
|
||||||
model Invoice {
|
model Invoice {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
|
publicId String @unique(map: "idx_invoices_public_id") @map("public_id") @db.VarChar(32)
|
||||||
businessId BigInt @map("business_id")
|
businessId BigInt @map("business_id")
|
||||||
ownerScope InvoiceOwnerScope @default(platform) @map("owner_scope")
|
ownerScope InvoiceOwnerScope @default(platform) @map("owner_scope")
|
||||||
issuerBusinessId BigInt? @map("issuer_business_id")
|
issuerBusinessId BigInt? @map("issuer_business_id")
|
||||||
|
|||||||
@@ -144,8 +144,8 @@ export class InvoicesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Public invoice show page (no auth). Issued / paid platform invoices only. */
|
/** Public invoice show page (no auth). Issued / paid platform invoices only. */
|
||||||
@Get('public/invoices/:invoiceId')
|
@Get('public/invoices/:publicId')
|
||||||
getPublic(@Param('invoiceId') invoiceId: string) {
|
getPublic(@Param('publicId') publicId: string) {
|
||||||
return this.service.getPublicInvoice(invoiceId);
|
return this.service.getPublicInvoice(publicId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { randomInt } from 'crypto';
|
||||||
import { InvoiceOwnerScope, InvoiceStatus, Prisma } from '@prisma/client';
|
import { InvoiceOwnerScope, InvoiceStatus, Prisma } from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
@@ -22,6 +23,10 @@ import {
|
|||||||
UpdateInvoiceTemplateDto,
|
UpdateInvoiceTemplateDto,
|
||||||
} from './dto/invoice.dto';
|
} 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()
|
@Injectable()
|
||||||
export class InvoicesService {
|
export class InvoicesService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -29,6 +34,17 @@ export class InvoicesService {
|
|||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private async generateUniquePublicId(): Promise<string> {
|
||||||
|
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) {
|
private async assertSuperAdmin(actor: AuthUser) {
|
||||||
if (!(await this.permissions.isSuperAdmin(actor.id))) {
|
if (!(await this.permissions.isSuperAdmin(actor.id))) {
|
||||||
throw new ForbiddenException('Super admin access required');
|
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();
|
const base = process.env.INVOICE_PUBLIC_BASE_URL?.trim();
|
||||||
if (base) {
|
if (base) {
|
||||||
return `${base.replace(/\/$/, '')}/invoices/${invoiceId.toString()}`;
|
return `${base.replace(/\/$/, '')}/invoices/${publicId}`;
|
||||||
}
|
}
|
||||||
const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com';
|
const domain = process.env.INVOICE_PUBLIC_DOMAIN?.trim() || 'meshkee.com';
|
||||||
return `https://${domain}/invoices/${invoiceId.toString()}`;
|
return `https://${domain}/invoices/${publicId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeInvoice(
|
private serializeInvoice(
|
||||||
row: {
|
row: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
|
publicId: string;
|
||||||
businessId: bigint;
|
businessId: bigint;
|
||||||
ownerScope: InvoiceOwnerScope;
|
ownerScope: InvoiceOwnerScope;
|
||||||
issuerBusinessId: bigint | null;
|
issuerBusinessId: bigint | null;
|
||||||
@@ -301,6 +318,7 @@ export class InvoicesService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.id.toString(),
|
id: row.id.toString(),
|
||||||
|
publicId: row.publicId,
|
||||||
businessId: row.businessId.toString(),
|
businessId: row.businessId.toString(),
|
||||||
ownerScope: row.ownerScope,
|
ownerScope: row.ownerScope,
|
||||||
issuerBusinessId: row.issuerBusinessId?.toString() ?? null,
|
issuerBusinessId: row.issuerBusinessId?.toString() ?? null,
|
||||||
@@ -311,7 +329,7 @@ export class InvoicesService {
|
|||||||
invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null,
|
invoiceTemplateId: row.invoiceTemplateId?.toString() ?? null,
|
||||||
publicUrl:
|
publicUrl:
|
||||||
row.ownerScope === InvoiceOwnerScope.platform
|
row.ownerScope === InvoiceOwnerScope.platform
|
||||||
? this.platformInvoicePublicUrl(row.id)
|
? this.platformInvoicePublicUrl(row.publicId)
|
||||||
: null,
|
: null,
|
||||||
issuedBy: row.issuedBy?.toString() ?? null,
|
issuedBy: row.issuedBy?.toString() ?? null,
|
||||||
issuedAt: row.issuedAt,
|
issuedAt: row.issuedAt,
|
||||||
@@ -715,17 +733,15 @@ export class InvoicesService {
|
|||||||
|
|
||||||
// --- Public invoice viewer (platform) ---
|
// --- Public invoice viewer (platform) ---
|
||||||
|
|
||||||
async getPublicInvoice(invoiceIdRaw: string) {
|
async getPublicInvoice(publicIdRaw: string) {
|
||||||
let invoiceId: bigint;
|
const publicId = publicIdRaw.trim();
|
||||||
try {
|
if (!/^\d{6,32}$/.test(publicId)) {
|
||||||
invoiceId = BigInt(invoiceIdRaw);
|
|
||||||
} catch {
|
|
||||||
throw new NotFoundException('Invoice not found');
|
throw new NotFoundException('Invoice not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const row = await this.prisma.invoice.findFirst({
|
const row = await this.prisma.invoice.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: invoiceId,
|
publicId,
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
ownerScope: InvoiceOwnerScope.platform,
|
||||||
status: { in: [InvoiceStatus.issued, InvoiceStatus.paid] },
|
status: { in: [InvoiceStatus.issued, InvoiceStatus.paid] },
|
||||||
},
|
},
|
||||||
@@ -737,9 +753,9 @@ export class InvoicesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serialized = this.serializeInvoice(row, true);
|
const serialized = this.serializeInvoice(row, true);
|
||||||
// Public payload: no internal notes / issuer identity
|
// Public payload: no internal notes / issuer / sequential id
|
||||||
return {
|
return {
|
||||||
id: serialized.id,
|
publicId: serialized.publicId,
|
||||||
status: serialized.status,
|
status: serialized.status,
|
||||||
name: serialized.name,
|
name: serialized.name,
|
||||||
topText: serialized.topText,
|
topText: serialized.topText,
|
||||||
@@ -849,6 +865,7 @@ export class InvoicesService {
|
|||||||
|
|
||||||
const row = await this.prisma.invoice.create({
|
const row = await this.prisma.invoice.create({
|
||||||
data: {
|
data: {
|
||||||
|
publicId: await this.generateUniquePublicId(),
|
||||||
businessId,
|
businessId,
|
||||||
ownerScope: InvoiceOwnerScope.platform,
|
ownerScope: InvoiceOwnerScope.platform,
|
||||||
status: dto.status ?? InvoiceStatus.issued,
|
status: dto.status ?? InvoiceStatus.issued,
|
||||||
|
|||||||
Reference in New Issue
Block a user