diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 000000000..9d71f84e6 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "crm-dev", + "runtimeExecutable": "bunx", + "runtimeArgs": ["turbo", "run", "dev", "--filter=api", "--filter=app"], + "port": 3000 + } + ] +} diff --git a/.github/workflows/sync-mailboxes.yml b/.github/workflows/sync-mailboxes.yml new file mode 100644 index 000000000..372bc505d --- /dev/null +++ b/.github/workflows/sync-mailboxes.yml @@ -0,0 +1,33 @@ +name: Sync mailboxes + +on: + schedule: + - cron: "*/10 * * * *" + workflow_dispatch: + +concurrency: + group: sync-mailboxes + cancel-in-progress: false + +permissions: {} + +jobs: + sync: + name: POST /internal/sync/mailboxes + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Trigger mailbox sync + env: + API_URL: ${{ vars.API_URL }} + CRON_SECRET: ${{ secrets.CRON_SECRET }} + run: | + if [ -z "$API_URL" ] || [ -z "$CRON_SECRET" ]; then + echo "::error::API_URL (variable) and CRON_SECRET (secret) must both be set in Settings → Secrets and variables → Actions." + exit 1 + fi + curl --fail-with-body --silent --show-error \ + --max-time 240 \ + --request POST \ + --header "Authorization: Bearer $CRON_SECRET" \ + "${API_URL%/}/internal/sync/mailboxes" diff --git a/AGENTS.md b/AGENTS.md index 2be0b3881..33f4801ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ context until you read them, and the rules in them are not optional. | The record sheet's Agent tab | `docs/agent-panel.md` | | `/settings/connections`, integrations, the intake endpoint | `docs/connections.md` | | The tracking script, the collector, form submissions | `docs/tracking.md` | +| Proposals: issuers, templates, catalog, `apps/api/src/proposals` | `docs/proposals.md` | | Running it locally, Google Cloud, DB commands, secrets | `docs/setup.md` | | Anything that sends a telemetry event, or a new property on one | `docs/telemetry.md` | | `.github/workflows`, versions, changelog, how a change reaches `release` | `CONTRIBUTING.md` | diff --git a/apps/api/package.json b/apps/api/package.json index 151aec0f8..1837e9c90 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -43,8 +43,10 @@ "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "context.dev": "^2.7.0", + "docxtemplater": "3.71.0", "helmet": "^8.3.0", "nestjs-trpc": "^2.13.0", + "pizzip": "3.3.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "trpc-to-openapi": "^3.3.0", diff --git a/apps/api/scripts/build-func.mjs b/apps/api/scripts/build-func.mjs index b06f0c017..975d1d0a9 100644 --- a/apps/api/scripts/build-func.mjs +++ b/apps/api/scripts/build-func.mjs @@ -169,7 +169,7 @@ writeFileSync( maxDuration: 60, memory: 1769, environment: { NODE_ENV: "production" }, - regions: ["iad1"], + regions: ["fra1"], }), ); writeFileSync( @@ -177,7 +177,7 @@ writeFileSync( JSON.stringify({ version: 3, routes: [{ src: "/(.*)", dest: "/api/index" }], - crons: [{ path: "/internal/sync/google", schedule: "*/5 * * * *" }], + crons: JSON.parse(readFileSync(join(apiDir, "vercel.json"), "utf8")).crons, }), ); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 82a345df2..a1442a628 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -22,10 +22,12 @@ import { EnrichmentModule } from "./enrichment/enrichment.module"; import { FieldsModule } from "./fields/fields.module"; import { GoogleModule } from "./google/google.module"; import { HealthModule } from "./health/health.module"; +import { ImportModule } from "./import/import.module"; import { LoggingModule } from "./logging/logging.module"; import { logAuthRoute } from "./logging/request-logger.middleware"; import { MailboxModule } from "./mailbox/mailbox.module"; import { MicrosoftModule } from "./microsoft/microsoft.module"; +import { ProposalsModule } from "./proposals/proposals.module"; import { SavedViewsModule } from "./saved-views/saved-views.module"; import { SearchModule } from "./search/search.module"; import { SettingsModule } from "./settings/settings.module"; @@ -61,6 +63,7 @@ import { WorkspaceModule } from "./workspace/workspace.module"; CurrencyModule, DealsModule, FieldsModule, + ImportModule, ActivitiesModule, AgentModule, EnrichmentModule, @@ -79,6 +82,7 @@ import { WorkspaceModule } from "./workspace/workspace.module"; TrackingModule, ArchiveModule, SavedViewsModule, + ProposalsModule, ], }) export class AppModule {} diff --git a/apps/api/src/generated/server.ts b/apps/api/src/generated/server.ts index b77da4001..65c840c09 100644 --- a/apps/api/src/generated/server.ts +++ b/apps/api/src/generated/server.ts @@ -26,6 +26,8 @@ import { enrichmentQueueInput } from "@crm/validation/enrichment-queue"; import { fieldListInput, fieldListOutput, fieldByKeyInput, serializedFieldOutput, fieldEntityInput, fieldFiltersOutput, fieldIdInput, fieldCoverageOutput, fieldCreateInput, fieldUpdateArgs, fieldReorderInput, fieldReorderOutput, fieldDeleteOutput, fieldBackfillOutput } from "../fields/fields.contracts"; import { googleConnectionStatusOutput, setAutoCreateInput, suppressDomainInput, suppressDomainOutput, threadInput, emailThreadOutput, calendarEventInput, calendarEventOutput } from "../google/google.contracts"; import { purgeSyncedDataOutput, revokeAccessOutput, microsoftConnectionStatusOutput, setOutlookAutoCreateInput } from "../microsoft/microsoft.contracts"; +import { importRequestInput, importPreviewOutput, importCommitOutput } from "../import/import.contracts"; +import { accessOutput, issuersOutput, createIssuerInput, idInput, updateIssuerInput, okOutput, uploadTemplateInput, templateOutput, fileOutput, companyInput, companyProfileOutput, setCompanyIssuerInput, setClientLogoInput, listServicesInput, servicesOutput, serviceOutput, saveServiceInput, dealInput, dealProposalsOutput, draftInput, draftOutput, generateInput, generateOutput, downloadInput, uploadFinalInput, setStatusInput, setStatusOutput } from "../proposals/proposals.contracts"; import { savedViewListInput, savedViewListOutput, savedViewCreateInput, savedViewOutput, savedViewUpdateArgs, savedViewIdInput, savedViewDeleteOutput } from "../saved-views/saved-views.contracts"; import { agentModelOutput, modelCatalogOutput, setAgentModelInput, researchKeyOutput, setResearchKeyInput, archiveRetentionOutput, setArchiveRetentionDaysInput } from "../settings/settings.contracts"; import { slackStatusOutput, slackMatchesOutput, slackChannelsInput, slackChannelsOutput, slackJoinChannelInput, slackJoinChannelOutput, slackRefreshPeopleOutput, slackCreateChannelInput, slackCreateChannelOutput, slackDisconnectOutput } from "../slack/slack.contracts"; @@ -558,6 +560,16 @@ const appRouter = t.router({ .output(calendarEventOutput) .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), + import: t.router({ + preview: publicProcedure + .input(importRequestInput) + .output(importPreviewOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + commit: publicProcedure + .input(importRequestInput) + .output(importCommitOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) + }), microsoft: t.router({ status: publicProcedure .output(microsoftConnectionStatusOutput) @@ -576,6 +588,98 @@ const appRouter = t.router({ .output(microsoftConnectionStatusOutput) .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) }), + proposals: t.router({ + access: publicProcedure + .output(accessOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + issuers: publicProcedure + .output(issuersOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + createIssuer: publicProcedure + .input(createIssuerInput) + .output(idInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + updateIssuer: publicProcedure + .input(updateIssuerInput) + .output(idInput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + deleteIssuer: publicProcedure + .input(idInput) + .output(okOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + uploadTemplate: publicProcedure + .input(uploadTemplateInput) + .output(templateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + archiveTemplate: publicProcedure + .input(idInput) + .output(okOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + downloadTemplate: publicProcedure + .input(idInput) + .output(fileOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + companyProfile: publicProcedure + .input(companyInput) + .output(companyProfileOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + setCompanyIssuer: publicProcedure + .input(setCompanyIssuerInput) + .output(companyProfileOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + setClientLogo: publicProcedure + .input(setClientLogoInput) + .output(companyProfileOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + removeClientLogo: publicProcedure + .input(companyInput) + .output(companyProfileOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + services: publicProcedure + .input(listServicesInput) + .output(servicesOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + service: publicProcedure + .input(idInput) + .output(serviceOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + saveService: publicProcedure + .input(saveServiceInput) + .output(serviceOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + archiveService: publicProcedure + .input(idInput) + .output(okOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + restoreService: publicProcedure + .input(idInput) + .output(okOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + forDeal: publicProcedure + .input(dealInput) + .output(dealProposalsOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + draft: publicProcedure + .input(draftInput) + .output(draftOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + generate: publicProcedure + .input(generateInput) + .output(generateOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + download: publicProcedure + .input(downloadInput) + .output(fileOutput) + .query(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + uploadFinal: publicProcedure + .input(uploadFinalInput) + .output(okOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any), + setStatus: publicProcedure + .input(setStatusInput) + .output(setStatusOutput) + .mutation(async () => "PLACEHOLDER_DO_NOT_REMOVE" as any) + }), savedViews: t.router({ list: publicProcedure .input(savedViewListInput) diff --git a/apps/api/src/import/import.contracts.ts b/apps/api/src/import/import.contracts.ts new file mode 100644 index 000000000..33e1b4853 --- /dev/null +++ b/apps/api/src/import/import.contracts.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +export const importRowInput = z.record(z.string(), z.string()); + +export type ImportRowInput = z.infer; + +export const importRequestInput = z.object({ + rows: z + .array(importRowInput) + .min(1, "O CSV precisa de pelo menos uma linha."), +}); + +export type ImportRequestInput = z.infer; + +const importRowErrorOutput = z.object({ + rowIndex: z.number(), + message: z.string(), +}); + +export const importPreviewOutput = z.object({ + totalRows: z.number(), + companiesToCreate: z.number(), + companiesToUpdate: z.number(), + contactsToCreate: z.number(), + contactsToUpdate: z.number(), + unrecognizedHeaders: z.array(z.string()), + errors: z.array(importRowErrorOutput), +}); + +export type ImportPreviewOutput = z.infer; + +export const importCommitOutput = z.object({ + companiesCreated: z.number(), + companiesUpdated: z.number(), + contactsCreated: z.number(), + contactsUpdated: z.number(), +}); + +export type ImportCommitOutput = z.infer; diff --git a/apps/api/src/import/import.module.ts b/apps/api/src/import/import.module.ts new file mode 100644 index 000000000..6d70dc714 --- /dev/null +++ b/apps/api/src/import/import.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { FieldsModule } from "../fields/fields.module"; +import { TrpcModule } from "../trpc/trpc.module"; +import { ImportRouter } from "./import.router"; +import { ImportService } from "./import.service"; + +@Module({ + imports: [FieldsModule, TrpcModule], + providers: [ImportService, ImportRouter], + exports: [ImportService], +}) +export class ImportModule {} diff --git a/apps/api/src/import/import.router.ts b/apps/api/src/import/import.router.ts new file mode 100644 index 000000000..75e25c611 --- /dev/null +++ b/apps/api/src/import/import.router.ts @@ -0,0 +1,37 @@ +import { Inject } from "@nestjs/common"; +import { Input, Mutation, Router, UseMiddlewares } from "nestjs-trpc"; +import type { z } from "zod"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; +import { + importCommitOutput, + importPreviewOutput, + importRequestInput, +} from "./import.contracts"; +import { ImportService } from "./import.service"; + +@Router({ alias: "import" }) +@UseMiddlewares(AuthMiddleware) +export class ImportRouter { + constructor( + @Inject(ImportService) private readonly importer: ImportService, + ) {} + + @Mutation({ + input: importRequestInput, + output: importPreviewOutput, + meta: restMeta("POST", "/import/preview", ["Import"]), + }) + async preview(@Input() input: z.infer) { + return this.importer.preview(input); + } + + @Mutation({ + input: importRequestInput, + output: importCommitOutput, + meta: restMeta("POST", "/import/commit", ["Import"]), + }) + async commit(@Input() input: z.infer) { + return this.importer.commit(input); + } +} diff --git a/apps/api/src/import/import.service.ts b/apps/api/src/import/import.service.ts new file mode 100644 index 000000000..86998344f --- /dev/null +++ b/apps/api/src/import/import.service.ts @@ -0,0 +1,444 @@ +import { type Db, RecordSource } from "@crm/db"; +import { BadRequestException, Injectable } from "@nestjs/common"; +import { domainFromEmail, normalizeDomain } from "../companies/domain"; +import { normalizeEmail } from "../crm/values"; +import { InjectDatabase } from "../database/database.constants"; +import { FieldsService } from "../fields/fields.service"; +import type { + ImportCommitOutput, + ImportPreviewOutput, + ImportRequestInput, + ImportRowInput, +} from "./import.contracts"; + +const ALLOWED_HEADERS = new Set([ + "company_name", + "company_domain", + "industry", + "country", + "country_code", + "first_name", + "last_name", + "email", + "title", + "linkedin_url", + "account_type", + "lead_source", + "idioma", + "evento_de_origem", + "mensagem_gancho", + "abordagem_sugerida", +]); + +type CompanyAction = "create" | "update" | "reuse-pending"; +type ContactAction = "create" | "update" | "reuse-pending"; + +type CompanyPlan = + | { action: "skip" } + | { + action: CompanyAction; + existingId: string | null; + domain: string | null; + name: string; + industry: string | null; + country: string | null; + countryCode: string | null; + fields: Record; + }; + +type ContactPlan = + | { action: "skip" } + | { + action: ContactAction; + existingId: string | null; + email: string | null; + firstName: string; + lastName: string | null; + title: string | null; + linkedinUrl: string | null; + fields: Record; + }; + +type RowPlan = { + rowIndex: number; + company: CompanyPlan; + contact: ContactPlan; + error: string | null; +}; + +type EvaluatedImport = { + unrecognizedHeaders: string[]; + rows: RowPlan[]; +}; + +function cell(row: ImportRowInput, key: string): string | undefined { + const value = row[key]; + if (value === undefined) return undefined; + const trimmed = value.trim(); + return trimmed === "" ? undefined : trimmed; +} + +@Injectable() +export class ImportService { + constructor( + @InjectDatabase() private readonly db: Db, + private readonly fields: FieldsService, + ) {} + + async preview(input: ImportRequestInput): Promise { + const evaluated = await this.evaluate(input.rows); + return this.summarize(evaluated); + } + + async commit(input: ImportRequestInput): Promise { + const evaluated = await this.evaluate(input.rows); + + const hasRowErrors = evaluated.rows.some((row) => row.error !== null); + if (hasRowErrors || evaluated.unrecognizedHeaders.length > 0) { + throw new BadRequestException( + "Corrija os erros apontados pelo dry-run antes de confirmar a importação.", + ); + } + + let companiesCreated = 0; + let companiesUpdated = 0; + let contactsCreated = 0; + let contactsUpdated = 0; + + const companyIdByDomain = new Map(); + const contactIdByEmail = new Map(); + + for (const row of evaluated.rows) { + await this.db.$transaction(async (tx) => { + let companyId: string | null = null; + + if (row.company.action !== "skip") { + const plan = row.company; + + if (plan.action === "update" && plan.existingId) { + companyId = plan.existingId; + await tx.company.update({ + where: { id: companyId }, + data: { + industry: plan.industry ?? undefined, + country: plan.country ?? undefined, + countryCode: plan.countryCode ?? undefined, + }, + }); + companiesUpdated += 1; + } else if ( + plan.action === "reuse-pending" && + plan.domain && + companyIdByDomain.has(plan.domain) + ) { + companyId = companyIdByDomain.get(plan.domain) ?? null; + } else { + const created = await tx.company.create({ + data: { + name: plan.name, + domain: plan.domain, + industry: plan.industry, + country: plan.country, + countryCode: plan.countryCode, + source: RecordSource.IMPORT, + }, + select: { id: true }, + }); + companyId = created.id; + companiesCreated += 1; + if (plan.domain) companyIdByDomain.set(plan.domain, companyId); + } + + if (companyId && Object.keys(plan.fields).length > 0) { + await this.fields.applyValues( + tx, + "COMPANY", + companyId, + plan.fields, + ); + } + } + + if (row.contact.action !== "skip") { + const plan = row.contact; + let contactId: string; + + if (plan.action === "update" && plan.existingId) { + contactId = plan.existingId; + await tx.contact.update({ + where: { id: contactId }, + data: { + title: plan.title ?? undefined, + linkedinUrl: plan.linkedinUrl ?? undefined, + companyId: companyId ?? undefined, + }, + }); + contactsUpdated += 1; + } else if ( + plan.action === "reuse-pending" && + plan.email && + contactIdByEmail.has(plan.email) + ) { + contactId = contactIdByEmail.get(plan.email) as string; + } else { + const created = await tx.contact.create({ + data: { + firstName: plan.firstName, + lastName: plan.lastName, + email: plan.email, + title: plan.title, + linkedinUrl: plan.linkedinUrl, + companyId, + source: RecordSource.IMPORT, + }, + select: { id: true }, + }); + contactId = created.id; + contactsCreated += 1; + if (plan.email) contactIdByEmail.set(plan.email, contactId); + } + + if (Object.keys(plan.fields).length > 0) { + await this.fields.applyValues( + tx, + "CONTACT", + contactId, + plan.fields, + ); + } + } + }); + } + + return { + companiesCreated, + companiesUpdated, + contactsCreated, + contactsUpdated, + }; + } + + private async evaluate(rows: ImportRowInput[]): Promise { + const unrecognizedHeaders = [ + ...new Set( + rows + .flatMap((row) => Object.keys(row)) + .filter((key) => !ALLOWED_HEADERS.has(key)), + ), + ]; + + const domains = new Set(); + const emails = new Set(); + + type Parsed = + | { rowIndex: number; error: string } + | { + rowIndex: number; + error: null; + row: ImportRowInput; + companyName: string | undefined; + domain: string | null; + firstName: string | undefined; + email: string | null; + }; + + const parsedRows: Parsed[] = rows.map((row, rowIndex) => { + const companyName = cell(row, "company_name"); + const firstName = cell(row, "first_name"); + const rawEmail = cell(row, "email"); + + if (!companyName && !firstName) { + return { + rowIndex, + error: + "Linha vazia: não há nome de empresa (company_name) nem nome de contato (first_name).", + }; + } + + if (!firstName && rawEmail) { + return { + rowIndex, + error: + "Há email nesta linha, mas first_name está vazio — todo contato precisa de um nome.", + }; + } + + let domain: string | null = null; + if (companyName) { + domain = + normalizeDomain(cell(row, "company_domain") ?? null) ?? + domainFromEmail(rawEmail ?? null); + if (domain) domains.add(domain); + } + + let email: string | null = null; + if (firstName) { + email = normalizeEmail(rawEmail ?? ""); + if (email) emails.add(email); + } + + return { + rowIndex, + error: null, + row, + companyName, + domain, + firstName, + email, + }; + }); + + const [existingCompanies, existingContacts] = await Promise.all([ + domains.size > 0 + ? this.db.company.findMany({ + where: { domain: { in: [...domains] }, archivedAt: null }, + select: { id: true, domain: true }, + }) + : Promise.resolve([]), + emails.size > 0 + ? this.db.contact.findMany({ + where: { email: { in: [...emails] }, archivedAt: null }, + select: { id: true, email: true }, + }) + : Promise.resolve([]), + ]); + + const companyIdByDomain = new Map( + existingCompanies + .filter((company) => company.domain) + .map((company) => [company.domain as string, company.id]), + ); + const contactIdByEmail = new Map( + existingContacts + .filter((contact) => contact.email) + .map((contact) => [contact.email as string, contact.id]), + ); + + const pendingDomains = new Set(); + const pendingEmails = new Set(); + + const planned: RowPlan[] = parsedRows.map((parsed) => { + if (parsed.error !== null) { + return { + rowIndex: parsed.rowIndex, + company: { action: "skip" }, + contact: { action: "skip" }, + error: parsed.error, + }; + } + + const { rowIndex, companyName, domain, firstName, email, row } = parsed; + + let company: CompanyPlan = { action: "skip" }; + if (companyName) { + const fields: Record = {}; + const accountType = cell(row, "account_type"); + const leadSource = cell(row, "lead_source"); + if (accountType) fields.account_type = accountType; + if (leadSource) fields.lead_source = leadSource; + + const base = { + domain, + name: companyName, + industry: cell(row, "industry") ?? null, + country: cell(row, "country") ?? null, + countryCode: cell(row, "country_code") ?? null, + fields, + }; + + if (domain && companyIdByDomain.has(domain)) { + company = { + action: "update", + existingId: companyIdByDomain.get(domain) ?? null, + ...base, + }; + } else if (domain && pendingDomains.has(domain)) { + company = { action: "reuse-pending", existingId: null, ...base }; + } else { + if (domain) pendingDomains.add(domain); + company = { action: "create", existingId: null, ...base }; + } + } + + let contact: ContactPlan = { action: "skip" }; + if (firstName) { + const fields: Record = {}; + const idioma = cell(row, "idioma"); + const evento = cell(row, "evento_de_origem"); + const gancho = cell(row, "mensagem_gancho"); + const abordagem = cell(row, "abordagem_sugerida"); + if (idioma) fields.idioma = idioma; + if (evento) fields.evento_de_origem = evento; + if (gancho) fields.mensagem_gancho = gancho; + if (abordagem) fields.abordagem_sugerida = abordagem; + + const base = { + email, + firstName, + lastName: cell(row, "last_name") ?? null, + title: cell(row, "title") ?? null, + linkedinUrl: cell(row, "linkedin_url") ?? null, + fields, + }; + + if (email && contactIdByEmail.has(email)) { + contact = { + action: "update", + existingId: contactIdByEmail.get(email) ?? null, + ...base, + }; + } else if (email && pendingEmails.has(email)) { + contact = { action: "reuse-pending", existingId: null, ...base }; + } else { + if (email) pendingEmails.add(email); + contact = { action: "create", existingId: null, ...base }; + } + } + + return { rowIndex, company, contact, error: null }; + }); + + return { unrecognizedHeaders, rows: planned }; + } + + private summarize(evaluated: EvaluatedImport): ImportPreviewOutput { + let companiesToCreate = 0; + let companiesToUpdate = 0; + let contactsToCreate = 0; + let contactsToUpdate = 0; + const errors: { rowIndex: number; message: string }[] = []; + + for (const row of evaluated.rows) { + if (row.error) { + errors.push({ rowIndex: row.rowIndex, message: row.error }); + continue; + } + + if (row.company.action === "create") companiesToCreate += 1; + if ( + row.company.action === "update" || + row.company.action === "reuse-pending" + ) { + companiesToUpdate += 1; + } + + if (row.contact.action === "create") contactsToCreate += 1; + if ( + row.contact.action === "update" || + row.contact.action === "reuse-pending" + ) { + contactsToUpdate += 1; + } + } + + return { + totalRows: evaluated.rows.length, + companiesToCreate, + companiesToUpdate, + contactsToCreate, + contactsToUpdate, + unrecognizedHeaders: evaluated.unrecognizedHeaders, + errors, + }; + } +} diff --git a/apps/api/src/proposals/catalog.service.ts b/apps/api/src/proposals/catalog.service.ts new file mode 100644 index 000000000..616b1bb1c --- /dev/null +++ b/apps/api/src/proposals/catalog.service.ts @@ -0,0 +1,146 @@ +import type { Db, JsonValue, Prisma } from "@crm/db"; +import { + type ProposalExtra, + parseProposalContent, + parseProposalExtras, +} from "@crm/validation/proposal"; +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import type { z } from "zod"; +import { InjectDatabase } from "../database/database.constants"; +import { prismaCode } from "./prisma-code"; +import type { ServiceOutput, saveServiceInput } from "./proposals.contracts"; + +const SERVICE_INCLUDE = { + prices: { + include: { issuer: { select: { name: true } } }, + orderBy: { issuer: { name: "asc" } }, + }, +} as const satisfies Prisma.ServiceInclude; + +type ServiceRow = Prisma.ServiceGetPayload<{ include: typeof SERVICE_INCLUDE }>; + +function extrasOf(value: JsonValue): ProposalExtra[] { + try { + return parseProposalExtras(value); + } catch { + return []; + } +} + +function summary(row: ServiceRow) { + return { + id: row.id, + name: row.name, + archived: row.archivedAt !== null, + updatedAt: row.updatedAt.toISOString(), + prices: row.prices.map((price) => ({ + issuerId: price.issuerId, + issuerName: price.issuer.name, + amount: price.amount.toFixed(2), + extras: extrasOf(price.extras), + })), + }; +} + +@Injectable() +export class CatalogService { + private readonly logger = new Logger(CatalogService.name); + + constructor(@InjectDatabase() private readonly db: Db) {} + + async list(archived: boolean) { + const rows = await this.db.service.findMany({ + where: { archivedAt: archived ? { not: null } : null }, + include: SERVICE_INCLUDE, + orderBy: { name: "asc" }, + }); + return rows.map(summary); + } + + async byId(id: string): Promise { + const row = await this.db.service.findUnique({ + where: { id }, + include: SERVICE_INCLUDE, + }); + if (!row) throw new NotFoundException(`No service with id ${id}.`); + return { ...summary(row), content: parseProposalContent(row.content) }; + } + + async save(input: z.infer): Promise { + const id = await this.write(input); + + this.logger.log({ + message: input.id ? "Service updated" : "Service created", + serviceId: id, + prices: input.prices.length, + }); + + return this.byId(id); + } + + private async write( + input: z.infer, + ): Promise { + try { + return await this.db.$transaction(async (tx) => { + const service = input.id + ? await tx.service.update({ + where: { id: input.id }, + data: { name: input.name, content: input.content }, + select: { id: true }, + }) + : await tx.service.create({ + data: { name: input.name, content: input.content }, + select: { id: true }, + }); + + await tx.servicePrice.deleteMany({ where: { serviceId: service.id } }); + + if (input.prices.length > 0) { + await tx.servicePrice.createMany({ + data: input.prices.map((price) => ({ + serviceId: service.id, + issuerId: price.issuerId, + amount: price.amount, + extras: price.extras, + })), + }); + } + + return service.id; + }); + } catch (cause) { + const code = prismaCode(cause); + if (code === "P2025") { + throw new NotFoundException(`No service with id ${input.id}.`); + } + if (code === "P2002") { + throw new BadRequestException( + "Each issuer can have one price per service.", + ); + } + if (code === "P2003") { + throw new BadRequestException( + "A price points at an issuer that does not exist.", + ); + } + throw cause; + } + } + + async setArchived(id: string, archived: boolean) { + const updated = await this.db.service.updateMany({ + where: { id }, + data: { archivedAt: archived ? new Date() : null }, + }); + if (updated.count === 0) { + throw new NotFoundException(`No service with id ${id}.`); + } + return { ok: true as const }; + } +} diff --git a/apps/api/src/proposals/issuers.service.ts b/apps/api/src/proposals/issuers.service.ts new file mode 100644 index 000000000..36de782d2 --- /dev/null +++ b/apps/api/src/proposals/issuers.service.ts @@ -0,0 +1,383 @@ +import type { Db, JsonValue } from "@crm/db"; +import { + type ProposalPayment, + parseProposalPayments, + readProposalLogoSlot, +} from "@crm/validation/proposal"; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import type { z } from "zod"; +import { InjectDatabase } from "../database/database.constants"; +import { prismaCode } from "./prisma-code"; +import { + imageSize, + inspectTemplate, + type LogoSlot, + TemplateError, +} from "./proposal-docx"; +import type { + CompanyProfileOutput, + createIssuerInput, + IssuerOutput, + setClientLogoInput, + TemplateOutput, + updateIssuerInput, + uploadTemplateInput, +} from "./proposals.contracts"; + +const TEMPLATE_SELECT = { + id: true, + name: true, + fileName: true, + size: true, + markers: true, + logoSlot: true, + createdAt: true, + archivedAt: true, +} as const; + +type TemplateRow = { + id: string; + name: string; + fileName: string; + size: number; + markers: string[]; + logoSlot: JsonValue; + createdAt: Date; + archivedAt: Date | null; +}; + +function toTemplate(row: TemplateRow): TemplateOutput { + return { + id: row.id, + name: row.name, + fileName: row.fileName, + size: row.size, + markers: row.markers, + logoSlot: readProposalLogoSlot(row.logoSlot), + createdAt: row.createdAt.toISOString(), + archived: row.archivedAt !== null, + }; +} + +function decode(contentBase64: string): Buffer { + return Buffer.from(contentBase64, "base64"); +} + +export function toBytes(buffer: Buffer): Uint8Array { + return new Uint8Array(buffer); +} + +@Injectable() +export class IssuersService { + private readonly logger = new Logger(IssuersService.name); + + constructor(@InjectDatabase() private readonly db: Db) {} + + async list(): Promise { + const rows = await this.db.issuer.findMany({ + orderBy: { name: "asc" }, + include: { + templates: { + select: TEMPLATE_SELECT, + orderBy: { createdAt: "desc" }, + }, + _count: { select: { companies: true, proposals: true } }, + }, + }); + + return rows.map((row) => ({ + id: row.id, + name: row.name, + numberPrefix: row.numberPrefix, + defaultPayments: this.payments(row.defaultPayments), + active: row.active, + clients: row._count.companies, + proposals: row._count.proposals, + templates: row.templates.map(toTemplate), + })); + } + + private payments(value: JsonValue): ProposalPayment[] { + try { + return parseProposalPayments(value); + } catch { + return []; + } + } + + async create(input: z.infer) { + try { + const issuer = await this.db.issuer.create({ + data: { + name: input.name, + numberPrefix: input.numberPrefix, + defaultPayments: input.defaultPayments, + }, + select: { id: true }, + }); + this.logger.log({ message: "Issuer created", issuerId: issuer.id }); + return issuer; + } catch (cause) { + if (prismaCode(cause) === "P2002") { + throw new ConflictException(`An issuer named ${input.name} exists.`); + } + throw cause; + } + } + + async update(input: z.infer) { + try { + return await this.db.issuer.update({ + where: { id: input.id }, + data: { + name: input.name, + numberPrefix: input.numberPrefix, + defaultPayments: input.defaultPayments, + active: input.active, + }, + select: { id: true }, + }); + } catch (cause) { + if (prismaCode(cause) === "P2002") { + throw new ConflictException(`An issuer named ${input.name} exists.`); + } + if (prismaCode(cause) === "P2025") { + throw new NotFoundException(`No issuer with id ${input.id}.`); + } + throw cause; + } + } + + async remove(id: string) { + const issuer = await this.db.issuer.findUnique({ + where: { id }, + select: { + _count: { + select: { + companies: true, + proposals: true, + prices: true, + templates: true, + }, + }, + }, + }); + + if (!issuer) throw new NotFoundException(`No issuer with id ${id}.`); + + const { companies, proposals, prices, templates } = issuer._count; + if (companies + proposals + prices + templates > 0) { + throw new ConflictException( + "This issuer is in use by clients, prices, templates or proposals. Deactivate it instead.", + ); + } + + await this.db.issuer.delete({ where: { id } }); + return { ok: true as const }; + } + + async uploadTemplate( + userId: string, + input: z.infer, + ): Promise { + if (!input.fileName.toLowerCase().endsWith(".docx")) { + throw new BadRequestException( + "Only Word documents (.docx) are accepted.", + ); + } + + const issuer = await this.db.issuer.findUnique({ + where: { id: input.issuerId }, + select: { id: true }, + }); + if (!issuer) { + throw new NotFoundException(`No issuer with id ${input.issuerId}.`); + } + + const content = decode(input.contentBase64); + const inspection = this.inspect(content); + + if (inspection.unknown.length > 0) { + throw new BadRequestException( + `The template uses markers this CRM does not know: ${inspection.unknown + .map((marker) => `{${marker}}`) + .join(", ")}.`, + ); + } + + const row = await this.db.issuerTemplate.create({ + data: { + issuerId: input.issuerId, + name: input.name, + fileName: input.fileName, + content: toBytes(content), + size: content.length, + markers: inspection.markers, + logoSlot: inspection.logoSlot ?? undefined, + uploadedById: userId, + }, + select: TEMPLATE_SELECT, + }); + + this.logger.log({ + message: "Proposal template uploaded", + templateId: row.id, + issuerId: input.issuerId, + markers: inspection.markers.length, + logoSlot: inspection.logoSlot !== null, + }); + + return toTemplate(row); + } + + private inspect(content: Buffer) { + try { + return inspectTemplate(content); + } catch (cause) { + if (cause instanceof TemplateError) { + throw new BadRequestException( + [cause.message, ...cause.details].join(" "), + ); + } + throw cause; + } + } + + async archiveTemplate(id: string) { + const updated = await this.db.issuerTemplate.updateMany({ + where: { id, archivedAt: null }, + data: { archivedAt: new Date() }, + }); + if (updated.count === 0) { + const exists = await this.db.issuerTemplate.count({ where: { id } }); + if (!exists) throw new NotFoundException(`No template with id ${id}.`); + } + return { ok: true as const }; + } + + async downloadTemplate(id: string) { + const row = await this.db.issuerTemplate.findUnique({ + where: { id }, + select: { fileName: true, content: true }, + }); + if (!row) throw new NotFoundException(`No template with id ${id}.`); + return { + fileName: row.fileName, + contentBase64: Buffer.from(row.content).toString("base64"), + }; + } + + async companyProfile(companyId: string): Promise { + const company = await this.db.company.findUnique({ + where: { id: companyId }, + select: { + id: true, + issuerId: true, + issuer: { select: { name: true } }, + proposalLogo: { select: { content: true, width: true, height: true } }, + }, + }); + + if (!company) + throw new NotFoundException(`No company with id ${companyId}.`); + + const proposals = await this.db.proposal.count({ + where: { deal: { companyId } }, + }); + + return { + companyId: company.id, + issuerId: company.issuerId, + issuerName: company.issuer?.name ?? null, + proposals, + logo: company.proposalLogo + ? { + contentBase64: Buffer.from(company.proposalLogo.content).toString( + "base64", + ), + width: company.proposalLogo.width, + height: company.proposalLogo.height, + } + : null, + }; + } + + async setCompanyIssuer(companyId: string, issuerId: string | null) { + if (issuerId) { + const issuer = await this.db.issuer.findUnique({ + where: { id: issuerId }, + select: { active: true }, + }); + if (!issuer) + throw new NotFoundException(`No issuer with id ${issuerId}.`); + if (!issuer.active) { + throw new BadRequestException("That issuer is deactivated."); + } + } + + try { + await this.db.company.update({ + where: { id: companyId }, + data: { issuerId }, + select: { id: true }, + }); + } catch (cause) { + if (prismaCode(cause) === "P2025") { + throw new NotFoundException(`No company with id ${companyId}.`); + } + throw cause; + } + + this.logger.log({ message: "Company issuer set", companyId, issuerId }); + return this.companyProfile(companyId); + } + + async setClientLogo( + userId: string, + input: z.infer, + ) { + const content = decode(input.contentBase64); + const size = imageSize(content); + + if (!size || content[1] !== 0x50) { + throw new BadRequestException("The logo must be a PNG image."); + } + + const exists = await this.db.company.count({ + where: { id: input.companyId }, + }); + if (!exists) { + throw new NotFoundException(`No company with id ${input.companyId}.`); + } + + await this.db.clientLogo.upsert({ + where: { companyId: input.companyId }, + create: { + companyId: input.companyId, + content: toBytes(content), + width: size.width, + height: size.height, + uploadedById: userId, + }, + update: { + content: toBytes(content), + width: size.width, + height: size.height, + uploadedById: userId, + }, + }); + + return this.companyProfile(input.companyId); + } + + async removeClientLogo(companyId: string) { + await this.db.clientLogo.deleteMany({ where: { companyId } }); + return this.companyProfile(companyId); + } +} diff --git a/apps/api/src/proposals/prisma-code.ts b/apps/api/src/proposals/prisma-code.ts new file mode 100644 index 000000000..de49f044d --- /dev/null +++ b/apps/api/src/proposals/prisma-code.ts @@ -0,0 +1,7 @@ +import { Prisma } from "@crm/db"; + +export function prismaCode(cause: unknown): string | null { + return cause instanceof Prisma.PrismaClientKnownRequestError + ? cause.code + : null; +} diff --git a/apps/api/src/proposals/proposal-access.service.ts b/apps/api/src/proposals/proposal-access.service.ts new file mode 100644 index 000000000..6c7c106d5 --- /dev/null +++ b/apps/api/src/proposals/proposal-access.service.ts @@ -0,0 +1,21 @@ +import { canManageProposals, workspaceRoleOf } from "@crm/auth"; +import type { Db } from "@crm/db"; +import { ForbiddenException, Injectable } from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; + +@Injectable() +export class ProposalAccessService { + constructor(@InjectDatabase() private readonly db: Db) {} + + async canManage(userId: string): Promise { + return canManageProposals(await workspaceRoleOf(userId, this.db)); + } + + async requireOwner(userId: string): Promise { + if (!(await this.canManage(userId))) { + throw new ForbiddenException( + "Only the workspace owner can use proposals.", + ); + } + } +} diff --git a/apps/api/src/proposals/proposal-data.ts b/apps/api/src/proposals/proposal-data.ts new file mode 100644 index 000000000..a0adf0102 --- /dev/null +++ b/apps/api/src/proposals/proposal-data.ts @@ -0,0 +1,138 @@ +import type { + ProposalContent, + ProposalExtra, + ProposalLine, + ProposalPayment, +} from "@crm/validation/proposal"; +import { + formatEur, + formatProposalDate, + splitByPercent, + toCents, +} from "./proposal-format"; + +type ProposalDocument = { + date: Date; + number: string; + clientName: string; + attentionName: string; + attentionPosition: string; + jurisdictionText: string; + content: ProposalContent; + lines: ProposalLine[]; + extras: ProposalExtra[]; + payments: ProposalPayment[]; +}; + +export const PROPOSAL_MARKERS = [ + "date", + "number", + "client_name", + "attention_name", + "attention_position", + "jurisdiction_text", + "service_title", + "service_pitch", + "challenge", + "risks", + "objective", + "scope", + "title", + "description", + "methodology_intro", + "phases", + "name", + "steps", + "compliance", + "deliverables", + "items", + "out_of_scope", + "schedule", + "phase", + "duration", + "effort_note", + "requirements", + "test_accounts", + "lines", + "amount", + "total", + "included", + "extras", + "label", + "price", + "unit", + "payments", + "percent", +] as const; + +export function unitSuffix(unit: string): string { + const trimmed = unit.trim(); + return /^[\p{L}\p{N}]/u.test(trimmed) ? ` ${trimmed}` : trimmed; +} + +export function proposalTotalCents(lines: ProposalLine[]): number { + return lines.reduce((sum, line) => sum + toCents(line.amount), 0); +} + +export function templateData(doc: ProposalDocument) { + const { content } = doc; + const totalCents = proposalTotalCents(doc.lines); + const shares = splitByPercent( + totalCents, + doc.payments.map((payment) => payment.percent), + ); + + return { + date: formatProposalDate(doc.date), + number: doc.number, + client_name: doc.clientName, + attention_name: doc.attentionName, + attention_position: doc.attentionPosition, + jurisdiction_text: doc.jurisdictionText, + service_title: content.title, + service_pitch: content.pitch, + challenge: content.challenge, + risks: content.risks, + objective: content.objective, + scope: content.scope.map((item) => ({ + title: item.title, + description: item.description, + })), + methodology_intro: content.methodologyIntro, + phases: content.phases.map((phase) => ({ + name: phase.name, + steps: phase.steps, + })), + compliance: content.compliance, + deliverables: content.deliverables.map((group) => ({ + name: group.name, + items: group.items, + })), + out_of_scope: content.outOfScope, + schedule: content.schedule.map((row) => ({ + phase: row.phase, + duration: row.duration, + })), + effort_note: content.effortNote, + requirements: content.requirements, + test_accounts: content.testAccounts, + lines: doc.lines.map((line) => ({ + description: line.description, + amount: formatEur(line.amount), + })), + total: formatEur(totalCents / 100), + included: content.included, + extras: doc.extras.map((extra) => ({ + label: extra.label, + price: formatEur(extra.price), + unit: unitSuffix(extra.unit), + })), + payments: doc.payments.map((payment, index) => ({ + percent: payment.percent, + label: payment.label, + amount: formatEur((shares[index] ?? 0) / 100), + })), + }; +} + +export type TemplateData = ReturnType; diff --git a/apps/api/src/proposals/proposal-docx.ts b/apps/api/src/proposals/proposal-docx.ts new file mode 100644 index 000000000..b5012671a --- /dev/null +++ b/apps/api/src/proposals/proposal-docx.ts @@ -0,0 +1,232 @@ +import Docxtemplater from "docxtemplater"; +import InspectModule from "docxtemplater/js/inspect-module.js"; +import PizZip from "pizzip"; +import { z } from "zod"; +import { PROPOSAL_MARKERS, type TemplateData } from "./proposal-data"; +import { PROPOSALS } from "./proposals.config"; + +const TRANSPARENT_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg==", + "base64", +); + +const KNOWN_MARKERS = new Set(PROPOSAL_MARKERS); + +export type LogoSlot = { width: number; height: number }; + +type TemplateInspection = { + markers: string[]; + unknown: string[]; + logoSlot: LogoSlot | null; +}; + +export class TemplateError extends Error { + constructor( + message: string, + readonly details: string[], + ) { + super(message); + } +} + +function openZip(file: Buffer): PizZip { + try { + return new PizZip(file); + } catch { + throw new TemplateError("The file is not a Word document (.docx).", []); + } +} + +function compile(zip: PizZip, modules: InspectModule[] = []): Docxtemplater { + if (!zip.file("word/document.xml")) { + throw new TemplateError("The file is not a Word document (.docx).", []); + } + + try { + return new Docxtemplater(zip, { + paragraphLoop: true, + linebreaks: true, + nullGetter: () => "", + errorLogging: false, + modules, + }); + } catch (cause) { + throw new TemplateError( + "The template has broken markers.", + templateErrorDetails(cause), + ); + } +} + +const templateFault = z.object({ + message: z.string().optional(), + properties: z.object({ explanation: z.string().optional() }).optional(), +}); + +const templateFailure = templateFault.extend({ + properties: z + .object({ + explanation: z.string().optional(), + errors: z.array(templateFault).optional(), + }) + .optional(), +}); + +function templateErrorDetails(cause: unknown): string[] { + const parsed = templateFailure.safeParse(cause); + if (!parsed.success) return [String(cause)]; + + const nested = parsed.data.properties?.errors ?? []; + const faults = nested.length > 0 ? nested : [parsed.data]; + + return faults.map( + (fault) => + fault.properties?.explanation ?? fault.message ?? "Unknown error.", + ); +} + +type TagTree = { [marker: string]: TagTree }; + +const tagTree: z.ZodType = z.lazy(() => z.record(z.string(), tagTree)); + +function flattenTags(tags: TagTree, into: Set) { + for (const [key, value] of Object.entries(tags)) { + if (key !== ".") into.add(key); + flattenTags(value, into); + } +} + +type LogoTarget = { path: string; extentRatio: number }; + +function logoTargets(zip: PizZip): LogoTarget[] { + const document = zip.file("word/document.xml")?.asText() ?? ""; + const rels = zip.file("word/_rels/document.xml.rels")?.asText() ?? ""; + const descr = new RegExp( + `]*descr="${PROPOSALS.logo.slotDescr}"`, + ); + const targets = new Map(); + + for (const drawing of document.matchAll( + /[\s\S]*?<\/w:drawing>/g, + )) { + if (!descr.test(drawing[0])) continue; + + const extent = drawing[0].match(/ 0 + ? Number(extent[1]) / Number(extent[2]) + : 1; + + for (const embed of drawing[0].matchAll(/r:embed="([^"]+)"/g)) { + const rel = rels.match( + new RegExp(`Id="${embed[1]}"[^>]*Target="([^"]+)"`), + ); + if (!rel?.[1]) continue; + const path = `word/${rel[1].replace(/^\.?\//, "")}`; + if (!targets.has(path)) targets.set(path, { path, extentRatio }); + } + } + + return [...targets.values()]; +} + +function pngSize(bytes: Uint8Array): LogoSlot | null { + const isPng = + bytes.length > 24 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47; + if (!isPng) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + return { width: view.getUint32(16), height: view.getUint32(20) }; +} + +function jpegSize(bytes: Uint8Array): LogoSlot | null { + if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return null; + let offset = 2; + + while (offset + 9 < bytes.length) { + if (bytes[offset] !== 0xff) return null; + const marker = bytes[offset + 1] ?? 0; + const length = ((bytes[offset + 2] ?? 0) << 8) | (bytes[offset + 3] ?? 0); + if ( + marker >= 0xc0 && + marker <= 0xcf && + ![0xc4, 0xc8, 0xcc].includes(marker) + ) { + return { + height: ((bytes[offset + 5] ?? 0) << 8) | (bytes[offset + 6] ?? 0), + width: ((bytes[offset + 7] ?? 0) << 8) | (bytes[offset + 8] ?? 0), + }; + } + offset += 2 + length; + } + + return null; +} + +export function imageSize(bytes: Uint8Array): LogoSlot | null { + return pngSize(bytes) ?? jpegSize(bytes); +} + +function slotOf(zip: PizZip): LogoSlot | null { + const [first] = logoTargets(zip); + if (!first) return null; + + const media = zip.file(first.path)?.asUint8Array(); + const size = media ? imageSize(media) : null; + if (size && size.width > 0 && size.height > 0) return size; + + return { width: Math.round(first.extentRatio * 1000), height: 1000 }; +} + +export function inspectTemplate(file: Buffer): TemplateInspection { + const zip = openZip(file); + const inspect = new InspectModule(); + compile(zip, [inspect]); + + const notPng = logoTargets(zip).filter( + (target) => !target.path.toLowerCase().endsWith(".png"), + ); + if (notPng.length > 0) { + throw new TemplateError( + "The client logo image in the template must be a PNG.", + notPng.map((target) => target.path), + ); + } + + const found = new Set(); + flattenTags(tagTree.parse(inspect.getAllTags()), found); + const markers = [...found].sort(); + + return { + markers, + unknown: markers.filter((marker) => !KNOWN_MARKERS.has(marker)), + logoSlot: slotOf(zip), + }; +} + +export function renderProposal( + file: Buffer, + data: TemplateData, + logo: Buffer | null, +): Buffer { + const doc = compile(openZip(file)); + + try { + doc.render(data); + } catch (cause) { + throw new TemplateError( + "The template could not be filled.", + templateErrorDetails(cause), + ); + } + + const zip = doc.getZip(); + for (const target of logoTargets(zip)) { + zip.file(target.path, logo ?? TRANSPARENT_PNG); + } + + return zip.generate({ type: "nodebuffer", compression: "DEFLATE" }); +} diff --git a/apps/api/src/proposals/proposal-format.ts b/apps/api/src/proposals/proposal-format.ts new file mode 100644 index 000000000..fdcf26364 --- /dev/null +++ b/apps/api/src/proposals/proposal-format.ts @@ -0,0 +1,53 @@ +import { PROPOSALS } from "./proposals.config"; + +export function toCents(amount: string | number): number { + const value = Number(amount); + + if (!Number.isFinite(value)) { + throw new Error(`"${amount}" is not an amount.`); + } + + return Math.round(value * 100); +} + +export function centsToDecimal(cents: number): string { + const sign = cents < 0 ? "-" : ""; + const abs = Math.abs(cents); + return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, "0")}`; +} + +export function formatEur(amount: string | number): string { + const { thousands, decimal, currencySuffix } = PROPOSALS.format; + const cents = toCents(amount); + const sign = cents < 0 ? "-" : ""; + const abs = Math.abs(cents); + const whole = String(Math.floor(abs / 100)).replace( + /\B(?=(\d{3})+(?!\d))/g, + thousands, + ); + const fraction = abs % 100; + const tail = + fraction === 0 ? "" : `${decimal}${String(fraction).padStart(2, "0")}`; + + return `${sign}${whole}${tail}${currencySuffix}`; +} + +export function formatProposalDate(date: Date): string { + const day = String(date.getUTCDate()).padStart(2, "0"); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); + return `${day}-${month}-${date.getUTCFullYear()}`; +} + +export function splitByPercent( + totalCents: number, + percents: number[], +): number[] { + let allotted = 0; + + return percents.map((percent, index) => { + if (index === percents.length - 1) return totalCents - allotted; + const share = Math.round((totalCents * percent) / 100); + allotted += share; + return share; + }); +} diff --git a/apps/api/src/proposals/proposals.config.ts b/apps/api/src/proposals/proposals.config.ts new file mode 100644 index 000000000..26b5abd59 --- /dev/null +++ b/apps/api/src/proposals/proposals.config.ts @@ -0,0 +1,25 @@ +const KB = 1024; +const MB = 1024 * KB; + +export const PROPOSALS = { + files: { + maxBytes: 3 * MB, + docxMediaType: + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + pngMediaType: "image/png", + }, + logo: { + maxBytes: 1 * MB, + slotDescr: "client_logo", + }, + format: { + thousands: ".", + decimal: ",", + currencySuffix: "€", + currency: "EUR", + }, + jurisdictions: { + PT: "Lei do Cibercrime (Lei n.º 109/2009) and applicable Portuguese and EU law", + ES: "applicable Spanish and EU law", + }, +} as const; diff --git a/apps/api/src/proposals/proposals.contracts.ts b/apps/api/src/proposals/proposals.contracts.ts new file mode 100644 index 000000000..739c6daf2 --- /dev/null +++ b/apps/api/src/proposals/proposals.contracts.ts @@ -0,0 +1,288 @@ +import { + eurAmount, + proposalContent, + proposalExtras, + proposalJurisdiction, + proposalLines, + proposalPayments, +} from "@crm/validation/proposal"; +import { z } from "zod"; +import { PROPOSALS } from "./proposals.config"; + +const id = z.string().trim().min(1); + +const base64 = (maxBytes: number) => + z + .string() + .min(1) + .max(Math.ceil((maxBytes * 4) / 3) + 4, "The file is too large.") + .regex( + /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/, + "The file content must be valid base64.", + ); + +const fileName = z.string().trim().min(1).max(200); + +const PROPOSAL_STATUSES = ["DRAFT", "SENT", "ACCEPTED", "REJECTED"] as const; + +const DEAL_STAGE_SUGGESTIONS = ["CONTRACT_SENT", "CLOSED_WON"] as const; + +export const accessOutput = z.object({ + canManage: z.boolean(), + maxFileBytes: z.number(), + maxLogoBytes: z.number(), +}); + +const issuerInput = z.object({ + name: z.string().trim().min(1).max(120), + numberPrefix: z.string().trim().max(40).default(""), + defaultPayments: proposalPayments, +}); + +export const createIssuerInput = issuerInput; + +export const updateIssuerInput = issuerInput.extend({ + id, + active: z.boolean(), +}); + +export const idInput = z.object({ id }); + +const logoSlotOutput = z + .object({ width: z.number(), height: z.number() }) + .nullable(); + +export const templateOutput = z.object({ + id: z.string(), + name: z.string(), + fileName: z.string(), + size: z.number(), + markers: z.array(z.string()), + logoSlot: logoSlotOutput, + createdAt: z.string(), + archived: z.boolean(), +}); + +export type TemplateOutput = z.infer; + +const issuerOutput = z.object({ + id: z.string(), + name: z.string(), + numberPrefix: z.string(), + defaultPayments: z.array( + z.object({ percent: z.number(), label: z.string() }), + ), + active: z.boolean(), + clients: z.number(), + proposals: z.number(), + templates: z.array(templateOutput), +}); + +export type IssuerOutput = z.infer; + +export const issuersOutput = z.array(issuerOutput); + +export const uploadTemplateInput = z.object({ + issuerId: id, + name: z.string().trim().min(1).max(120), + fileName, + contentBase64: base64(PROPOSALS.files.maxBytes), +}); + +export const fileOutput = z.object({ + fileName: z.string(), + contentBase64: z.string(), +}); + +export const companyInput = z.object({ companyId: id }); + +export const companyProfileOutput = z.object({ + companyId: z.string(), + issuerId: z.string().nullable(), + issuerName: z.string().nullable(), + proposals: z.number(), + logo: z + .object({ + contentBase64: z.string(), + width: z.number(), + height: z.number(), + }) + .nullable(), +}); + +export type CompanyProfileOutput = z.infer; + +export const setCompanyIssuerInput = z.object({ + companyId: id, + issuerId: id.nullable(), +}); + +export const setClientLogoInput = z.object({ + companyId: id, + contentBase64: base64(PROPOSALS.logo.maxBytes), +}); + +const priceInput = z.object({ + issuerId: id, + amount: eurAmount, + extras: proposalExtras, +}); + +export const saveServiceInput = z.object({ + id: id.optional(), + name: z.string().trim().min(1).max(200), + content: proposalContent, + prices: z.array(priceInput).max(20), +}); + +const priceOutput = z.object({ + issuerId: z.string(), + issuerName: z.string(), + amount: z.string(), + extras: z.array( + z.object({ label: z.string(), price: z.string(), unit: z.string() }), + ), +}); + +const serviceSummaryOutput = z.object({ + id: z.string(), + name: z.string(), + archived: z.boolean(), + prices: z.array(priceOutput), + updatedAt: z.string(), +}); + +export const servicesOutput = z.array(serviceSummaryOutput); + +export const serviceOutput = serviceSummaryOutput.extend({ + content: proposalContent, +}); + +export type ServiceOutput = z.infer; + +export const listServicesInput = z.object({ + archived: z.boolean().default(false), +}); + +export const dealInput = z.object({ dealId: id }); + +const versionOutput = z.object({ + id: z.string(), + version: z.number(), + number: z.string(), + date: z.string(), + status: z.enum(PROPOSAL_STATUSES), + statusChangedAt: z.string(), + total: z.string(), + issuerName: z.string(), + templateName: z.string(), + generatedFileName: z.string(), + finalFileName: z.string().nullable(), + finalUploadedAt: z.string().nullable(), + createdAt: z.string(), + createdByName: z.string().nullable(), +}); + +export type VersionOutput = z.infer; + +export const dealProposalsOutput = z.object({ + companyId: z.string(), + companyName: z.string(), + issuer: z.object({ id: z.string(), name: z.string() }).nullable(), + hasLogo: z.boolean(), + versions: z.array(versionOutput), +}); + +export const draftInput = z.object({ + dealId: id, + serviceId: id.optional(), + fromProposalId: id.optional(), +}); + +const draftTemplateOutput = z.object({ + id: z.string(), + name: z.string(), + logoSlot: logoSlotOutput, +}); + +export const draftOutput = z.object({ + dealId: z.string(), + serviceId: z.string().nullable(), + issuer: z.object({ id: z.string(), name: z.string() }), + clientName: z.string(), + templates: z.array(draftTemplateOutput), + contacts: z.array( + z.object({ id: z.string(), name: z.string(), title: z.string() }), + ), + number: z.string(), + date: z.string(), + attentionContactId: z.string().nullable(), + attentionName: z.string(), + attentionPosition: z.string(), + jurisdiction: proposalJurisdiction, + jurisdictionText: z.string(), + jurisdictionTexts: z.object({ PT: z.string(), ES: z.string() }), + content: proposalContent, + lines: proposalLines, + extras: proposalExtras, + payments: proposalPayments, +}); + +export type DraftOutput = z.infer; + +export const generateInput = z.object({ + dealId: id, + serviceId: id.nullable(), + templateId: id, + number: z.string().trim().min(1).max(120), + date: z.iso.date(), + attentionContactId: id.nullable(), + attentionName: z.string().trim().max(200).default(""), + attentionPosition: z.string().trim().max(200).default(""), + jurisdiction: proposalJurisdiction, + jurisdictionText: z.string().trim().min(1).max(1000), + content: proposalContent, + lines: proposalLines, + extras: proposalExtras, + payments: proposalPayments, + logoBase64: base64(PROPOSALS.logo.maxBytes).nullable(), +}); + +export type GenerateInput = z.infer; + +export const generateOutput = z.object({ + id: z.string(), + version: z.number(), + total: z.string(), + fileName: z.string(), + contentBase64: z.string(), +}); + +export const downloadInput = z.object({ + id, + which: z.enum(["preferred", "generated", "final"]).default("preferred"), +}); + +export const uploadFinalInput = z.object({ + id, + fileName, + contentBase64: base64(PROPOSALS.files.maxBytes), +}); + +export const setStatusInput = z.object({ + id, + status: z.enum(PROPOSAL_STATUSES), +}); + +export const setStatusOutput = z.object({ + id: z.string(), + dealId: z.string(), + status: z.enum(PROPOSAL_STATUSES), + suggestion: z.object({ + stage: z.enum(DEAL_STAGE_SUGGESTIONS).nullable(), + amountCents: z.number().nullable(), + currency: z.literal(PROPOSALS.format.currency).nullable(), + }), +}); + +export const okOutput = z.object({ ok: z.literal(true) }); diff --git a/apps/api/src/proposals/proposals.module.ts b/apps/api/src/proposals/proposals.module.ts new file mode 100644 index 000000000..5477fbda7 --- /dev/null +++ b/apps/api/src/proposals/proposals.module.ts @@ -0,0 +1,19 @@ +import { Module } from "@nestjs/common"; +import { TrpcModule } from "../trpc/trpc.module"; +import { CatalogService } from "./catalog.service"; +import { IssuersService } from "./issuers.service"; +import { ProposalAccessService } from "./proposal-access.service"; +import { ProposalsRouter } from "./proposals.router"; +import { ProposalsService } from "./proposals.service"; + +@Module({ + imports: [TrpcModule], + providers: [ + ProposalAccessService, + IssuersService, + CatalogService, + ProposalsService, + ProposalsRouter, + ], +}) +export class ProposalsModule {} diff --git a/apps/api/src/proposals/proposals.router.ts b/apps/api/src/proposals/proposals.router.ts new file mode 100644 index 000000000..db9f0f073 --- /dev/null +++ b/apps/api/src/proposals/proposals.router.ts @@ -0,0 +1,355 @@ +import { Inject } from "@nestjs/common"; +import { + Ctx, + Input, + Mutation, + Query, + Router, + UseMiddlewares, +} from "nestjs-trpc"; +import type { z } from "zod"; +import type { AuthedTrpcContext } from "../trpc/context.types"; +import { AuthMiddleware } from "../trpc/middlewares/auth.middleware"; +import { restMeta } from "../trpc/openapi"; +import { CatalogService } from "./catalog.service"; +import { IssuersService } from "./issuers.service"; +import { ProposalAccessService } from "./proposal-access.service"; +import { PROPOSALS } from "./proposals.config"; +import { + accessOutput, + companyInput, + companyProfileOutput, + createIssuerInput, + dealInput, + dealProposalsOutput, + downloadInput, + draftInput, + draftOutput, + fileOutput, + generateInput, + generateOutput, + idInput, + issuersOutput, + listServicesInput, + okOutput, + saveServiceInput, + serviceOutput, + servicesOutput, + setClientLogoInput, + setCompanyIssuerInput, + setStatusInput, + setStatusOutput, + templateOutput, + updateIssuerInput, + uploadFinalInput, + uploadTemplateInput, +} from "./proposals.contracts"; +import { ProposalsService } from "./proposals.service"; + +const TAGS = ["Proposals"]; + +@Router({ alias: "proposals" }) +@UseMiddlewares(AuthMiddleware) +export class ProposalsRouter { + constructor( + @Inject(ProposalAccessService) + private readonly gate: ProposalAccessService, + @Inject(IssuersService) private readonly issuerService: IssuersService, + @Inject(CatalogService) private readonly catalog: CatalogService, + @Inject(ProposalsService) private readonly proposals: ProposalsService, + ) {} + + @Query({ + output: accessOutput, + meta: restMeta("GET", "/proposals/access", TAGS), + }) + async access(@Ctx() ctx: AuthedTrpcContext) { + return { + canManage: await this.gate.canManage(ctx.user.id), + maxFileBytes: PROPOSALS.files.maxBytes, + maxLogoBytes: PROPOSALS.logo.maxBytes, + }; + } + + @Query({ + output: issuersOutput, + meta: restMeta("GET", "/proposals/issuers", TAGS), + }) + async issuers(@Ctx() ctx: AuthedTrpcContext) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.list(); + } + + @Mutation({ + input: createIssuerInput, + output: idInput, + meta: restMeta("POST", "/proposals/issuers", TAGS), + }) + async createIssuer( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.create(input); + } + + @Mutation({ + input: updateIssuerInput, + output: idInput, + meta: restMeta("PATCH", "/proposals/issuers/{id}", TAGS), + }) + async updateIssuer( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.update(input); + } + + @Mutation({ + input: idInput, + output: okOutput, + meta: restMeta("DELETE", "/proposals/issuers/{id}", TAGS), + }) + async deleteIssuer( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.remove(input.id); + } + + @Mutation({ + input: uploadTemplateInput, + output: templateOutput, + meta: restMeta("POST", "/proposals/templates", TAGS), + }) + async uploadTemplate( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.uploadTemplate(ctx.user.id, input); + } + + @Mutation({ + input: idInput, + output: okOutput, + meta: restMeta("POST", "/proposals/templates/{id}/archive", TAGS), + }) + async archiveTemplate( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.archiveTemplate(input.id); + } + + @Query({ + input: idInput, + output: fileOutput, + meta: restMeta("GET", "/proposals/templates/{id}/file", TAGS), + }) + async downloadTemplate( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.downloadTemplate(input.id); + } + + @Query({ + input: companyInput, + output: companyProfileOutput, + meta: restMeta("GET", "/proposals/companies/{companyId}", TAGS), + }) + async companyProfile( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.companyProfile(input.companyId); + } + + @Mutation({ + input: setCompanyIssuerInput, + output: companyProfileOutput, + meta: restMeta("PUT", "/proposals/companies/{companyId}/issuer", TAGS), + }) + async setCompanyIssuer( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.setCompanyIssuer(input.companyId, input.issuerId); + } + + @Mutation({ + input: setClientLogoInput, + output: companyProfileOutput, + meta: restMeta("PUT", "/proposals/companies/{companyId}/logo", TAGS), + }) + async setClientLogo( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.setClientLogo(ctx.user.id, input); + } + + @Mutation({ + input: companyInput, + output: companyProfileOutput, + meta: restMeta("DELETE", "/proposals/companies/{companyId}/logo", TAGS), + }) + async removeClientLogo( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.issuerService.removeClientLogo(input.companyId); + } + + @Query({ + input: listServicesInput, + output: servicesOutput, + meta: restMeta("GET", "/proposals/services", TAGS), + }) + async services( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.catalog.list(input.archived); + } + + @Query({ + input: idInput, + output: serviceOutput, + meta: restMeta("GET", "/proposals/services/{id}", TAGS), + }) + async service( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.catalog.byId(input.id); + } + + @Mutation({ + input: saveServiceInput, + output: serviceOutput, + meta: restMeta("PUT", "/proposals/services", TAGS), + }) + async saveService( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.catalog.save(input); + } + + @Mutation({ + input: idInput, + output: okOutput, + meta: restMeta("POST", "/proposals/services/{id}/archive", TAGS), + }) + async archiveService( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.catalog.setArchived(input.id, true); + } + + @Mutation({ + input: idInput, + output: okOutput, + meta: restMeta("POST", "/proposals/services/{id}/restore", TAGS), + }) + async restoreService( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.catalog.setArchived(input.id, false); + } + + @Query({ + input: dealInput, + output: dealProposalsOutput, + meta: restMeta("GET", "/proposals/deals/{dealId}", TAGS), + }) + async forDeal( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.forDeal(input.dealId); + } + + @Query({ + input: draftInput, + output: draftOutput, + meta: restMeta("GET", "/proposals/deals/{dealId}/draft", TAGS), + }) + async draft( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.draft(input); + } + + @Mutation({ + input: generateInput, + output: generateOutput, + meta: restMeta("POST", "/proposals/deals/{dealId}/generate", TAGS), + }) + async generate( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.generate(ctx.user.id, input); + } + + @Query({ + input: downloadInput, + output: fileOutput, + meta: restMeta("GET", "/proposals/{id}/file", TAGS), + }) + async download( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.download(input.id, input.which); + } + + @Mutation({ + input: uploadFinalInput, + output: okOutput, + meta: restMeta("PUT", "/proposals/{id}/final", TAGS), + }) + async uploadFinal( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.uploadFinal(input); + } + + @Mutation({ + input: setStatusInput, + output: setStatusOutput, + meta: restMeta("PATCH", "/proposals/{id}/status", TAGS), + }) + async setStatus( + @Ctx() ctx: AuthedTrpcContext, + @Input() input: z.infer, + ) { + await this.gate.requireOwner(ctx.user.id); + return this.proposals.setStatus(input.id, input.status); + } +} diff --git a/apps/api/src/proposals/proposals.service.ts b/apps/api/src/proposals/proposals.service.ts new file mode 100644 index 000000000..633dba7fa --- /dev/null +++ b/apps/api/src/proposals/proposals.service.ts @@ -0,0 +1,564 @@ +import type { Db, JsonValue, ProposalStatus } from "@crm/db"; +import { + type ProposalContent, + type ProposalJurisdiction, + parseProposalContent, + parseProposalExtras, + parseProposalLines, + parseProposalPayments, + readProposalLogoSlot, +} from "@crm/validation/proposal"; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; +import { InjectDatabase } from "../database/database.constants"; +import { toBytes } from "./issuers.service"; +import { prismaCode } from "./prisma-code"; +import { + proposalTotalCents, + type TemplateData, + templateData, +} from "./proposal-data"; +import { imageSize, renderProposal, TemplateError } from "./proposal-docx"; +import { centsToDecimal } from "./proposal-format"; +import { PROPOSALS } from "./proposals.config"; +import type { + DraftOutput, + GenerateInput, + VersionOutput, +} from "./proposals.contracts"; + +const VERSION_SELECT = { + id: true, + version: true, + number: true, + date: true, + status: true, + statusChangedAt: true, + total: true, + issuerName: true, + templateName: true, + generatedFileName: true, + finalFileName: true, + finalUploadedAt: true, + createdAt: true, + createdById: true, +} as const; + +function stageFor( + status: ProposalStatus, +): "CONTRACT_SENT" | "CLOSED_WON" | null { + if (status === "SENT") return "CONTRACT_SENT"; + if (status === "ACCEPTED") return "CLOSED_WON"; + return null; +} + +function isoDate(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function contactName(contact: { + firstName: string; + lastName: string | null; +}): string { + return [contact.firstName, contact.lastName].filter(Boolean).join(" "); +} + +function safeFileName(value: string): string { + return value + .replace(/[\\/:*?"<>|]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .slice(0, 150); +} + +function defaultJurisdiction(countryCode: string | null): ProposalJurisdiction { + return countryCode?.toUpperCase() === "ES" ? "ES" : "PT"; +} + +function isPng(bytes: Buffer): boolean { + return bytes[1] === 0x50 && imageSize(bytes) !== null; +} + +@Injectable() +export class ProposalsService { + private readonly logger = new Logger(ProposalsService.name); + + constructor(@InjectDatabase() private readonly db: Db) {} + + async forDeal(dealId: string) { + const deal = await this.db.deal.findUnique({ + where: { id: dealId }, + select: { + company: { + select: { + id: true, + name: true, + issuer: { select: { id: true, name: true } }, + proposalLogo: { select: { companyId: true } }, + }, + }, + proposals: { select: VERSION_SELECT, orderBy: { version: "desc" } }, + }, + }); + + if (!deal) throw new NotFoundException(`No deal with id ${dealId}.`); + + return { + companyId: deal.company.id, + companyName: deal.company.name, + issuer: deal.company.issuer, + hasLogo: deal.company.proposalLogo !== null, + versions: await this.toVersions(deal.proposals), + }; + } + + private async toVersions( + rows: Array<{ + id: string; + version: number; + number: string; + date: Date; + status: ProposalStatus; + statusChangedAt: Date; + total: { toFixed(digits: number): string }; + issuerName: string; + templateName: string; + generatedFileName: string; + finalFileName: string | null; + finalUploadedAt: Date | null; + createdAt: Date; + createdById: string; + }>, + ): Promise { + const authorIds = [...new Set(rows.map((row) => row.createdById))]; + const authors = await this.db.user.findMany({ + where: { id: { in: authorIds } }, + select: { id: true, name: true }, + }); + const nameOf = new Map(authors.map((author) => [author.id, author.name])); + + return rows.map((row) => ({ + id: row.id, + version: row.version, + number: row.number, + date: isoDate(row.date), + status: row.status, + statusChangedAt: row.statusChangedAt.toISOString(), + total: row.total.toFixed(2), + issuerName: row.issuerName, + templateName: row.templateName, + generatedFileName: row.generatedFileName, + finalFileName: row.finalFileName, + finalUploadedAt: row.finalUploadedAt?.toISOString() ?? null, + createdAt: row.createdAt.toISOString(), + createdByName: nameOf.get(row.createdById) ?? null, + })); + } + + private async dealContext(dealId: string) { + const deal = await this.db.deal.findUnique({ + where: { id: dealId }, + select: { + id: true, + company: { + select: { + name: true, + countryCode: true, + issuer: { + select: { + id: true, + name: true, + active: true, + numberPrefix: true, + defaultPayments: true, + templates: { + where: { archivedAt: null }, + select: { id: true, name: true, logoSlot: true }, + orderBy: { createdAt: "desc" }, + }, + }, + }, + }, + }, + contacts: { + select: { + contact: { + select: { + id: true, + firstName: true, + lastName: true, + title: true, + }, + }, + }, + orderBy: { contact: { firstName: "asc" } }, + }, + }, + }); + + if (!deal) throw new NotFoundException(`No deal with id ${dealId}.`); + + const issuer = deal.company.issuer; + if (!issuer) { + throw new BadRequestException( + "Set the issuer of this client before creating a proposal.", + ); + } + if (!issuer.active) { + throw new BadRequestException( + `The issuer ${issuer.name} is deactivated. Choose another issuer for this client.`, + ); + } + + return { deal, issuer }; + } + + async draft(input: { + dealId: string; + serviceId?: string; + fromProposalId?: string; + }): Promise { + const { deal, issuer } = await this.dealContext(input.dealId); + const contacts = deal.contacts.map(({ contact }) => ({ + id: contact.id, + name: contactName(contact), + title: contact.title ?? "", + })); + const first = contacts[0]; + + const base = { + dealId: deal.id, + issuer: { id: issuer.id, name: issuer.name }, + clientName: deal.company.name, + templates: issuer.templates.map((template) => ({ + id: template.id, + name: template.name, + logoSlot: readProposalLogoSlot(template.logoSlot), + })), + contacts, + date: isoDate(new Date()), + jurisdictionTexts: { ...PROPOSALS.jurisdictions }, + }; + + if (input.fromProposalId) { + const previous = await this.db.proposal.findFirst({ + where: { id: input.fromProposalId, dealId: deal.id }, + }); + if (!previous) { + throw new NotFoundException( + `No proposal with id ${input.fromProposalId} on this deal.`, + ); + } + + return { + ...base, + serviceId: previous.serviceId, + number: previous.number, + attentionContactId: previous.attentionContactId, + attentionName: previous.attentionName, + attentionPosition: previous.attentionPosition, + jurisdiction: previous.jurisdiction, + jurisdictionText: previous.jurisdictionText, + content: parseProposalContent(previous.content), + lines: parseProposalLines(previous.lines), + extras: parseProposalExtras(previous.extras), + payments: parseProposalPayments(previous.payments), + }; + } + + if (!input.serviceId) { + throw new BadRequestException("Choose a service or a previous version."); + } + + const service = await this.db.service.findUnique({ + where: { id: input.serviceId }, + select: { + id: true, + archivedAt: true, + content: true, + prices: { + where: { issuerId: issuer.id }, + select: { amount: true, extras: true }, + }, + }, + }); + + if (!service || service.archivedAt) { + throw new NotFoundException(`No service with id ${input.serviceId}.`); + } + + const price = service.prices[0]; + if (!price) { + throw new BadRequestException( + `This service has no price for ${issuer.name}.`, + ); + } + + const content: ProposalContent = parseProposalContent(service.content); + const jurisdiction = defaultJurisdiction(deal.company.countryCode); + + return { + ...base, + serviceId: service.id, + number: issuer.numberPrefix, + attentionContactId: first?.id ?? null, + attentionName: first?.name ?? "", + attentionPosition: first?.title ?? "", + jurisdiction, + jurisdictionText: PROPOSALS.jurisdictions[jurisdiction], + content, + lines: [{ description: content.title, amount: price.amount.toFixed(2) }], + extras: parseProposalExtras(price.extras), + payments: this.issuerPayments(issuer.defaultPayments), + }; + } + + private issuerPayments(value: JsonValue) { + try { + return parseProposalPayments(value); + } catch { + return [{ percent: 100, label: "upon acceptance" }]; + } + } + + async generate(userId: string, input: GenerateInput) { + const { deal, issuer } = await this.dealContext(input.dealId); + + const template = await this.db.issuerTemplate.findFirst({ + where: { id: input.templateId, issuerId: issuer.id, archivedAt: null }, + select: { id: true, name: true, content: true, logoSlot: true }, + }); + if (!template) { + throw new BadRequestException( + `That template does not belong to ${issuer.name}.`, + ); + } + + if ( + input.attentionContactId && + !deal.contacts.some( + ({ contact }) => contact.id === input.attentionContactId, + ) + ) { + throw new BadRequestException("The A/C contact is not on this deal."); + } + + const logo = input.logoBase64 + ? Buffer.from(input.logoBase64, "base64") + : null; + if (logo && !isPng(logo)) { + throw new BadRequestException("The client logo must be a PNG image."); + } + + const date = new Date(`${input.date}T00:00:00.000Z`); + const totalCents = proposalTotalCents(input.lines); + const file = this.render( + Buffer.from(template.content), + templateData({ + date, + number: input.number, + clientName: deal.company.name, + attentionName: input.attentionName, + attentionPosition: input.attentionPosition, + jurisdictionText: input.jurisdictionText, + content: input.content, + lines: input.lines, + extras: input.extras, + payments: input.payments, + }), + readProposalLogoSlot(template.logoSlot) ? logo : null, + ); + + const created = await this.insertVersion(deal.id, (version) => ({ + dealId: deal.id, + version, + issuerId: issuer.id, + issuerName: issuer.name, + templateId: template.id, + templateName: template.name, + serviceId: input.serviceId, + number: input.number, + date, + attentionContactId: input.attentionContactId, + attentionName: input.attentionName, + attentionPosition: input.attentionPosition, + jurisdiction: input.jurisdiction, + jurisdictionText: input.jurisdictionText, + content: input.content, + lines: input.lines, + extras: input.extras, + payments: input.payments, + total: centsToDecimal(totalCents), + generatedFile: toBytes(file), + generatedFileName: `${safeFileName( + `${input.number} ${deal.company.name} v${version}`, + )}.docx`, + createdById: userId, + })); + + this.logger.log({ + message: "Proposal generated", + proposalId: created.id, + dealId: deal.id, + version: created.version, + issuerId: issuer.id, + templateId: template.id, + bytes: file.length, + }); + + return { + id: created.id, + version: created.version, + total: centsToDecimal(totalCents), + fileName: created.generatedFileName, + contentBase64: file.toString("base64"), + }; + } + + private render( + template: Buffer, + data: TemplateData, + logo: Buffer | null, + ): Buffer { + try { + return renderProposal(template, data, logo); + } catch (cause) { + if (cause instanceof TemplateError) { + throw new BadRequestException( + [cause.message, ...cause.details].join(" "), + ); + } + throw cause; + } + } + + private async insertVersion< + Data extends Parameters[0]["data"], + >(dealId: string, build: (version: number) => Data) { + for (let attempt = 0; attempt < 3; attempt += 1) { + const last = await this.db.proposal.aggregate({ + where: { dealId }, + _max: { version: true }, + }); + const version = (last._max.version ?? 0) + 1; + + try { + return await this.db.proposal.create({ + data: build(version), + select: { id: true, version: true, generatedFileName: true }, + }); + } catch (cause) { + if (prismaCode(cause) !== "P2002") throw cause; + } + } + + throw new ConflictException( + "Another version was saved at the same time. Try again.", + ); + } + + async download(id: string, which: "preferred" | "generated" | "final") { + const row = await this.db.proposal.findUnique({ + where: { id }, + select: { + generatedFile: true, + generatedFileName: true, + finalFile: true, + finalFileName: true, + }, + }); + if (!row) throw new NotFoundException(`No proposal with id ${id}.`); + + const wantsFinal = + which === "final" || (which === "preferred" && row.finalFile !== null); + + if (wantsFinal) { + if (!row.finalFile || !row.finalFileName) { + throw new NotFoundException("This version has no final file."); + } + return { + fileName: row.finalFileName, + contentBase64: Buffer.from(row.finalFile).toString("base64"), + }; + } + + return { + fileName: row.generatedFileName, + contentBase64: Buffer.from(row.generatedFile).toString("base64"), + }; + } + + async uploadFinal(input: { + id: string; + fileName: string; + contentBase64: string; + }) { + if (!input.fileName.toLowerCase().endsWith(".docx")) { + throw new BadRequestException( + "Only Word documents (.docx) are accepted.", + ); + } + + const content = Buffer.from(input.contentBase64, "base64"); + if (content[0] !== 0x50 || content[1] !== 0x4b) { + throw new BadRequestException("The file is not a Word document (.docx)."); + } + + try { + await this.db.proposal.update({ + where: { id: input.id }, + data: { + finalFile: toBytes(content), + finalFileName: safeFileName(input.fileName), + finalUploadedAt: new Date(), + }, + select: { id: true }, + }); + } catch (cause) { + if (prismaCode(cause) === "P2025") { + throw new NotFoundException(`No proposal with id ${input.id}.`); + } + throw cause; + } + + return { ok: true as const }; + } + + async setStatus(id: string, status: ProposalStatus) { + const current = await this.db.proposal.findUnique({ + where: { id }, + select: { status: true }, + }); + if (!current) throw new NotFoundException(`No proposal with id ${id}.`); + + const row = await this.db.proposal.update({ + where: { id }, + data: + current.status === status + ? {} + : { status, statusChangedAt: new Date() }, + select: { id: true, dealId: true, status: true, total: true }, + }); + + const stage = stageFor(status); + const offersAmount = status === "ACCEPTED"; + + return { + id: row.id, + dealId: row.dealId, + status: row.status, + suggestion: { + stage, + amountCents: offersAmount + ? Math.round(row.total.toNumber() * 100) + : null, + currency: offersAmount ? PROPOSALS.format.currency : null, + }, + }; + } +} diff --git a/apps/api/test/fixtures/broken-loop.docx b/apps/api/test/fixtures/broken-loop.docx new file mode 100644 index 000000000..847d2dcc1 Binary files /dev/null and b/apps/api/test/fixtures/broken-loop.docx differ diff --git a/apps/api/test/fixtures/proposal-template.docx b/apps/api/test/fixtures/proposal-template.docx new file mode 100644 index 000000000..9598c8830 Binary files /dev/null and b/apps/api/test/fixtures/proposal-template.docx differ diff --git a/apps/api/test/fixtures/unknown-marker.docx b/apps/api/test/fixtures/unknown-marker.docx new file mode 100644 index 000000000..7af353b43 Binary files /dev/null and b/apps/api/test/fixtures/unknown-marker.docx differ diff --git a/apps/api/test/proposal-docx.spec.ts b/apps/api/test/proposal-docx.spec.ts new file mode 100644 index 000000000..db28f59f5 --- /dev/null +++ b/apps/api/test/proposal-docx.spec.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import PizZip from "pizzip"; +import { templateData } from "../src/proposals/proposal-data"; +import { + inspectTemplate, + renderProposal, + TemplateError, +} from "../src/proposals/proposal-docx"; + +const fixture = (name: string) => + readFileSync(join(import.meta.dir, "fixtures", name)); + +const LOGO = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAEUlEQVR4nGP4z8DwnwEIGAEA/wH/eKDUAAAAAElFTkSuQmCC", + "base64", +); + +function documentText(file: Buffer): string { + const zip = new PizZip(file); + return (zip.file("word/document.xml")?.asText() ?? "") + .replace(/]/g, "\n$&") + .replace(/<[^>]+>/g, ""); +} + +function logoBytes(file: Buffer): Buffer { + const zip = new PizZip(file); + const media = Object.keys(zip.files).find((name) => + name.startsWith("word/media/"), + ); + return Buffer.from(zip.file(media ?? "")?.asUint8Array() ?? []); +} + +const data = templateData({ + date: new Date("2026-09-29T00:00:00.000Z"), + number: "MMN-TST-0001", + clientName: "Acme Lda", + attentionName: "Jane Doe", + attentionPosition: "CISO", + jurisdictionText: "applicable Spanish and EU law", + content: { + title: "Web Application Penetration Test", + pitch: "a comprehensive web application penetration test", + challenge: "", + risks: [], + objective: "", + scope: [], + methodologyIntro: "", + phases: [ + { name: "PHASE 1: Recon", steps: ["Passive recon", "Mapping"] }, + { name: "PHASE 2: Scan", steps: ["Scanning"] }, + { name: "PHASE 3: Exploit", steps: ["Exploitation"] }, + { name: "PHASE 4: Report", steps: ["Reporting"] }, + ], + compliance: "", + deliverables: [], + outOfScope: "", + schedule: [ + { phase: "Recon", duration: "Days 1-2" }, + { phase: "Scanning", duration: "Days 2-4" }, + { phase: "Testing", duration: "Days 4-10" }, + { phase: "Report", duration: "Days 11-14" }, + { phase: "Delivery", duration: "Day 14" }, + ], + effortNote: "", + requirements: [], + testAccounts: "", + included: [], + }, + lines: [{ description: "Web Application Penetration Test", amount: "5200" }], + extras: [], + payments: [ + { percent: 50, label: "deposit" }, + { percent: 50, label: "final payment" }, + ], +}); + +describe("templateData", () => { + it("computes the total and the instalment amounts", () => { + expect(data.total).toBe("5.200€"); + expect(data.payments.map((p) => p.amount)).toEqual(["2.600€", "2.600€"]); + expect(data.date).toBe("29-09-2026"); + }); +}); + +describe("renderProposal", () => { + const out = renderProposal(fixture("proposal-template.docx"), data, LOGO); + const text = documentText(out); + + it("fills fields and leaves no marker behind", () => { + expect(text).toContain("29-09-2026 MMN-TST-0001"); + expect(text).toContain( + "For Acme Lda, we propose a comprehensive web application penetration test.", + ); + expect(text).not.toContain("{"); + expect(text).not.toContain("}"); + }); + + it("repeats nested loops and table rows", () => { + expect(text.match(/PHASE \d/g)).toHaveLength(4); + expect(text).toContain("Passive recon"); + expect(text).toContain("Days 11-14"); + expect(text.match(/Days? \d/g)).toHaveLength(5); + expect(text).toContain("50% deposit (2.600€)"); + expect(text).toMatch(/TOTAL\s+5\.200€/); + }); + + it("puts the client logo into the logo slot", () => { + expect(logoBytes(out).equals(LOGO)).toBe(true); + }); + + it("empties the logo slot when the client has no logo", () => { + const blank = renderProposal(fixture("proposal-template.docx"), data, null); + const bytes = logoBytes(blank); + expect(bytes.equals(LOGO)).toBe(false); + expect(bytes.subarray(1, 4).toString()).toBe("PNG"); + expect(bytes.length).toBeLessThan(100); + }); +}); + +describe("inspectTemplate", () => { + it("lists the markers and the logo slot", () => { + const inspection = inspectTemplate(fixture("proposal-template.docx")); + expect(inspection.unknown).toEqual([]); + expect(inspection.markers).toContain("phases"); + expect(inspection.markers).toContain("steps"); + expect(inspection.logoSlot).toEqual({ width: 300, height: 128 }); + }); + + it("reports a marker outside the contract", () => { + expect(inspectTemplate(fixture("unknown-marker.docx")).unknown).toEqual([ + "client_nme", + ]); + }); + + it("refuses a loop that is never closed", () => { + expect(() => inspectTemplate(fixture("broken-loop.docx"))).toThrow( + TemplateError, + ); + }); + + it("refuses a file that is not a Word document", () => { + expect(() => inspectTemplate(Buffer.from("%PDF-1.7"))).toThrow( + TemplateError, + ); + }); +}); diff --git a/apps/api/test/proposal-format.spec.ts b/apps/api/test/proposal-format.spec.ts new file mode 100644 index 000000000..0d50cd3b0 --- /dev/null +++ b/apps/api/test/proposal-format.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "bun:test"; +import { unitSuffix } from "../src/proposals/proposal-data"; +import { + centsToDecimal, + formatEur, + formatProposalDate, + splitByPercent, +} from "../src/proposals/proposal-format"; + +describe("formatEur", () => { + it("groups thousands with a dot and puts the euro sign after", () => { + expect(formatEur(10000)).toBe("10.000€"); + expect(formatEur("5200.00")).toBe("5.200€"); + expect(formatEur(1234567)).toBe("1.234.567€"); + }); + + it("shows cents only when they are not zero", () => { + expect(formatEur(1234.5)).toBe("1.234,50€"); + expect(formatEur("0.5")).toBe("0,50€"); + expect(formatEur(600)).toBe("600€"); + }); +}); + +describe("formatProposalDate", () => { + it("is day-month-year with dashes", () => { + expect(formatProposalDate(new Date("2026-09-29T00:00:00.000Z"))).toBe( + "29-09-2026", + ); + }); +}); + +describe("splitByPercent", () => { + it("splits a total into instalments", () => { + expect(splitByPercent(520000, [50, 50])).toEqual([260000, 260000]); + }); + + it("puts the rounding remainder on the last instalment", () => { + const parts = splitByPercent(10001, [33, 33, 34]); + expect(parts.reduce((a, b) => a + b, 0)).toBe(10001); + }); +}); + +describe("centsToDecimal", () => { + it("writes cents as a two-decimal string", () => { + expect(centsToDecimal(520000)).toBe("5200.00"); + expect(centsToDecimal(5)).toBe("0.05"); + }); +}); + +describe("unitSuffix", () => { + it("puts a space before a word and none before a slash", () => { + expect(unitSuffix("each")).toBe(" each"); + expect(unitSuffix(" each")).toBe(" each"); + expect(unitSuffix("/hour")).toBe("/hour"); + expect(unitSuffix("")).toBe(""); + }); +}); diff --git a/apps/api/test/proposals-db.integration.spec.ts b/apps/api/test/proposals-db.integration.spec.ts new file mode 100644 index 000000000..bb118adb7 --- /dev/null +++ b/apps/api/test/proposals-db.integration.spec.ts @@ -0,0 +1,114 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { db } from "@crm/db"; + +const suffix = process.env.TEST_RUN_ID ?? "proposals-db-spec"; +const userId = `user-${suffix}`; + +let issuerId: string; +let companyId: string; + +async function proposalFor(dealId: string, version: number) { + return db.proposal.create({ + data: { + dealId, + version, + issuerId, + issuerName: "Spec issuer", + templateName: "Spec template", + number: `SPEC-${version}`, + date: new Date("2026-09-29"), + jurisdiction: "PT", + jurisdictionText: "applicable Portuguese and EU law", + content: { title: "Spec" }, + lines: [{ description: "Spec", amount: "100.00" }], + extras: [], + payments: [{ percent: 100, label: "upfront" }], + total: "100.00", + generatedFile: Buffer.from("docx"), + generatedFileName: "spec.docx", + createdById: userId, + }, + }); +} + +beforeAll(async () => { + await db.user.upsert({ + where: { id: userId }, + create: { id: userId, name: "Spec", email: `${suffix}@example.test` }, + update: {}, + }); + const issuer = await db.issuer.create({ + data: { name: `Issuer ${suffix}`, defaultPayments: [] }, + }); + issuerId = issuer.id; + const company = await db.company.create({ + data: { name: `Company ${suffix}`, issuerId }, + }); + companyId = company.id; +}, 30_000); + +afterAll(async () => { + await db.company.deleteMany({ where: { id: companyId } }); + await db.issuer.deleteMany({ where: { id: issuerId } }); + await db.user.deleteMany({ where: { id: userId } }); +}, 30_000); + +describe("proposal storage", () => { + it("deletes a deal's proposals with the deal", async () => { + const deal = await db.deal.create({ + data: { name: `Deal ${suffix}`, companyId, ownerId: userId }, + }); + await proposalFor(deal.id, 1); + await proposalFor(deal.id, 2); + + await db.deal.delete({ where: { id: deal.id } }); + + expect(await db.proposal.count({ where: { dealId: deal.id } })).toBe(0); + }); + + it("refuses two proposals with the same version on one deal", async () => { + const deal = await db.deal.create({ + data: { name: `Deal twin ${suffix}`, companyId, ownerId: userId }, + }); + await proposalFor(deal.id, 1); + + let code: string | undefined; + try { + await proposalFor(deal.id, 1); + } catch (error) { + code = (error as { code?: string }).code; + } + expect(code).toBe("P2002"); + + await db.deal.delete({ where: { id: deal.id } }); + }); + + it("refuses to delete an issuer that a client uses", async () => { + let code: string | undefined; + try { + await db.issuer.delete({ where: { id: issuerId } }); + } catch (error) { + code = (error as { code?: string }).code; + } + expect(code).toBe("P2003"); + }); + + it("keeps the client logo out of an ordinary company read", async () => { + await db.clientLogo.create({ + data: { + companyId, + content: Buffer.from("png"), + width: 300, + height: 128, + uploadedById: userId, + }, + }); + + const company = await db.company.findUniqueOrThrow({ + where: { id: companyId }, + }); + + expect(Object.keys(company)).not.toContain("proposalLogo"); + expect(JSON.stringify(company)).not.toContain("png"); + }); +}); diff --git a/apps/api/test/proposals.integration.spec.ts b/apps/api/test/proposals.integration.spec.ts new file mode 100644 index 000000000..a240cb8b3 --- /dev/null +++ b/apps/api/test/proposals.integration.spec.ts @@ -0,0 +1,471 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { WORKSPACE_ID } from "@crm/auth"; +import { db } from "@crm/db"; +import { SETTINGS_ID, writeReportingCurrency } from "@crm/db/settings"; +import type { AgentTriggerService } from "../src/agent/agent-trigger.service"; +import { ActivityStampService } from "../src/crm/activity-stamp.service"; +import { ConversionService } from "../src/currency/conversion.service"; +import { DealsService } from "../src/deals/deals.service"; +import { FieldsService } from "../src/fields/fields.service"; +import { CatalogService } from "../src/proposals/catalog.service"; +import { IssuersService } from "../src/proposals/issuers.service"; +import { ProposalAccessService } from "../src/proposals/proposal-access.service"; +import { uploadTemplateInput } from "../src/proposals/proposals.contracts"; +import { ProposalsService } from "../src/proposals/proposals.service"; +import { withDiscardedCrmEvents } from "./agent-trigger.stub"; + +const suffix = process.env.TEST_RUN_ID ?? "proposals-spec"; +const ownerId = `owner-${suffix}`; +const memberId = `member-${suffix}`; + +const access = new ProposalAccessService(db); +const issuers = new IssuersService(db); +const catalog = new CatalogService(db); +const proposals = new ProposalsService(db); +const deals = new DealsService( + db, + { withCrmEvents: withDiscardedCrmEvents } as unknown as AgentTriggerService, + new ActivityStampService(db), + new ConversionService(db), + new FieldsService(db, { fieldBackfill: async () => undefined } as never), +); + +const template = readFileSync( + join(import.meta.dir, "fixtures", "proposal-template.docx"), +).toString("base64"); +const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAABCAYAAAD0In+KAAAAEUlEQVR4nGP4z8DwnwEIGAEA/wH/eKDUAAAAAElFTkSuQmCC", + "base64", +).toString("base64"); + +const payments = [ + { percent: 50, label: "deposit required to schedule testing" }, + { percent: 50, label: "final payment upon delivery" }, +]; + +let issuerB: string; +let issuerA: string; +let companyId: string; +let dealId: string; +let contactId: string; +let serviceId: string; +let templateId: string; +let previousReporting: string | null = null; + +async function codeOf(run: () => Promise): Promise { + try { + await run(); + return null; + } catch (error) { + return (error as { getStatus?: () => number }).getStatus?.() ?? -1; + } +} + +async function generateFrom( + draft: Awaited>, +) { + return proposals.generate(ownerId, { + dealId, + serviceId: draft.serviceId, + templateId, + number: draft.number, + date: draft.date, + attentionContactId: draft.attentionContactId, + attentionName: draft.attentionName, + attentionPosition: draft.attentionPosition, + jurisdiction: draft.jurisdiction, + jurisdictionText: draft.jurisdictionText, + content: draft.content, + lines: draft.lines, + extras: draft.extras, + payments: draft.payments, + logoBase64: PNG, + }); +} + +beforeAll(async () => { + const existing = await db.appSetting.findUnique({ + where: { id: SETTINGS_ID }, + select: { reportingCurrency: true }, + }); + previousReporting = existing?.reportingCurrency ?? null; + await writeReportingCurrency(db, "EUR"); + + await db.organization.upsert({ + where: { id: WORKSPACE_ID }, + create: { + id: WORKSPACE_ID, + name: "CRM", + slug: "crm", + createdAt: new Date(), + }, + update: {}, + }); + for (const [id, role] of [ + [ownerId, "owner"], + [memberId, "member"], + ] as const) { + await db.user.upsert({ + where: { id }, + create: { id, name: `User ${id}`, email: `${id}@example.test` }, + update: {}, + }); + await db.member.upsert({ + where: { + organizationId_userId: { organizationId: WORKSPACE_ID, userId: id }, + }, + create: { + id: `m-${id}`, + organizationId: WORKSPACE_ID, + userId: id, + role, + createdAt: new Date(), + }, + update: { role }, + }); + } + + const company = await db.company.create({ + data: { name: `Acme ${suffix}`, countryCode: "ES" }, + }); + companyId = company.id; + const contact = await db.contact.create({ + data: { + firstName: "Jane", + lastName: "Doe", + title: "CISO", + email: `jane-${suffix}@example.test`, + companyId, + }, + }); + contactId = contact.id; + const deal = await db.deal.create({ + data: { + name: `Pentest ${suffix}`, + companyId, + ownerId, + currency: "EUR", + contacts: { create: { contactId } }, + }, + }); + dealId = deal.id; +}, 60_000); + +afterAll(async () => { + await db.deal.deleteMany({ where: { companyId } }); + await db.contact.deleteMany({ where: { companyId } }); + await db.company.deleteMany({ where: { id: companyId } }); + await db.servicePrice.deleteMany({ + where: { issuerId: { in: [issuerB, issuerA] } }, + }); + await db.service.deleteMany({ where: { name: { endsWith: suffix } } }); + await db.issuerTemplate.deleteMany({ + where: { issuerId: { in: [issuerB, issuerA] } }, + }); + await db.issuer.deleteMany({ where: { id: { in: [issuerB, issuerA] } } }); + await db.member.deleteMany({ + where: { userId: { in: [ownerId, memberId] } }, + }); + await db.user.deleteMany({ where: { id: { in: [ownerId, memberId] } } }); + if (previousReporting) await writeReportingCurrency(db, previousReporting); +}, 60_000); + +describe("access", () => { + it("lets the owner in and keeps a member out", async () => { + expect(await access.canManage(ownerId)).toBe(true); + expect(await access.canManage(memberId)).toBe(false); + expect(await codeOf(() => access.requireOwner(memberId))).toBe(403); + expect(await codeOf(() => access.requireOwner(ownerId))).toBeNull(); + }); +}); + +describe("issuers", () => { + it("creates issuers with default instalments", async () => { + issuerB = ( + await issuers.create({ + name: `Emissor B ${suffix}`, + numberPrefix: "MMN-", + defaultPayments: payments, + }) + ).id; + issuerA = ( + await issuers.create({ + name: `Emissor A ${suffix}`, + numberPrefix: "MMN-A-", + defaultPayments: payments, + }) + ).id; + + const listed = (await issuers.list()).find((row) => row.id === issuerB); + expect(listed?.defaultPayments).toEqual(payments); + }); + + it("refuses a second issuer with the same name", async () => { + expect( + await codeOf(() => + issuers.create({ + name: `Emissor B ${suffix}`, + numberPrefix: "", + defaultPayments: payments, + }), + ), + ).toBe(409); + }); +}); + +describe("templates", () => { + it("accepts a valid template and reports its markers and logo slot", async () => { + const row = await issuers.uploadTemplate(ownerId, { + issuerId: issuerB, + name: "Emissor B proposal", + fileName: "modelo-emissor-b.docx", + contentBase64: template, + }); + templateId = row.id; + expect(row.markers).toContain("phases"); + expect(row.logoSlot).toEqual({ width: 300, height: 128 }); + }); + + it("refuses a PDF", async () => { + expect( + await codeOf(() => + issuers.uploadTemplate(ownerId, { + issuerId: issuerB, + name: "PDF", + fileName: "gruposm.pdf", + contentBase64: template, + }), + ), + ).toBe(400); + }); + + it("refuses a template with an unknown marker", async () => { + const unknown = readFileSync( + join(import.meta.dir, "fixtures", "unknown-marker.docx"), + ).toString("base64"); + expect( + await codeOf(() => + issuers.uploadTemplate(ownerId, { + issuerId: issuerB, + name: "Bad", + fileName: "bad.docx", + contentBase64: unknown, + }), + ), + ).toBe(400); + }); + + it("refuses a file above the size limit before it reaches the service", () => { + const big = "A".repeat(5 * 1024 * 1024); + expect( + uploadTemplateInput.safeParse({ + issuerId: issuerB, + name: "Big", + fileName: "big.docx", + contentBase64: big, + }).success, + ).toBe(false); + }); +}); + +describe("client issuer and logo", () => { + it("asks for an issuer before a proposal", async () => { + expect( + await codeOf(() => proposals.draft({ dealId, serviceId: "x" })), + ).toBe(400); + }); + + it("sets the issuer and the logo of the client", async () => { + await issuers.setCompanyIssuer(companyId, issuerB); + const profile = await issuers.setClientLogo(ownerId, { + companyId, + contentBase64: PNG, + }); + expect(profile.issuerName).toBe(`Emissor B ${suffix}`); + expect(profile.logo?.width).toBe(2); + }); + + it("refuses a logo that is not a PNG", async () => { + expect( + await codeOf(() => + issuers.setClientLogo(ownerId, { + companyId, + contentBase64: Buffer.from("GIF89a....").toString("base64"), + }), + ), + ).toBe(400); + }); + + it("refuses to delete an issuer in use", async () => { + expect(await codeOf(() => issuers.remove(issuerB))).toBe(409); + }); +}); + +describe("catalog", () => { + it("keeps one price per issuer", async () => { + const saved = await catalog.save({ + name: `Web App Pentest ${suffix}`, + content: { + title: "Web Application Penetration Test", + pitch: "a comprehensive web application penetration test", + challenge: "", + risks: ["Breach"], + objective: "", + scope: [{ title: "Input Validation", description: "SQLi, XSS" }], + methodologyIntro: "", + phases: [{ name: "PHASE 1", steps: ["Recon"] }], + compliance: "", + deliverables: [], + outOfScope: "", + schedule: [{ phase: "Recon", duration: "Days 1-2" }], + effortNote: "", + requirements: [], + testAccounts: "", + included: [], + }, + prices: [ + { + issuerId: issuerB, + amount: "5200.00", + extras: [{ label: "Extra retests", price: "600", unit: " each" }], + }, + { issuerId: issuerA, amount: "6100.00", extras: [] }, + ], + }); + serviceId = saved.id; + expect(saved.prices.map((p) => p.amount).sort()).toEqual([ + "5200.00", + "6100.00", + ]); + }); + + it("hides a service without a price for the client's issuer", async () => { + const other = await catalog.save({ + name: `Emissor A only ${suffix}`, + content: { title: "Only Emissor A" } as never, + prices: [{ issuerId: issuerA, amount: "100.00", extras: [] }], + }); + expect( + await codeOf(() => proposals.draft({ dealId, serviceId: other.id })), + ).toBe(400); + }); +}); + +describe("proposals", () => { + it("drafts from the catalog with the issuer's price and defaults", async () => { + const draft = await proposals.draft({ dealId, serviceId }); + expect(draft.lines).toEqual([ + { description: "Web Application Penetration Test", amount: "5200.00" }, + ]); + expect(draft.number).toBe("MMN-"); + expect(draft.jurisdiction).toBe("ES"); + expect(draft.attentionName).toBe("Jane Doe"); + expect(draft.payments).toEqual(payments); + }); + + it("generates v1 and v2, and v1 stays as it was", async () => { + const draft = await proposals.draft({ dealId, serviceId }); + const v1 = await generateFrom({ ...draft, number: "MMN-001" }); + expect(v1.version).toBe(1); + expect(v1.total).toBe("5200.00"); + + const again = await proposals.draft({ dealId, fromProposalId: v1.id }); + expect(again.number).toBe("MMN-001"); + const v2 = await generateFrom({ + ...again, + lines: [{ description: "Web App", amount: "4800.00" }], + }); + expect(v2.version).toBe(2); + + const listed = await proposals.forDeal(dealId); + expect(listed.versions.map((v) => [v.version, v.total])).toEqual([ + [2, "4800.00"], + [1, "5200.00"], + ]); + expect(listed.versions[1]?.createdByName).toBe(`User ${ownerId}`); + }); + + it("keeps the generated file byte for byte after the template is archived", async () => { + const [latest] = (await proposals.forDeal(dealId)).versions; + const before = await proposals.download(latest?.id ?? "", "preferred"); + await issuers.archiveTemplate(templateId); + const after = await proposals.download(latest?.id ?? "", "preferred"); + expect(after.contentBase64).toBe(before.contentBase64); + }); + + it("prefers the uploaded final file and keeps the generated one", async () => { + const [latest] = (await proposals.forDeal(dealId)).versions; + const id = latest?.id ?? ""; + const generated = await proposals.download(id, "generated"); + await proposals.uploadFinal({ + id, + fileName: "final.docx", + contentBase64: template, + }); + + expect((await proposals.download(id, "preferred")).fileName).toBe( + "final.docx", + ); + expect((await proposals.download(id, "generated")).contentBase64).toBe( + generated.contentBase64, + ); + }); + + it("suggests the deal stage and amount, and leaves other versions alone", async () => { + const [v2, v1] = (await proposals.forDeal(dealId)).versions; + const sent = await proposals.setStatus(v2?.id ?? "", "SENT"); + expect(sent.suggestion).toEqual({ + stage: "CONTRACT_SENT", + amountCents: null, + currency: null, + }); + + const accepted = await proposals.setStatus(v2?.id ?? "", "ACCEPTED"); + expect(accepted.suggestion).toEqual({ + stage: "CLOSED_WON", + amountCents: 480000, + currency: "EUR", + }); + + const rejected = await proposals.setStatus(v1?.id ?? "", "REJECTED"); + expect(rejected.suggestion.stage).toBeNull(); + + const after = await proposals.forDeal(dealId); + expect(after.versions.map((v) => v.status)).toEqual([ + "ACCEPTED", + "REJECTED", + ]); + }); + + it("keeps the old issuer on old versions after the client changes issuer", async () => { + const profile = await issuers.companyProfile(companyId); + expect(profile.proposals).toBe(2); + + await issuers.setCompanyIssuer(companyId, issuerA); + const after = await proposals.forDeal(dealId); + expect(after.issuer?.id).toBe(issuerA); + expect( + after.versions.every((v) => v.issuerName === `Emissor B ${suffix}`), + ).toBe(true); + await issuers.setCompanyIssuer(companyId, issuerB); + }); + + it("updates the deal amount through the deals service, with conversion", async () => { + await deals.update(dealId, { amountCents: 480000, currency: "EUR" }); + const deal = await db.deal.findUniqueOrThrow({ + where: { id: dealId }, + select: { + amount: true, + currency: true, + baseAmount: true, + baseCurrency: true, + }, + }); + expect(deal.amount?.toFixed(2)).toBe("4800.00"); + expect(deal.currency).toBe("EUR"); + expect(deal.baseCurrency).toBe("EUR"); + expect(deal.baseAmount?.toFixed(2)).toBe("4800.00"); + }); +}); diff --git a/apps/api/vercel.json b/apps/api/vercel.json index ffce1ccc0..2f6c5712a 100644 --- a/apps/api/vercel.json +++ b/apps/api/vercel.json @@ -3,7 +3,7 @@ "crons": [ { "path": "/internal/sync/mailboxes", - "schedule": "*/5 * * * *" + "schedule": "0 3 * * *" }, { "path": "/internal/sync/rates", diff --git a/apps/app/app/(app)/[slug]/settings/import/import-panel.tsx b/apps/app/app/(app)/[slug]/settings/import/import-panel.tsx new file mode 100644 index 000000000..8f2b9b751 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/import/import-panel.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@crm/ui/components/card"; +import { Spinner } from "@crm/ui/components/spinner"; +import { StatusIndicator } from "@crm/ui/components/status-indicator"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { parseCsv } from "@/lib/csv"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import { useWorkspaceUrl } from "@/lib/use-workspace-url"; + +const ACCEPTED_COLUMNS = + "company_name, company_domain, industry, country, country_code, " + + "first_name, last_name, email, title, linkedin_url, account_type, " + + "lead_source, idioma, evento_de_origem, mensagem_gancho, abordagem_sugerida"; + +export function ImportPanel() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const workspaceUrl = useWorkspaceUrl(); + const fileInput = useRef(null); + + const [fileName, setFileName] = useState(null); + const [rows, setRows] = useState[] | null>(null); + + const contactFields = useQuery( + trpc.fields.list.queryOptions({ + entity: "CONTACT", + includeArchived: false, + }), + ); + + const preview = useMutation( + trpc.import.preview.mutationOptions({ + onError: (error) => toast.error(error.message), + }), + ); + + const commit = useMutation( + trpc.import.commit.mutationOptions({ + onSuccess: async (data) => { + await cache.everything(); + toast.success( + `Importação concluída: ${data.companiesCreated} empresa(s) e ` + + `${data.contactsCreated} contato(s) criados.`, + ); + }, + onError: (error) => toast.error(error.message), + }), + ); + + async function pick(file: File | undefined) { + if (!file) return; + + const text = await file.text(); + const parsed = parseCsv(text); + + if (parsed.rows.length === 0) { + toast.error("O arquivo não tem linhas de dados."); + return; + } + + setFileName(file.name); + setRows(parsed.rows); + commit.reset(); + preview.mutate({ rows: parsed.rows }); + } + + const report = preview.data; + const blocked = + !report || + report.errors.length > 0 || + report.unrecognizedHeaders.length > 0; + + const eventOption = (() => { + if (!rows || !contactFields.data) return null; + const eventName = rows.find( + (row) => row.evento_de_origem, + )?.evento_de_origem; + if (!eventName) return null; + const field = contactFields.data.find( + (entry) => entry.key === "evento_de_origem", + ); + const option = field?.options.find((entry) => entry.label === eventName); + return field && option ? { key: field.key, optionId: option.id } : null; + })(); + + const contactsHref = eventOption + ? workspaceUrl( + `/contacts?fields=${encodeURIComponent( + JSON.stringify({ [eventOption.key]: [eventOption.optionId] }), + )}`, + ) + : workspaceUrl("/contacts"); + + return ( +
+ + + Carregar CSV + + Colunas aceitas: {ACCEPTED_COLUMNS}. + + + + { + void pick(event.target.files?.[0]); + event.target.value = ""; + }} + /> + + {fileName ? ( + {fileName} + ) : null} + + + + {report ? ( + + + Relatório (dry-run) + + Nada foi gravado ainda. Revise antes de confirmar. + + + +
+
+
Empresas a criar
+
+ {report.companiesToCreate} +
+
+
+
Empresas a atualizar
+
+ {report.companiesToUpdate} +
+
+
+
Contatos a criar
+
+ {report.contactsToCreate} +
+
+
+
Contatos a atualizar
+
+ {report.contactsToUpdate} +
+
+
+ + {report.unrecognizedHeaders.length > 0 ? ( +
+ + + {report.unrecognizedHeaders.join(", ")} + +
+ ) : null} + + {report.errors.length > 0 ? ( +
    + {report.errors.map((error) => ( +
  • + Linha {error.rowIndex + 2}: {error.message} +
  • + ))} +
+ ) : null} + + +
+
+ ) : null} + + {commit.data ? ( + + + Importação concluída + + {commit.data.companiesCreated} empresa(s) criada(s),{" "} + {commit.data.companiesUpdated} atualizada(s) ·{" "} + {commit.data.contactsCreated} contato(s) criado(s),{" "} + {commit.data.contactsUpdated} atualizado(s). + + + + + + + ) : null} +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/import/page.tsx b/apps/app/app/(app)/[slug]/settings/import/page.tsx new file mode 100644 index 000000000..0808417f0 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/import/page.tsx @@ -0,0 +1,39 @@ +import type { Metadata } from "next"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { ImportPanel } from "./import-panel"; + +export const metadata: Metadata = { + title: "Import", +}; + +export default async function ImportSettingsPage() { + await requireSession(); + + return ( + + + + Import + + Carregue um CSV de empresas e contatos. Revise o relatório antes de + confirmar — nada é gravado até você confirmar. + + + + + +
+ +
+
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/proposals/issuers-panel.tsx b/apps/app/app/(app)/[slug]/settings/proposals/issuers-panel.tsx new file mode 100644 index 000000000..a5159d35e --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/proposals/issuers-panel.tsx @@ -0,0 +1,444 @@ +"use client"; + +import { Badge } from "@crm/ui/components/badge"; +import { Button } from "@crm/ui/components/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@crm/ui/components/card"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@crm/ui/components/field"; +import { Input } from "@crm/ui/components/input"; +import { + SimpleTable, + type SimpleTableColumn, + SimpleTableRow, +} from "@crm/ui/components/simple-table"; +import { Spinner } from "@crm/ui/components/spinner"; +import { Switch } from "@crm/ui/components/switch"; +import { TableCell } from "@crm/ui/components/table"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useId, useRef, useState } from "react"; +import { toast } from "sonner"; +import { PaymentsEditor } from "@/components/crm/proposals/money-editors"; +import { PROPOSAL_UI } from "@/components/crm/proposals/proposal-config"; +import { + downloadBase64, + fileToBase64, +} from "@/components/crm/proposals/proposal-files"; +import type { ProposalPayment } from "@/components/crm/proposals/proposal-types"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import type { RouterOutputs } from "@/lib/trpc/types"; + +type Issuer = RouterOutputs["proposals"]["issuers"][number]; + +const DEFAULT_PAYMENTS: ProposalPayment[] = [ + { percent: 50, label: "deposit required to schedule testing" }, + { percent: 50, label: "final payment upon delivery" }, +]; + +const CELL = "px-3 py-2.5 align-middle"; + +const TEMPLATE_COLUMNS: SimpleTableColumn[] = [ + { id: "name", header: "Template" }, + { id: "markers", header: "Markers", width: "w-24", align: "right" }, + { id: "logo", header: "Client logo", width: "w-28" }, + { id: "actions", srLabel: "Actions", width: "w-44" }, +]; + +function megabytes(bytes: number): string { + return `${(bytes / (1024 * 1024)).toFixed(0)} MB`; +} + +export function IssuersPanel({ maxFileBytes }: { maxFileBytes: number }) { + const trpc = useTRPC(); + const issuers = useQuery(trpc.proposals.issuers.queryOptions()); + + return ( +
+ {(issuers.data ?? []).map((issuer) => ( + + ))} + +
+ ); +} + +function NewIssuerCard() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const nameId = useId(); + const prefixId = useId(); + const [name, setName] = useState(""); + const [prefix, setPrefix] = useState("MMN-"); + const [payments, setPayments] = useState(DEFAULT_PAYMENTS); + + const create = useMutation( + trpc.proposals.createIssuer.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success(`${name} added.`); + setName(""); + }, + onError: (error) => toast.error(error.message), + }), + ); + + return ( + + + New issuer + + A company you issue proposals for. Each client belongs to one issuer. + + + +
{ + event.preventDefault(); + create.mutate({ + name, + numberPrefix: prefix, + defaultPayments: payments, + }); + }} + > + + + Name + setName(event.target.value)} + required + /> + + + Proposal number prefix + setPrefix(event.target.value)} + /> + + A new proposal's number starts with this. You complete it. + + + + +
+
+ + + +
+ ); +} + +function IssuerCard({ + issuer, + maxFileBytes, +}: { + issuer: Issuer; + maxFileBytes: number; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const nameId = useId(); + const prefixId = useId(); + const activeId = useId(); + const [name, setName] = useState(issuer.name); + const [prefix, setPrefix] = useState(issuer.numberPrefix); + const [active, setActive] = useState(issuer.active); + const [payments, setPayments] = useState( + issuer.defaultPayments.length > 0 + ? issuer.defaultPayments + : DEFAULT_PAYMENTS, + ); + + const update = useMutation( + trpc.proposals.updateIssuer.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success(`${name} saved.`); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const remove = useMutation( + trpc.proposals.deleteIssuer.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success(`${issuer.name} deleted.`); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const inUse = issuer.clients + issuer.proposals + issuer.templates.length > 0; + const formId = `issuer-${issuer.id}`; + + return ( + + + {issuer.name} + + {issuer.clients} {issuer.clients === 1 ? "client" : "clients"} ·{" "} + {issuer.proposals} {issuer.proposals === 1 ? "proposal" : "proposals"} + + + + {issuer.active ? "Active" : "Deactivated"} + + + + +
{ + event.preventDefault(); + update.mutate({ + id: issuer.id, + name, + numberPrefix: prefix, + active, + defaultPayments: payments, + }); + }} + > + + + Name + setName(event.target.value)} + required + /> + + + Proposal number prefix + setPrefix(event.target.value)} + /> + + + + Active + + + +
+ +
+ + + {inUse ? null : ( + + )} + +
+ ); +} + +function Templates({ + issuer, + maxFileBytes, +}: { + issuer: Issuer; + maxFileBytes: number; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const nameId = useId(); + const fileInput = useRef(null); + const [templateName, setTemplateName] = useState(""); + const [lastMarkers, setLastMarkers] = useState(null); + const templates = issuer.templates.filter((template) => !template.archived); + + const upload = useMutation( + trpc.proposals.uploadTemplate.mutationOptions({ + onSuccess: async (template) => { + await cache.proposals(); + setTemplateName(""); + setLastMarkers(template.markers); + toast.success(`${template.name} uploaded.`); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const archive = useMutation( + trpc.proposals.archiveTemplate.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success("Template archived."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const queryClient = useQueryClient(); + const download = async (id: string) => { + try { + const file = await queryClient.fetchQuery({ + ...trpc.proposals.downloadTemplate.queryOptions({ id }), + staleTime: 0, + }); + downloadBase64(file.fileName, file.contentBase64); + } catch (error) { + toast.error((error as Error).message); + } + }; + + const pick = async (file: File | undefined) => { + if (!file) return; + if (!file.name.toLowerCase().endsWith(".docx")) { + toast.error("Only Word documents (.docx) are accepted."); + return; + } + if (file.size > maxFileBytes) { + toast.error(`The file is larger than ${megabytes(maxFileBytes)}.`); + return; + } + upload.mutate({ + issuerId: issuer.id, + name: templateName.trim() || file.name.replace(/\.docx$/i, ""), + fileName: file.name, + contentBase64: await fileToBase64(file), + }); + }; + + return ( +
+

Word templates

+ {templates.length > 0 ? ( + + {templates.map((template) => ( + + +
+ {template.name} + + {template.fileName} + +
+
+ + {template.markers.length} + + + {template.logoSlot ? "Yes" : "No"} + + +
+ + +
+
+
+ ))} +
+ ) : ( +

+ No template yet. Upload the Word file with the markers from the marker + guide. +

+ )} +
+ + Template name + setTemplateName(event.target.value)} + /> + + { + void pick(event.target.files?.[0]); + event.target.value = ""; + }} + /> + +
+ {lastMarkers ? ( +

+ Markers found: {lastMarkers.map((marker) => `{${marker}}`).join(" ")} +

+ ) : null} +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/proposals/page.tsx b/apps/app/app/(app)/[slug]/settings/proposals/page.tsx new file mode 100644 index 000000000..0775ee525 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/proposals/page.tsx @@ -0,0 +1,58 @@ +import type { Metadata } from "next"; +import { Suspense } from "react"; +import { + PageShell, + PageShellContent, + PageShellDescription, + PageShellHeader, + PageShellHeading, + PageShellLoading, + PageShellTitle, +} from "@/components/page-shell"; +import { requireSession } from "@/lib/session"; +import { HydrateClient } from "@/lib/trpc/hydrate"; +import { getServerQueryClient, getServerTrpc } from "@/lib/trpc/server"; +import { ProposalSettings } from "./proposal-settings"; + +export const metadata: Metadata = { + title: "Proposals", +}; + +export default function ProposalsSettingsPage() { + return ( + + + + Proposals + + The companies you issue proposals for, their Word templates, and the + services you sell with their prices. + + + + + + }> + + + + + ); +} + +async function Proposals() { + await requireSession(); + + const trpc = getServerTrpc(); + const queryClient = getServerQueryClient(); + + await queryClient.prefetchQuery(trpc.proposals.access.queryOptions()); + + return ( + +
+ +
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/proposals/proposal-settings.tsx b/apps/app/app/(app)/[slug]/settings/proposals/proposal-settings.tsx new file mode 100644 index 000000000..35f164c99 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/proposals/proposal-settings.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@crm/ui/components/card"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@crm/ui/components/tabs"; +import { useQuery } from "@tanstack/react-query"; +import { useTRPC } from "@/lib/trpc/client"; +import { IssuersPanel } from "./issuers-panel"; +import { ServicesPanel } from "./services-panel"; + +export function ProposalSettings() { + const trpc = useTRPC(); + const access = useQuery(trpc.proposals.access.queryOptions()); + + if (!access.data) return null; + + if (!access.data.canManage) { + return ( + + + Only the workspace owner uses proposals + + Issuers, templates, services and proposals are visible to the owner + of this workspace only. + + + + ); + } + + return ( + + + Issuers + Services + + + + + + + + + ); +} diff --git a/apps/app/app/(app)/[slug]/settings/proposals/service-sheet.tsx b/apps/app/app/(app)/[slug]/settings/proposals/service-sheet.tsx new file mode 100644 index 000000000..7ac9ad1f7 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/proposals/service-sheet.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@crm/ui/components/field"; +import { Input } from "@crm/ui/components/input"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@crm/ui/components/sheet"; +import { Spinner } from "@crm/ui/components/spinner"; +import { Switch } from "@crm/ui/components/switch"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { ProposalContentEditor } from "@/components/crm/proposals/content-editor"; +import { ExtrasEditor } from "@/components/crm/proposals/money-editors"; +import { PROPOSAL_UI } from "@/components/crm/proposals/proposal-config"; +import { + cleanContent, + EMPTY_CONTENT, + type ProposalContent, + type ProposalExtra, +} from "@/components/crm/proposals/proposal-types"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; + +type IssuerOption = { id: string; name: string; active: boolean }; + +type PriceDraft = { + offered: boolean; + amount: string; + extras: ProposalExtra[]; +}; + +type ServiceDraft = { + name: string; + content: ProposalContent; + prices: Record; +}; + +export function ServiceSheet({ + serviceId, + onClose, +}: { + serviceId: string | null; + onClose: () => void; +}) { + const trpc = useTRPC(); + const issuers = useQuery(trpc.proposals.issuers.queryOptions()); + const service = useQuery({ + ...trpc.proposals.service.queryOptions({ id: serviceId ?? "" }), + enabled: serviceId !== null, + }); + + const ready = issuers.data && (serviceId === null || service.data); + const options: IssuerOption[] = (issuers.data ?? []).map((issuer) => ({ + id: issuer.id, + name: issuer.name, + active: issuer.active, + })); + + const initial: ServiceDraft | null = ready + ? { + name: service.data?.name ?? "", + content: service.data?.content ?? EMPTY_CONTENT, + prices: Object.fromEntries( + options.map((issuer) => { + const price = service.data?.prices.find( + (row) => row.issuerId === issuer.id, + ); + return [ + issuer.id, + { + offered: Boolean(price), + amount: price?.amount ?? "", + extras: price?.extras ?? [], + }, + ]; + }), + ), + } + : null; + + return ( + (open ? null : onClose())}> + + + {serviceId ? "Edit service" : "New service"} + + Proposals copy this text and price. Changing it later does not + change proposals already made. + + + {initial ? ( + + ) : ( +
+ +
+ )} +
+
+ ); +} + +function ServiceForm({ + serviceId, + initial, + issuers, + onDone, +}: { + serviceId: string | null; + initial: ServiceDraft; + issuers: IssuerOption[]; + onDone: () => void; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const nameId = useId(); + const [draft, setDraft] = useState(initial); + + const save = useMutation( + trpc.proposals.saveService.mutationOptions({ + onSuccess: async (saved) => { + await cache.proposals(); + toast.success(`${saved.name} saved.`); + onDone(); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const setPrice = (issuerId: string, next: PriceDraft) => + setDraft({ ...draft, prices: { ...draft.prices, [issuerId]: next } }); + + return ( + <> +
{ + event.preventDefault(); + save.mutate({ + id: serviceId ?? undefined, + name: draft.name, + content: cleanContent(draft.content), + prices: Object.entries(draft.prices) + .filter(([, price]) => price.offered) + .map(([issuerId, price]) => ({ + issuerId, + amount: price.amount, + extras: price.extras.filter((extra) => extra.label.trim()), + })), + }); + }} + > + + + Catalog name + + setDraft({ ...draft, name: event.target.value }) + } + required + /> + + How you find it in the catalog. The document prints the service + title below. + + + + {issuers.map((issuer) => { + const price = draft.prices[issuer.id] ?? { + offered: false, + amount: "", + extras: [], + }; + return ( + setPrice(issuer.id, next)} + /> + ); + })} + + setDraft({ ...draft, content })} + /> + +
+ + + + + ); +} + +function PriceFields({ + issuer, + price, + onChange, +}: { + issuer: IssuerOption; + price: PriceDraft; + onChange: (next: PriceDraft) => void; +}) { + const offeredId = useId(); + const amountId = useId(); + + return ( +
+ + {issuer.name} + {issuer.active ? "" : " (deactivated)"} + +
+ + onChange({ ...price, offered })} + /> + + {issuer.name} offers this service + + + {price.offered ? ( + <> + + Price in euros + + onChange({ + ...price, + amount: event.target.value.replace(",", "."), + }) + } + required + /> + + onChange({ ...price, extras })} + /> + + ) : null} +
+
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/proposals/services-panel.tsx b/apps/app/app/(app)/[slug]/settings/proposals/services-panel.tsx new file mode 100644 index 000000000..20b289044 --- /dev/null +++ b/apps/app/app/(app)/[slug]/settings/proposals/services-panel.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { Button } from "@crm/ui/components/button"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@crm/ui/components/card"; +import { CardTableEmpty } from "@crm/ui/components/card-table"; +import { + SimpleTable, + type SimpleTableColumn, + SimpleTableRow, +} from "@crm/ui/components/simple-table"; +import { Switch } from "@crm/ui/components/switch"; +import { TableCell } from "@crm/ui/components/table"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { formatEur } from "@/components/crm/proposals/proposal-types"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import { ServiceSheet } from "./service-sheet"; + +const CELL = "px-3 py-2.5 align-middle"; + +const COLUMNS: SimpleTableColumn[] = [ + { id: "name", header: "Service" }, + { id: "prices", header: "Prices" }, + { id: "actions", srLabel: "Actions", width: "w-40" }, +]; + +type Editing = { id: string | null } | null; + +export function ServicesPanel() { + const trpc = useTRPC(); + const cache = useCrmCache(); + const archivedId = useId(); + const [archived, setArchived] = useState(false); + const [editing, setEditing] = useState(null); + + const services = useQuery(trpc.proposals.services.queryOptions({ archived })); + + const setArchivedState = useMutation( + trpc.proposals.archiveService.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success("Service archived."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const restore = useMutation( + trpc.proposals.restoreService.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success("Service restored."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const rows = services.data ?? []; + + return ( + + + Services + + One catalog for every issuer. Each service keeps the text a proposal + starts from, and a price per issuer. + + + + + + +
+ + +
+ {rows.length === 0 ? ( + + {archived ? "No archived service." : "No service yet."} + + ) : ( + + {rows.map((service) => ( + + + {service.name} + + + {service.prices.length === 0 + ? "No price" + : service.prices + .map( + (price) => + `${price.issuerName} ${formatEur(price.amount)}`, + ) + .join(" · ")} + + +
+ + {service.archived ? ( + + ) : ( + + )} +
+
+
+ ))} +
+ )} +
+ {editing ? ( + setEditing(null)} + /> + ) : null} +
+ ); +} diff --git a/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx b/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx index cdce91b4f..4fda8e754 100644 --- a/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx +++ b/apps/app/app/(app)/[slug]/settings/settings-sidebar.tsx @@ -2,9 +2,11 @@ import { Button } from "@crm/ui/components/button"; import { cn } from "@crm/ui/lib/utils"; +import { useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useMemo } from "react"; +import { useTRPC } from "@/lib/trpc/client"; import { useWorkspaceUrl } from "@/lib/use-workspace-url"; type SettingsNavItem = { @@ -19,11 +21,16 @@ const ITEMS: SettingsNavItem[] = [ { title: "Tracking & Analytics", href: `${ROOT}/tracking` }, { title: "Connections", href: `${ROOT}/connections` }, { title: "Currencies", href: `${ROOT}/currencies` }, + { title: "Import", href: `${ROOT}/import` }, { title: "Members", href: `${ROOT}/members` }, { title: "API Keys", href: `${ROOT}/api-keys` }, { title: "SSO", href: `${ROOT}/sso` }, ]; +const OWNER_ITEMS: SettingsNavItem[] = [ + { title: "Proposals", href: `${ROOT}/proposals` }, +]; + function isActive(href: string, root: string, pathname: string): boolean { return href === root ? pathname === href : pathname.startsWith(href); } @@ -105,11 +112,18 @@ export function SettingsSidebarFallback() { export function SettingsSidebar() { const pathname = usePathname(); const workspaceUrl = useWorkspaceUrl(); + const trpc = useTRPC(); + const access = useQuery(trpc.proposals.access.queryOptions()); + const canManageProposals = access.data?.canManage === true; const root = workspaceUrl(ROOT); const items = useMemo( - () => ITEMS.map((item) => ({ ...item, href: workspaceUrl(item.href) })), - [workspaceUrl], + () => + [...ITEMS, ...(canManageProposals ? OWNER_ITEMS : [])].map((item) => ({ + ...item, + href: workspaceUrl(item.href), + })), + [workspaceUrl, canManageProposals], ); return ( diff --git a/apps/app/components/crm/proposals/company-proposal-profile.tsx b/apps/app/components/crm/proposals/company-proposal-profile.tsx new file mode 100644 index 000000000..36984858d --- /dev/null +++ b/apps/app/components/crm/proposals/company-proposal-profile.tsx @@ -0,0 +1,260 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@crm/ui/components/alert-dialog"; +import { Button } from "@crm/ui/components/button"; +import { EntityLogo } from "@crm/ui/components/entity-logo"; +import { Field, FieldDescription, FieldLabel } from "@crm/ui/components/field"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@crm/ui/components/select"; +import { Spinner } from "@crm/ui/components/spinner"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useId, useRef, useState } from "react"; +import { toast } from "sonner"; +import { DetailSheetSection } from "@/components/detail-sheet"; +import { useCrmCache } from "@/lib/trpc/cache"; +import { useTRPC } from "@/lib/trpc/client"; +import { PROPOSAL_UI } from "./proposal-config"; +import { imageFileToPng } from "./proposal-files"; + +const NONE = "none"; + +export function CompanyProposalProfile({ companyId }: { companyId: string }) { + const trpc = useTRPC(); + const access = useQuery(trpc.proposals.access.queryOptions()); + + if (!access.data?.canManage) return null; + + return ( + + ); +} + +export function IssuerSelect({ + companyId, + id, +}: { + companyId: string; + id?: string; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const [pending, setPending] = useState(null); + + const profile = useQuery( + trpc.proposals.companyProfile.queryOptions({ companyId }), + ); + const issuers = useQuery(trpc.proposals.issuers.queryOptions()); + + const setIssuer = useMutation( + trpc.proposals.setCompanyIssuer.mutationOptions({ + onSuccess: async (next) => { + await cache.proposals(); + toast.success( + next.issuerName + ? `Proposals for this client now go out as ${next.issuerName}.` + : "This client has no issuer now.", + ); + }, + onError: (error) => toast.error(error.message), + }), + ); + + if (!profile.data) return null; + + const current = profile.data.issuerId ?? NONE; + const choose = (value: string) => { + if (value === current) return; + if (profile.data.proposals > 0) { + setPending(value); + return; + } + setIssuer.mutate({ companyId, issuerId: value === NONE ? null : value }); + }; + + const active = (issuers.data ?? []).filter( + (issuer) => issuer.active || issuer.id === profile.data.issuerId, + ); + + return ( + <> + + (open ? null : setPending(null))} + > + + + + Change the issuer of this client? + + + This client has {profile.data.proposals}{" "} + {profile.data.proposals === 1 ? "proposal" : "proposals"}. They + keep the issuer they were made with. New proposals use the new + issuer. + + + + Cancel + { + if (pending === null) return; + setIssuer.mutate({ + companyId, + issuerId: pending === NONE ? null : pending, + }); + setPending(null); + }} + > + Change issuer + + + + + + ); +} + +function ProposalProfileSection({ + companyId, + maxLogoBytes, +}: { + companyId: string; + maxLogoBytes: number; +}) { + const trpc = useTRPC(); + const cache = useCrmCache(); + const issuerId = useId(); + const fileInput = useRef(null); + const profile = useQuery( + trpc.proposals.companyProfile.queryOptions({ companyId }), + ); + + const setLogo = useMutation( + trpc.proposals.setClientLogo.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success("Logo saved for proposals."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const removeLogo = useMutation( + trpc.proposals.removeClientLogo.mutationOptions({ + onSuccess: async () => { + await cache.proposals(); + toast.success("Logo removed."); + }, + onError: (error) => toast.error(error.message), + }), + ); + + const pick = async (file: File | undefined) => { + if (!file) return; + try { + const contentBase64 = await imageFileToPng(file); + if ((contentBase64.length * 3) / 4 > maxLogoBytes) { + toast.error("The logo is too large. Use a smaller image."); + return; + } + setLogo.mutate({ companyId, contentBase64 }); + } catch (error) { + toast.error((error as Error).message); + } + }; + + const logo = profile.data?.logo ?? null; + + return ( + + + Issuer + + + Every proposal for this client goes out in the name of this company. + + + + Client logo + {logo ? ( + + ) : ( + + No logo. Templates with a client logo print an empty space. + + )} + { + void pick(event.target.files?.[0]); + event.target.value = ""; + }} + /> +
+ + {logo ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/app/components/crm/proposals/content-editor.tsx b/apps/app/components/crm/proposals/content-editor.tsx new file mode 100644 index 000000000..e1c21b254 --- /dev/null +++ b/apps/app/components/crm/proposals/content-editor.tsx @@ -0,0 +1,389 @@ +"use client"; + +import Add from "@carbon/icons-react/es/Add"; +import Close from "@carbon/icons-react/es/Close"; +import { Button } from "@crm/ui/components/button"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, +} from "@crm/ui/components/field"; +import { Icon } from "@crm/ui/components/icon"; +import { Input } from "@crm/ui/components/input"; +import { Textarea } from "@crm/ui/components/textarea"; +import { useId, useState } from "react"; +import { PROPOSAL_UI } from "./proposal-config"; +import { type ProposalContent, toLines } from "./proposal-types"; + +function TextField({ + label, + description, + value, + onChange, + rows = 3, +}: { + label: string; + description?: string; + value: string; + onChange: (value: string) => void; + rows?: number; +}) { + const id = useId(); + return ( + + {label} +